Rust ile CHIP-8 Emülatörü Geliştirme

Bu kitap Rust Programlama Dili ve farklı mimariler için emülatör yapımını öğrenmek isteyenler için yazılmış eğitici bir belgedir. Yazı programlamayı sıfırdan öğretmemektedir ve okuyucuların daha önce en az bir programlama dili bildiği varsayılmıştır. Aynı zamanda emülatör geliştirirken lazım olan bir çok bitdüzeyi (bitwise) işlem kullanılmıştır. Bitdüzeyi işlemler olabildiğince anlaşılır bir dilde açıklanmasına rağmen, okuyucuların konu hakkında daha önce bilgili olması anlamalarını daha da kolaylaştıracaktır.

Neden CHIP-8 Emülatörü Yazmalıyım?

CHIP-8 sadece 35 opcode'u bulunan çok basit bir sistemdir. Aynı zamanda bir çok opcode modern CPU'larda da kullanıldığından bu sistem için geliştirilecek bir emülatör projesi, gerekse modern CPU'ların çalışma prensibini ve temelinde bir sistem programlama dili olan Rust'ı öğrenmek için en iyi başlangıç projelerinden biridir. Yıllardır Rust programlama dilini öğrenmek isteyenlere bir CHIP-8 emülatörü yazarak başlamalarını tavsiye ederim.

Yazar Hakkında

Rust'ı stabil sürümü çıkmadan, beta sürecinden beri gündelik yaşamımda kullanmaktayım. Rust için belgeleme aracı olan Docs.rs'i geliştirdim ve Mozilla'nın Rust Programlama Dili organizasyonunda geliştirmesine devam etmekteyim. Bana ulaşabileceğiniz kanallar:

Kaynak Kodları

Bu kitapta kullanılan tüm kod örneklerine, projenin çalışabilir halinin tamamına ve kitabın kaynak kodlarına GitHub üzerinden erişilebilirsiniz: https://github.com/onur/chip8. Proje göndereceğiniz her türlü katkıya açıktır.

CHIP-8 Hakkında

CHIP-8 1970'lerin sonunda 1980'lerin başında kendi kendine yap (DIY) bilgisayar sistemleri için kullanılmış, basit, yorumlanan bir programlama dilidir. The COSMAC VIP, DREAM 6800 ve ETI 660 bu bilgisayarlar için birer örnektir.

Telmac

Telmac 1800, CHIP-8 oyunu Space Intercept çalıştırıyor.

Bu sayfanın geri kalanı referans niteliğindedir ve ihtiyaç duyulduğunda geri dönüp tekrar bakılmak üzere hazırlanmıştır. İsterseniz bu sayfayı okumayı geçebilirsiniz, yine de bir göz gezdirmenizde fayda var.

CHIP-8 Özellikleri

1. Bellek

CHIP-8 dili 0x000'dan 0xFFF'e kadar 4KB (4096 byte) RAM'e ulaşabilir. İlk 512 byte (0x000-0x1FF) yorumlayıcının bulunduğu alandır ve hiç bir program bu alanı kullanmamalıdır.

Çoğu CHIP-8 programı 0x200 alanından başlar. ETI-660 için olan bazı programlar 0x600'den başlar fakat bu bir istisna olduğundan görmezden geleceğiz.

Bellek haritası:

+---------------+= 0xFFF (4095) CHIP-8 RAM'inin sonu
|               |
|               |
|               |
|               |
| 0x200 - 0xFFF |
|    CHIP-8     |
|  Program ve   |
|  Veri Alanı   |
|               |
|               |
|               |
+---------------+= 0x200 (512) CHIP-8 programlarının başlangıcı
| 0x000 - 0x1FF |
|  Yorumlayıcı  |
| için ayrılmış |
|     alan      |
+---------------+= 0x000 (0) CHIP-8 RAM'inin başlangıcı

2. Registerlar (Yazmaçlar)

CHIP-8 8-bit boyutunda 16 tane genel kullanımlı register'a sahiptir. Bu registerlar genelde Vx olarak adlandırılır (x: 0 dan F'e olmak suretiyle 16 lık bir sayıdır). Bir tane de 16-bitlik I isimli register vardır. Bu register genelde bellek adresini tutmaya yarar ve bu nedenle I registerinin sadece en düşük, (sağdan) 12 biti kullanılır.

VF registeri hic bir program tarafından kullanılmamalıdır ve bu register bazı instructionların flag değerini saklamak için kullanılır.

CHIP-8 ayrıca, gecikme ve ses zamanlaması için, iki 8-bit uzunluğunda özel kullanımlı register'a sahiptir. Bu registerlar sıfır olmadığı zaman, 60 Hz oranında düşürülür.

Çalışmakta olan adresi tutan; 16-bit boyutunda program sayacı (PC: program counter) ve stack'in en üst adresini tutan; 8-bit uzunluğunda stack pointer (SP) sözde-registerları da vadır. Bu registerlara hiç bir program ulaşamaz.

Stack 16 tane 16-bitlik değerden oluşur.

3. Klavye

CHIP-8 dilinin kullanıldığı bilgisayarlar aşagıdaki şemadaki gibi 16 tuşlu klavyeye sahiptir:

+---+---+---+---+
| 1 | 2 | 3 | C |
| 4 | 5 | 6 | D |
| 7 | 8 | 9 | E |
| A | 0 | B | F |
+---+---+---+---+

Bu klavyenin modern bilgisayarlarda çalışabilmesi için tekrar haritalanması gerekmektedir.

4. Görüntü

CHIP-8 dili 64x32 boyutunda, aşağıdaki formata sahip siyah beyaz bir ekrana sahiptir:

+-------------------+
| (0,0)      (63,0) |
| (0,31)    (63,31) |
+-------------------+

Programlar 0'dan F'e kadar olmak üzere CHIP-8 belleği üzerinde yer alan spriteları da gösterebilir.

5. Zamanlayıcılar ve Ses

CHIP-8'de 2 tane zamanlayıcı bulunmaktadır: Gecikme ve ses zamanlayıcıları.

Gecikme zamanlayıcısı 0 olmadığı süre boyunca aktiftir ve her 60Hz yenileme sürecinden sonra bir azaltılır.

Ses zamanlayıcısı da aynı gecikme zamanlayıcısı gibi çalışır. Tek fark aktif olduğu süre boyunca CHIP-8 zili öter.

Ayrıntılı Bilgi

CHIP-8 hakkında daha fazla bilgi almak için: Cowgod'un Chip-8 Teknik Referans'ı sayfasına göz atabilirsiniz.

Başlangıç

Projeye başlamadan önce Rust Programlama Dili derleyicisinin bilgisayarınızda kurulu olması gerekmektedir. Rust derleyicisini işletim sisteminize göre Rust kurulumu sayfasında anlatıldığı gibi kurabilirsiniz. Bu kurulum bilgisayarınıza Rust Programlama Dili derleyicisi rustc ve paket yöneticisi cargo'yu kuracaktır. Cargo, Rust paketlerinin bağımlılığı çözüp, indirip derlemeye yarayan bir araçtır.

Yeni Proje Oluşturması

Cargo aynı zamanda yeni bir proje oluşturmanızı da sağlar. Yeni bir Rust projesi oluşturmak için komut satırından aşağıdaki komutları kullanın:

$ cargo new chip8
$ cd chip8

İkinci satırdaki komut, çalışma dizinimizi yeni projemizin içerisinde almaktadır. Bu adımdan sonra cargo build ile projenizi derleyebilir, cargo run ile çalıştırabilirsiniz. Yeni oluşturduğumuz projemizde cargo run komutunu kullandığımızda karşımıza Hello, world! yazısı çıkacaktır:

$ cargo run
   Compiling chip8 v0.1.0 (/home/onur/chip8)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/chip8`
Hello, world!

Rust paketlerinin meta verisi Cargo.toml içerisinde tutulmaktadır. Bu dosya içinde bağımlılıkları tanımlayabilirsiniz. Şu an için bizim bu dosya üzerinde yapacağımız bir değişiklik yok, yine de ön bilgi edinmek için bu dosyayı inceleyebilirsiniz. Cargo hakkında daha fazla bilgi almak için Cargo kitabını okuyabilirsiniz.

Rust Programlama Dili'nde her bir proje aynı zamanda bir pakettir. Bir paket; kütüphane veya çalıştırılabilir bir program olabilir. Eğer paketinizin kaynak dizini (src) içerisinde main.rs bulunursa, paketiniz çalıştırılabilir bir program olarak derlenir. cargo new ile oluşturulan bir paket, varsayılan olarak çalıştırılabilir bir program olarak oluşturulur. main.rs yerine lib.rs kullanan bir paket, kütüphane olarak tasarlanmıştır. Kütüphaneler aynı zamanda çalıştırılabilir programlarda barındırabilir. Biz bu projede çalıştırılabilir bir program yapacağımızdan, Cargo'nun oluşturduğu main.rs'e sadık kalacağız.

Bağımlılıkların Eklenmesi

Rust'ta paket bağımlılıkları Cargo.toml dosyası içerisinde [dependencies] bölümüne eklenir. Bu bölüme eklenen bağımlılıkları cargo, otomatikmen Rust paket deposu olan crates.io dan indirir derler ve uygulamızla birleştirir. Biz uygulamamızda sadece iki tane bağımlılık kullanacağız. Bunları [dependencies] bölümüne ekleyelim:

[dependencies]
rand = "0.7"
minifb = "0.13"

rand paketi rastgele bir sayı üretmeye yarar, minifb paketi en yaygın işletim sistemlerinde (Windows, OSX ve Linux) basit bir şekilde pencere oluşturup cizim yapmaya yarar.

Biz projemizde Rust 2021 yayınını kullandığımızdan, paketimiz içine tekrar extern crate tanımlaması yapmamıza gerek yok.

Opcode Yapısı

CHIP-8'in tüm OPCODE'ları 16-bit uzunluğundadır. Bu nedenle çok basit bir bir OPCODE yapısı tanımlayalım:

pub struct Opcode(u16);

Rust'ta yapılar struct anahtar kelimesiyle tanımlanır. Struct'lar:

  • Opcode yapımızda kullandığımız gibi bir isim sahibi tuple olabilir.
  • C dilindeki gibi elemanlar içerebilir.
  • Hiç bir eleman içermeden unit biçiminde olabilir.

Biz sadece 16-bitlik bir sayı üzerine eklemeler yapacağımız için, tuple cinsinden struct kullanıyoruz.

Yapımızın başına gelen pub anahtar kelimesi, yapıyı açık hale getirerek şu an kullandığımız modül dışında da erişimini mümkün kılmaktadır. pub anahtar kelimesi yapılar dışında; enumlar, metodlar, fonksiyonlar ve type ile kendi tanımladığınız tipler için de kullanılabilir.

Rust'ta yer alan primitif tipler (yapımızda kullandığımız u16), tek haneli bir önek ardından gelen boyut ile tanımlanır. Yani u8: 8-bit boyutunda bir unsigned (sadece pozitif sayıları tutabilen) bir sayı tipidir. u16 tahmin edilebileceği üzere 16-bit boyutunda bir unsigned sayı tipidir. Dilde yer alan diğer primitif tipleri görmek için, Rust kitabında yer alan primitif tipler bölümüne başvurulabilir.

16-bitlik bu OPCODE'un her bir basamağı (nibble) farklı anlamlara gelmektedir. Örneğin: 0x71AA OPCODE'u; 1. registerdeki değere 0xAA sayısını eklemeye yarar. Bu nedenle her bir basamağı alabilmek için, Opcode yapımıza yardımcı metodları ekleyelim:

impl Opcode {
    /// OPCODE üzerinde 0x0X00 değerini döner
    fn oxoo(&self) -> usize {
        ((self.0 & 0x0F00) >> 8) as usize
    }

    /// OPCODE üzerinde 0x00Y0 değerini döner
    fn ooyo(&self) -> usize {
        ((self.0 & 0x00F0) >> 4) as usize
    }

    /// OPCODE üzerinde 0x000N değerini döner
    fn ooon(&self) -> u8 {
        (self.0 & 0x000F) as u8
    }

    /// OPCODE üzerinde 0x00NN değerini döner
    fn oonn(&self) -> u8 {
        (self.0 & 0x00FF) as u8
    }

    /// OPCODE üzerinde 0x0NNN değerini döner
    fn onnn(&self) -> u16 {
        self.0 & 0x0FFF
    }
}

Rust'ta yapılara eklenecek metodlar impl bloğu içinde yer alır. Aynı zamanda metodların ilk parametresi: &self bu fonksiyonların sadece oluşturulan bir Opcode instance'ı ile çalışacağını belirtir.

Methodlar fonksiyonlar gibidir ve fn anahtar kelimesi kullanılarak tanımlanır. Parametreler, dönüş değeri ve çağrıldığında çalıştırılacak kod bloğuna sahiptirler. Methodların fonksiyonlardan farkı ise, bir yapı (struct veya enum) için tanımlanırlar ve ilk parametre o yapıya ait instance'a ulaşabileceğimiz self anahtar kelimesidir. Eğer metod çalıştırıldığında, instance sahipliğini almak istemiyorsak, self parametresi başına & koyularak referans olarak eklenir. Instance'ı referans olarak sunmayıp sahipliğini alsaydık, bu metodların kullanımının ardından instance sahipliği kaybolacaktı.

Metod adı ve parametrelerinin ardından döneceği tip -> dan sonra yazılır.

Yapımız tuple cinsinden bir struct olduğu için, yapı içerisindeki veriye aynı tuplelarda olduğu gibi self.0 ile ulaşılır.

Rust'ta primitif tipler (u8, u16 vb.) as anahtar kelimesiyle birbirine dönüştürüleblir. Fakat unutmayın ki boyutu büyük bir sayı (u16), as anahtar kelimesiyle daha küçük bir sayıya dönüştürülürken hiç bir uyarı vermeden sayıyı kırpabilir. Bu nedenle eğer büyük bir sayıyı küçük bir sayıya dönüştürmek istiyorsanız u8::from() metodunu kullanın. Biz geliştirdiğimiz uygulamada dönüştürülen sayıların 16-bit'ten küçük olduğunu bildiğimiz için as anahtar kelimesini kullanacağız.

Metod isimleri ilk bakışta garip gelebilir fakat, CHIP-8 OPCODE'larında her zaman soldan ikinci basamak x: ilk register numarasını, üçüncü basamak ise y: ikinci register numarasını içermektedir. İleride array cinsinden olan registerlarda daha rahat çalışabilmek için x ve y registerlarını dönen oxoo, ooyo metodları usize dönmektedir (Rust'ta array indisleri usize cinsinden olmalıdır).

Yapılan bitwise işlemleri açıklamak gerekirse, tekrar 0x71AA örneğini ele alalım. 0x71AA sayısında ikinci basamakta yer alan 1 değerini almak için öncelikle oxoo metodunu kullanmamız gerekli. Bu metod şu işlemleri yapmaktadır:

0x71AA     111000110101010  AND
0x0F00     000111100000000
0x0100     000000100000000  >> 8
0x0001     000000000000001  # 16-bit uzunluğundaki sayı, CHIP-8'de sadece 16
--------------------------  # register bulunduğundan 8-bit'e dönüştürülür:
0x01              00000001

Ardından 0xAA sayısı alınabilmesi için oonn metodu kullanılır:

0x71AA     111000110101010  AND
0x00FF     000000011111111
0x00AA     000000010101010  # Son iki basamak sadece 8-bit uzunluğunda
                            # olabileceğinden çıkan sonuç 8-bit'e dönüştürülür
--------------------------
0xAA              10101010

Sayıların OPCODE'a Dönüştürülmesi

İleride işimize yarayacağı için 16-bitlik bir sayının (u16), Opcode yapısına kolayca dönüştürülmesini sağlayan From özelliğini eklememiz gerekli. Rust'ta yapılara yeni özellikle şu şekilde eklenir:

impl From<u16> for Opcode {
    fn from(opcode: u16) -> Opcode {
        Opcode(opcode)
    }
}

From özelliği aynı zamanda Into özelliğini de otomatikmen eklediğinden, Opcode kullanmamız gereken parametrelerde Into<Opcode> genelleyici tipini kullanmamız yeterli olacak. Bu sayede Opcode hem verbose olarak Opcode(0xF1AA) hem de into metoduyla direkt bir sayıdan dönüştürülebilir.

Genelleyiciler (generics) ve özellikler (traits) çok kapsamlı bir konudur ve Rust'ın en önemli bileşenlerindendir. Şimdilik kafanızı çok fazla karıştırmanıza gerek yok fakat isterseniz Rust kitabında yer alan: Özellikler bölümünü okuyabilirsiniz.

Instuction Yapısı

Bu bölümde Rust'ta enum cinsinden yeni bir yapı tanımlama şeklini göreceğiz. Rust'ta enum'lar farklı biçimlerde yapılar içerebilir. Örneğin bir enum içerisinde yer alan yapı: unit benzeri hiç bir eleman içermeyen, tuple cinsinden ya da C benzeri bir struct olabilir. Her bir geçerli struct yapısı, aynı zamanda geçerli bir enum biçimidir.

Rust'ın yine en güçlü özelliklerinden biri olan pattern matching, enum yapısını kolay bir şekilde parçalayarak işlemeye yarar. Yazacağımız emülatörün okunabilir olabilmesi için, 16-bitlik sayı olarak okuduğumuz Opcode'u, enum ile okunabilir bir yapıya dönüştüreceğiz.

Bu işlemi yapmadan önce 16-bit uzunluğunda olan ve bellekte bir adresi ifade eden Address tipimizi ve array indislerinde kolayca kullanmamızı sağlayan Register tipimizi tanımlayalım:

pub type Address = u16;
pub type Register = usize;

Rust'ta yeni tipler type anahtar kelimesiyle tanımlanır. Başka bir tipi daha okunabilir bir hale getirmeye yarar. Bu sayede artık adres olduğunu bildiğimiz alanlar için u16 yerine Address tipini kullanabiliriz.

CHIP-8, 35 instructiona sahip bir yorumlayıcı. Bu OPCODE'ların hepsini bir enum içerisinde şu şekilde tanımlayabiliriz:

pub enum Instruction {
    /// 0x00E0 CLS: Ekranı temizler
    ClearDisplay,

    /// 0x00EE RET: Alt programdan döner
    Return,

    /// 0x1nnn JP: `nnn` adresine zıplar
    Jump(Address),

    /// 0x2nnn CALL: `nnn` adresindeki alt programı çağırır
    Call(Address),

    /// 0x3xnn SE: Eğer `x` registeri `nn`'e eşitse bir sonraki instruction'ı atlar
    SkipIfEqualsByte(Register, u8),

    /// 0x4xnn SE: Eğer `x` registeri `nn`'e eşit değilse bir sonraki instruction'ı atlar
    SkipIfNotEqualsByte(Register, u8),

    /// 0x5xy0 SE: Eğer `x` registeri `y` registerine eşitse
    /// bir sonraki instruction'ı atlar
    SkipIfEqual(Register, Register),

    /// 0x6xnn LD: `x` registerinin değerini `nn` yapar.
    LoadByte(Register, u8),

    /// 0x7xnn ADD: `x` registerindeki değere `nn` ekler.
    AddByte(Register, u8),

    /// 0x8xy0 LD: `x` registerinin değerini `y` registerinin değerine eşitler.
    Move(Register, Register),

    /// 0x8xy1 OR: `x` registerinin değerini `y` registerinin değeriyle
    /// bitwise OR işlemi yapar.
    Or(Register, Register),

    /// 0x8xy2 AND: `x` registerinin değerini `y` registerinin değeriyle
    /// bitwise AND işlemi yapar.
    And(Register, Register),

    /// 0x8xy3 XOR: `x` registerinin değerini `y` registerinin değeriyle
    /// bitwise XOR işlemi yapar.
    Xor(Register, Register),

    /// 0x8xy4 ADD: `x` registerinin değerini `y` registerinin değeriyle toplar ve `x`
    /// registerinin değerini sonuca eşitler. Çıkan sonuç 8 bitten fazla ise, `F` (carry)
    /// registerinin değerini 1 yapar, değilse 0.
    Add(Register, Register),

    /// 0x8xy5 SUB: `x` registerinin değerini `y` registerinin değerinden çıkarır ve `x`
    /// registerinin değerini sonuca eşitler. Eğer `x` registerindei değer, `y`
    /// registerindeki değerden büyükse, `F` (carry) registerinin değerini 1 yapar,
    /// değilse 0.
    Sub(Register, Register),

    /// 0x8xy6 SHR: `x` registerindeki değeri bir bit sağa kaydırır.
    /// Eğer `x` registerinin son biti 1 ise `F` (carry) registerinin değerini 1 yapar
    /// değilse 0.
    ShiftRight(Register),

    /// 0x8xy7 SUB: `y` registerinin değerini `x` registerinin değerinden çıkarır ve `x`
    /// registerinin değerini sonuca eşitler. Eğer `x` registerindei değer, `y`
    /// registerindeki değerden büyükse, `F` (carry) registerinin değerini 1 yapar,
    /// değilse 0.
    ReverseSub(Register, Register),

    /// 0x8xyE SHR: `x` registerindeki değeri bir bit sola kaydırır.
    /// Eğer `x` registerinin son biti 1 ise `F` (carry) registerinin değerini 1 yapar
    /// değilse 0.
    ShiftLeft(Register),

    /// 0x9xy0 SE: Eğer `x` registeri `y` registerine eşit değilse
    /// bir sonraki instruction'ı atlar
    SkipIfNotEqual(Register, Register),

    /// 0xAnnn LD: `I` registerinin değerini `nnn` yapar.
    LoadI(Address),

    /// 0xBnnn JP: `nnn` ve `V0` registerinin toplamından çıkan sonuca zıplar.
    JumpPlusZero(Address),

    /// 0xCxnn RND: Rastgele üretilen 8 bitlik sayı `nn` ile AND işleminden sonra
    /// çıkan sonuç `x` registerine atanır.
    Random(Register, u8),

    /// 0xDxyn DRW: `x` ve `y` registerinden başayarak `n` adet byte sprite'ı ekranda
    /// gösterir. Çakışma (collision) durumu `F` registerinde tutulur.
    Draw(Register, Register, u8),

    /// 0xEx9E SKP: `x` registerinde yer alan tuş basılırsa
    /// bir sonraki instruction'ı atlar
    SkipIfPressed(Register),

    /// 0xExA1 SKP: `x` registerinde yer alan tuş basılı değilse
    /// bir sonraki instruction'ı atlar
    SkipIfNotPressed(Register),

    /// 0xFx07 LD: `x` registerinin değerini delay timer yapar.
    LoadDelayTimer(Register),

    /// 0xFx0A LD: Bir tuşa basılmasını bekler ve basılan tuşun değerini `x`
    /// registerine atar. Tuş basılana kadar tüm çalıştırma durur.
    WaitForKeyPress(Register),

    /// 0xFx15 LD: Delay timer'ı `x` registerindeki değer yapar.
    SetDelayTimer(Register),

    /// 0xFx18 LD: Sound timer'ı `x` registerindeki değer yapar.
    SetSoundTimer(Register),

    /// 0xFx1E ADD: `I` registerinin değerini `I` ve `x` registerinin toplamı yapar.
    AddToI(Register),

    /// 0xFx29 LD: `I` registerinin değerini `x` registerinde yer alan değerden
    /// gelen sprite yeri yapar.
    LoadSprite(Register),

    /// 0xFx33 LD: `x` registerinin BCD (Binary Coded Decimal) cinsinden değerini:
    /// `I`, `I + 1`, `I + 2` alanlarında saklar.
    BCDRepresentation(Register),

    /// 0xFx55 LD: `I` registerinde yer alan alandan itibaren
    /// `0` dan `x` registerine kadar olan değerleri belleğe kopyalar.
    StoreRegisters(Register),

    /// 0xFx65 LD: `I` registerinde yer alan alandan itibaren
    /// bellekte yer alan değerleri `0` dan `x` registerine kopyalar.
    LoadRegisters(Register),
}

enum yapımızda içerisinde hiç bir veri tutmayan unit cinsinden ve veri barındıran tuple cinsinden bileşenleri görebilirsiniz. enum elemanları yukarıda da belirttiğimiz gibi bu iki türden de olabilir.

OPCODE'un Instruction'a Dönüştürülmesi

Rust'ta struct'lara olduğu gibi, enum'lara da metod ekleyebilirsiniz. Elimizde sayı halinde bulunan raw OPCODE'dan yeni bir Instruction instance'ı oluşturmak için new metodunu ekleyelim. Rust'ta yeni bir instance oluşturan metodlar genelde new ismiyle adlandırılır. Bu bir zorunluluk değil, istediğiniz ismi koyabilirsiniz.

impl Instruction {
    pub fn new<T: Into<Opcode>>(opcode: T) -> Option<Instruction> {
        let opcode = opcode.into();
        match opcode.0 & 0xF000 {
            0x0000 => match opcode.ooon() {
                0x0000 => Some(Instruction::ClearDisplay),
                0x000E => Some(Instruction::Return),
                _ => None,
            },
            0x1000 => Some(Instruction::Jump(opcode.onnn())),
            0x2000 => Some(Instruction::Call(opcode.onnn())),
            0x3000 => Some(Instruction::SkipIfEqualsByte(opcode.oxoo(), opcode.oonn())),
            0x4000 => Some(Instruction::SkipIfNotEqualsByte(
                opcode.oxoo(),
                opcode.oonn(),
            )),
            0x5000 => Some(Instruction::SkipIfEqual(opcode.oxoo(), opcode.ooyo())),
            0x6000 => Some(Instruction::LoadByte(opcode.oxoo(), opcode.oonn())),
            0x7000 => Some(Instruction::AddByte(opcode.oxoo(), opcode.oonn())),
            0x8000 => match opcode.ooon() {
                0x0000 => Some(Instruction::Move(opcode.oxoo(), opcode.ooyo())),
                0x0001 => Some(Instruction::Or(opcode.oxoo(), opcode.ooyo())),
                0x0002 => Some(Instruction::And(opcode.oxoo(), opcode.ooyo())),
                0x0003 => Some(Instruction::Xor(opcode.oxoo(), opcode.ooyo())),
                0x0004 => Some(Instruction::Add(opcode.oxoo(), opcode.ooyo())),
                0x0005 => Some(Instruction::Sub(opcode.oxoo(), opcode.ooyo())),
                0x0006 => Some(Instruction::ShiftRight(opcode.oxoo())),
                0x0007 => Some(Instruction::ReverseSub(opcode.oxoo(), opcode.ooyo())),
                0x000E => Some(Instruction::ShiftLeft(opcode.oxoo())),
                _ => None,
            },
            0x9000 => Some(Instruction::SkipIfNotEqual(opcode.oxoo(), opcode.ooyo())),
            0xA000 => Some(Instruction::LoadI(opcode.onnn())),
            0xB000 => Some(Instruction::JumpPlusZero(opcode.onnn())),
            0xC000 => Some(Instruction::Random(opcode.oxoo(), opcode.oonn())),
            0xD000 => Some(Instruction::Draw(
                opcode.oxoo(),
                opcode.ooyo(),
                opcode.ooon(),
            )),
            0xE000 => match opcode.oonn() {
                0x009E => Some(Instruction::SkipIfPressed(opcode.oxoo())),
                0x00A1 => Some(Instruction::SkipIfNotPressed(opcode.oxoo())),
                _ => None,
            },
            0xF000 => match opcode.oonn() {
                0x0007 => Some(Instruction::LoadDelayTimer(opcode.oxoo())),
                0x000A => Some(Instruction::WaitForKeyPress(opcode.oxoo())),
                0x0015 => Some(Instruction::SetDelayTimer(opcode.oxoo())),
                0x0018 => Some(Instruction::SetSoundTimer(opcode.oxoo())),
                0x001E => Some(Instruction::AddToI(opcode.oxoo())),
                0x0029 => Some(Instruction::LoadSprite(opcode.oxoo())),
                0x0033 => Some(Instruction::BCDRepresentation(opcode.oxoo())),
                0x0055 => Some(Instruction::StoreRegisters(opcode.oxoo())),
                0x0065 => Some(Instruction::LoadRegisters(opcode.oxoo())),
                _ => None,
            },
            _ => None,
        }
    }
}

Bu metodda Rust'a ait bir çok özellik bulunuyor. Öncelikle metod imzamızı inceleyelim:

    pub fn new<T: Into<Opcode>>(opcode: T) -> Option<Instruction> {

T tahmin edebileceğiniz gibi bir genelleyici (generic). Genelleyici tanımları metod adından sonra <> içerisinde yapılır. Genelleyicimiz Into<Opcode> özelliğine sahip bir parametre anlamına gelmektedir. Daha önce OPCODE'a eklediğimiz From özelliği sayesinde, opcode isiminli parametremiz, Opcode'a dönüştürülebilen herhangi bir tip olabilir. Bu sayede bu metodu istersek 16-bitlik bir sayı olarak da çalıştırabiliriz (Instruction::new(0xF155) gibi). Opcode'a eklediğimiz From özelliği, sayının otomatikmen Opcode tipine çevrilmesini sağlayacaktır.

Metodumuz aynı zamanda normal bir Instruction yerine Option<Instruction> dönmekte. Rust'ta yer alan Option tipi; opsiyonel bir değeri temsil etmektedir. Bu tip herhangi bir Some ya da hiç bir None değer taşıyabilir. CHIP-8'de sadece 35 OPCODE bulunduğundan genen raw OPCODE, Instruction tipine dönüştürülürken bilinen OPCODE'lar için Some(Instruction), bilinmeyenler için hiç bir değeri olan None dönüyoruz.

Metodumuz içerisinde yer alan let opcode = opcode.into(); satırı, yukarıda bahsettiğimiz genelleyici ile gelen Into<Opcode> özelliğine sahip opcode parametresini Opcode'a çevirmeye yarar. Aynı zamanda Rust gölgelemeye de izin verdiğinden, opcode değişkeni bu satırdan sonra Opcode tipine dönüşür.

Ardından gelen kod bloğunda raw OPCODE parçalanarak, okunabilir tipimiz olan Instruction'a dönüştürülüyor. Bu işlemi yaparken yine Rust'ın yine en önemli özelliklerinden biri olan pattern matching kullanıyoruz. match C de yer alan switch-case'e çok benzemesine rağmen, match edilen değerin tüm elemanlarını kapsamak zorundadır. Biz bu işlemi yaparken 16-bitlik bir sayı kullandığımızdan, işimize yarayan tüm değerleri aldık ve geri kalan ve işimize yaramayanlar içinde _ elemanını kullandık.

match bloğumuz Option<Instruction> döndüğü sürece, iç içe istediğimiz kadar match kullanabiliriz. Bu nedenle önce en soldaki nibble kontrol edildikten sonra, aynı nibble ile başlayan OPCODE'lar tekrar match ile kontrol edildip, OPCODE'a uyan bir Instruction tipi oluşturuluyor.

Instruction tipimizi içerisinde veri barındıran (daha önce tanımladığımız Address, Register vb.) bileşenler barındırdığından, Instruction tipi oluşturulurken bu bileşenlere gerekli değerler atanır. Bu işlemi yaparken daha önce Opcode tipine eklediğimiz yardımcı fonksiyonları kullanıyoruz.

Rust'ta her satır aynı zamanda bir deyim olduğundan ve match bloğumuz da aynı zamanda Option<Instruction> döndüğünden, return anahtar kelimesini kullanmamıza gerek yok. Deyim olarak kullanılan bu match bloğunun sonunda ; olmamasına dikkat edin.

Son olarak Rust'ta yorum satırları // ile başlar. Rust içerisinde çok gelişmiş bir belgeleme aracı (rustdoc) da bulundurmaktadır. Herhangi bir tanımdan önce (bu bir metod, fonksiyon, struct, bileşen ya da alan olabilir); 3 slash ile (///) oluşturacağınız yorum satırı, belgeleme aracı ile oluşturacağınız belgede o alan için tanımlama yapar. Kodlarken yazabileceğiniz bu yorum satırları, aynı zamanda herhangi bir Rust kütüphanesinin belgelemesini de çok kolay bir hale getirir.

Emulatör Yapısı

CHIP-8'in tüm sistem özelliklerini biliyoruz. Bunların hepsini emulator.rs içerisinde yeni bir yapı içerisinde tanımlayalım, bu sefer yapımızı tanımlarken içerisinde elemanları olan bir yapı tanımlayacağız:

pub struct Emulator {
    /// Bellek 4KB
    memory: [u8; 4096],

    /// Genel amaçlı registerlar
    v: [u8; 16],

    /// Bellek adresini tutmaya yarayan register
    i: u16,

    /// Program sayacı (program counter)
    pc: u16,

    /// Stack
    stack: [u16; 16],

    /// Stack'ın en üstünü tutan stack pointeri
    sp: u8,

    /// Gecikme zamanlayıcısı
    delay_timer: u8,

    /// Ses zamanlayıcısı
    sound_timer: u8,

    /// Görüntü yapısı
    display: Display,

    /// Klavye yapısı
    keyboard: Keyboard,
}

Rust static ve strong yazılımlı bir dil olduğundan, tüm alanların tipleri de tanımlanmak zorundadır. Diğer dillerin aksine tip tanımlamaları sağ tarafta yer alır.

Yeni Bir Emulator Instance'ı Oluşturulması

Öncelikle Emulator yapımızı, varsayılan değerleriyle bir instance'ını oluşturan yeni bir fonksiyon tanımlayalım.

impl Emulator {
    pub fn new() -> Emulator {
        let mut emulator = Emulator {
            memory: [0; 4096],
            v: [0; 16],
            i: 0x200,
            pc: 0x200,
            stack: [0; 16],
            sp: 0,
            delay_timer: 0,
            sound_timer: 0,
            display: Display::new(),
            keyboard: Keyboard::new(),
        };

        for (i, font) in FONTSET.iter().enumerate() {
            emulator.memory[i] = *font;
        }

        emulator
    }
}

Değişken tanımlamaları let anahtar kelimesi ile yapılır. Rust'ta her değişken varsayılan olarak immutable (içeriği değiştirilemez) haldedir. Biz fonksiyonumuzun devamında, emülatör belleğine font setini yükleyeceğimizden içeriği değiştirilebilir bir değişkene ihtiyacımız var. Bu nedenle let tanımlamamızın ardından mut anahtar kelimesini kullanıyoruz. mut anahtar kelimesi o değişkeni mutable (içeriği değiştirilebilir) duruma getirmektedir. CHIP-8 programları belleğin 0x200 alanından başlayacağından, I ve PC registerları 0x200 yapılır. Onun dışında her alan 0 olacak şekilde bir Emulator instance'ı oluşturulur.

Şu an için Display ve Keyboard yapılarını görmezden gelin. Görüntü ve klavye işlemlerinin yapıldığı bu yapılar ileride anlatılacaktır.

        for (i, font) in FONTSET.iter().enumerate() {
            emulator.memory[i] = *font;
        }

CHIP-8: 0 dan F'e olmak üzere 5 byte uzunluğunda sprite gösterme özelliğine sahiptir (16*5=80). Bu font bilgisi daha önce tanımlanmış bir değişken üzerinden emülatör belleğine yüklenir. FONTSET array'ının içeriğini kaynak kodlarında görebilirsiniz. Rust'ta arraylar Iterator özelliğine sahip olduğundan iter() metodu yardımıyla kolayca iterator'e dönüştürülebilir. enumerate metodu ise aynı Python'da olduğu gibi item ile birlikte iterasyon sayacını döner. Bu metod sayaç ve item değerlerini dönen bir tuple olduğundan, for döngüsü tuple değişken tanımlamalarıyla kullanılır.

Son olarak fonksiyonumuz içerisinde yer alan emulator satırı, fonksiyonumuzdan emulator değişkenimizin dönmesine yarar.

ROM Okunması

Herhangi bir işlem yapmadan önce emülatör belleğimizde bir CHIP-8 programı yüklememiz gerekli. Sonuçta emulatörümüz bu CHIP-8 programını çalıştırmaya yarayacak. Bu işlemi gerçekleştirmek için önce bir dosya açmalı, bu dosya içerisindeki byteları Emulator belleği'ne (memory alanı) yazmamız gerekli. Öncelikle bu işlemi yapmak için gerekli tipleri modül içinde kullanalım:

use std::fs::File;
use std::io;
use std::io::Read;
use std::path::Path;

Rust'ta başka bir paket ya da standart kütüphaneden herhangi bir yapı kullanabilmek için use anahtar kelimesi kullanılır. İlk satırda kullanılan use, std::fs modülü içerisinde yer alan File yapısını üzerinde çalıştığımız modül içinde kullanmaya yarar. Bu sayede File yapısında direkt ulaşabiliriz. İkinci satırda std::io modülünü kullanıyoruz. Bu kullanımın ardından modül içerisinde yazacağımız her io, standart kütüphanede yer alan io modülünü simgelemektedir. Read özelliği, File üzerinde okuma yapmamız için gerekli.Son olarak dosya yolu değişkeninde kullanacağımız Path yapısını kullanılıyoruz.

ROM okuma metodumuzu impl Emulator bloğu içerisine tanımlıyoruz:

    pub fn rom_oku<P: AsRef<Path>>(mut self, path: P) -> io::Result<Emulator> {
        let file = File::open(path)?;
        for (loc, byte) in file.bytes().enumerate() {
            self.memory[0x200 + loc] = byte?;
        }
        Ok(self)
    }

rom_oku isimli bu metod, Emulator instance'ının bir parçası olduğundan ilk parametresi: &mut self'dir. Instance'ın sahipliğini, bu fonksiyona almamak için, self referans olarak kullanılır. Eğer sadece self olarak kullansaydık, instance sahipliği bu fonksiyonun çağrılmasıyla, bu fonksiyona geçecekti ve bir daha dışarıdan erişime izin verilmeyecekti. Bu referansın mutable olmasının nedeni ise emulatör içerisinde yer alan bellek (memory) alanını değiştiriyor olmamızdan kaynaklanıyor. Eğer bu referans mutable olmasaydı, Emulator yapımızda yer alan hiç bir alanı değiştiremezdik. path değişkeni Rust'ta referansdan-referansa dönüşüm yapmayı mümkün kılan AsRef özelliğine sahip bir genelleyici seçilmiştir. Bu sayede path değişkeni AsRef<Path> özelliğine sahip herhangi bir tip olabilir (String, &str, OsStr, Path, PathBuf vb.).

Metodumuzda hata yönetimi yapmak için; fonksiyon tanımlamamızda dönüş değeri, yine standart kütüphanenin io (girdi-çıktı) modülünde yer alan Result tipini kullanıyoruz. Bizim kullandığımız io::Result tipi; std::result::Result tipiyle karıştırılmamalıdır. Normal Result tanımlaması, hem dönüş tipi hem de hata tipi gerektirir. Bizim bu metodda kullanacağımız tüm hata dönebilecek fonksiyonlar io::Error tipinde olduğundan, io::Result tipini kullanmamız yeterli. io::Result sadece dönüş için gerekli bir tip gerektirdiğinden ve biz bu metoddan self ile sahipliğini aldığımız Emulator instance'ını döneceğimizden, dönüş tipi olarak Emulator kullanılır.

Dosya let file = File::open(path)?; ile açılıyor. Bu satırda yer alan ? hata yönetimimiz için gerekli. Bu fonksiyon normalde io::Result dönüyor ve bu Result tipi hata olması durumunda Err (bizim kullandığımız Result tipine göre io::Error), olmaması halinde ise Ok değeri döner. ? ile yaptığımız hata kontrolü sayesinde, bu fonksiyonun Err dönmesi durumunda, operatörümüz o noktada gelen hatayı dönmeye yarıyor. Rust'ın bu gelişmiş hata yönetimi sayesinde, hata kontrolü için yazılması gereken kod çok daha azalıyor ve bu basit kullanım Rust'ın hata yönetimini çok daha güçlü hale getiriyor.

Read::bytes() metodu, okunabilir instance'ı byteları iteratöre çevirmeye yarıyor. For ile kullandığımız bu iterator hata olması durumunda yine io::Result dönen bir yapıya sahip. Bu nedenle direkt byte'ı kullanmadan önce hata kontrolü operatörümüz olan ?'ini kullanıyoruz. CHIP-8'de programlar bellekte 0x200 adresinden başladığından dolayı, belleğin 0x200 alanından itibaren yazıyoruz.

İlk başta tanımladığımız gibi metodumuz io::Result<Emulator> dönmesi gerekli. Fonksiyonumuz son satıra ulaştığında herhangi bir hata olmaması durumunda, sahipliğini aldığımız instance ile birlikte Ok(self) dönüyor. Bu sayede bu metodu kullanırken sahipliğini aldığımız instance'ı bir zincir halinde kullanabiliriz.

Instruction'ların Alınması

ROM'umuzu emülatör belleğine yüklediğimize göre, artık OPCODE'ları okumaya ve onları çalıştırmaya başlayabiliriz.

CHIP-8 OPCODE'ları 2 byte uzunluğundadır. Belleğimizde her bir alan 1 byte uzunluğunda olduğundan, 2 byte okuyup bunları birleştirmemiz gerekli. Örnek olarak bellek üzerinde şu iki byte düşünüldüğünde:

memory[pc]     == 0xA2
memory[pc + 1] == 0xF0

Bu iki byte'ı 16-bit'lik bir sayı yapmak için, öncelikle 0xA2 değeri 16-bitlik bir sayıya çevrilir ve ardından 8-bit sol tarafa kaydırılır (left shift). Ardından bitwise OR ile bir sonraki byte yeni değere eklenir:

0xA2            10100010           // 8-bitlik değer 16-bit'e çevrilir
0x00A2  0000000010100010 << 8      // Ardından 8-bit sola kaydırılır
0xA200  1010001000000000 | 0x00F0  // Ardından sonraki byte (0xF0) XOR ile eklenir
0xA2F0  1010001011110000

Bu işlemi Rust ile impl Emulator bloğu içine tanımladığımız metodla şu şekilde yapabiliriz:

    fn instruction_oku(&self) -> Option<Instruction> {
        let opcode = (self.memory[self.pc as usize] as u16) << 8
            | (self.memory[(self.pc + 1) as usize] as u16);
        Instruction::new(opcode)
    }

Bu şekilde program counter'da tutulan bellek alanından 2 byte'lık OPCODE okunur bitwise OR işlemi ile birbirine eklenir ve tanımladığımız Instruction tipine dönüştürülür. Daha önce Instruction'a eklediğimiz From<u16> özelliği sayesinde, opcode değişkenine herhangi bir işlem yapmadan Instruction::new metodunda kullanabiliriz.

Instruction'ların Çalıştırılması

Artık bellekten instructionları okuyup kendi tipimiz olan Instruction'a dönüştürdüğümüze göre, çalıştırma aşamasına geçebiliriz. Instructionların tam olarak ne yaptıklarını ayrıntılı olarak Instruction Yapısı'nda tanımlamıştık. Şimdi bu enum'un tüm bileşenlerini işleyeceğimiz bir match deyimi tanımlayalım.

Her bir instruction program counter'ı (self.pc) değiştireceğinden, match deyimimiz, her bir işlemden sonra program counter'ın yeni değerini dönüyor.

Bunun dışında Rust'a ait range operatörünü bir çok yerde kullanıyoruz. Farklı bir syntax olarak 0..=x gözünüze çarpabilir. Bu range operatörü 0 dan x + 1'e kadar sayıları oluşturmaya yarıyor.

CHIP-8'de her bir instruction 2 byte olduğundan, bir çok instruction program counter'ı 2 byte arttırıyor. Bir sonraki instruction'ı atlamamız gereken durumlarda ise program counter, 4 byte arttırılıyor.

Bunun dışında overflow olabilecek toplama ve çıkarma işlemlerinde, + veya - operatörlerini direkt kullanmak yerine, overflow_add ve overflow_sub metodlarını kullanacağız. Rust güvenli bir dil olduğundan overflow durumlarında panikleyerek çıkar. overflow_add ve overflow_sub metodları istediğimiz gibi toplama ve çıkarma işlemini yaptıktan sonra, overflow olursa bu durumu da dönüyorlar. Zaten CHIP-8'de overflow durumları da F registerinde saklandığı için, bu durumu kolayca F registerine atayabiliriz.

    fn instruction_calistir(&mut self, instruction: Instruction) {
        self.pc = match instruction {
            Instruction::ClearDisplay => {
                self.display.clear();
                self.pc + 2
            }
            Instruction::Return => {
                self.sp -= 1;
                self.stack[self.sp as usize] + 2
            }
            Instruction::Jump(addr) => addr,
            Instruction::Call(addr) => {
                self.stack[self.sp as usize] = self.pc as u16;
                self.sp += 1;
                addr
            }
            Instruction::SkipIfEqualsByte(x, v) => {
                if self.v[x] == v {
                    self.pc + 4
                } else {
                    self.pc + 2
                }
            }
            Instruction::SkipIfNotEqualsByte(x, v) => {
                if self.v[x] != v {
                    self.pc + 4
                } else {
                    self.pc + 2
                }
            }
            Instruction::SkipIfEqual(x, y) => {
                if self.v[x] == self.v[y] {
                    self.pc + 4
                } else {
                    self.pc + 2
                }
            }
            Instruction::LoadByte(x, v) => {
                self.v[x] = v;
                self.pc + 2
            }
            Instruction::AddByte(x, v) => {
                let (res, _) = self.v[x].overflowing_add(v);
                self.v[x] = res;
                self.pc + 2
            }
            Instruction::Move(x, y) => {
                self.v[x] = self.v[y];
                self.pc + 2
            }
            Instruction::Or(x, y) => {
                self.v[x] |= self.v[y];
                self.pc + 2
            }
            Instruction::And(x, y) => {
                self.v[x] &= self.v[y];
                self.pc + 2
            }
            Instruction::Xor(x, y) => {
                self.v[x] ^= self.v[y];
                self.pc + 2
            }
            Instruction::Add(x, y) => {
                let (res, overflow) = self.v[x].overflowing_add(self.v[y]);
                self.v[x] = res;
                self.v[0x0F] = if overflow { 1 } else { 0 };
                self.pc + 2
            }
            Instruction::Sub(x, y) => {
                let (res, overflow) = self.v[x].overflowing_sub(self.v[y]);
                self.v[x] = res;
                self.v[0x0F] = if overflow { 0 } else { 1 };
                self.pc + 2
            }
            Instruction::ShiftRight(x) => {
                self.v[0x0F] = self.v[x] & 0x1;
                self.v[x] >>= 1;
                self.pc + 2
            }
            Instruction::ReverseSub(x, y) => {
                self.v[0x0F] = if self.v[x] > self.v[y] { 0 } else { 1 };
                self.v[x] = self.v[y] - self.v[x];
                self.pc + 2
            }
            Instruction::ShiftLeft(x) => {
                self.v[0x0F] = self.v[x] >> 7;
                self.v[x] <<= 1;
                self.pc + 2
            }
            Instruction::SkipIfNotEqual(x, y) => {
                if self.v[x] != self.v[y] {
                    self.pc + 4
                } else {
                    self.pc + 2
                }
            }
            Instruction::LoadI(addr) => {
                self.i = addr;
                self.pc + 2
            }
            Instruction::JumpPlusZero(addr) => addr + (self.v[0] as u16),
            Instruction::Random(x, val) => {
                self.v[x] = val & rand::random::<u8>();
                self.pc + 2
            }
            Instruction::Draw(x, y, n) => {
                let from = self.i as usize;
                let to = from + n as usize;
                let x = self.v[x];
                let y = self.v[y];
                self.v[0x0F] = self
                    .display
                    .draw(x as usize, y as usize, &self.memory[from..to]);
                self.pc + 2
            }
            Instruction::SkipIfPressed(x) => {
                if self
                    .keyboard
                    .pressed_key()
                    .map_or(false, |key| key == self.v[x])
                {
                    self.pc + 4
                } else {
                    self.pc + 2
                }
            }
            Instruction::SkipIfNotPressed(x) => {
                if self
                    .keyboard
                    .pressed_key()
                    .map_or(false, |key| key == self.v[x])
                {
                    self.pc + 2
                } else {
                    self.pc + 4
                }
            }
            Instruction::LoadDelayTimer(x) => {
                self.v[x] = self.delay_timer;
                self.pc + 2
            }
            Instruction::WaitForKeyPress(x) => {
                if let Some(key) = self.keyboard.pressed_key() {
                    self.v[x] = key;
                    self.pc + 2
                } else {
                    self.pc
                }
            }
            Instruction::SetDelayTimer(x) => {
                self.delay_timer = self.v[x];
                self.pc + 2
            }
            Instruction::SetSoundTimer(x) => {
                self.sound_timer = self.v[x];
                self.pc + 2
            }
            Instruction::AddToI(x) => {
                self.i += self.v[x] as u16;
                self.pc + 2
            }
            Instruction::LoadSprite(x) => {
                self.i = self.v[x] as u16 * 5;
                self.pc + 2
            }
            Instruction::BCDRepresentation(x) => {
                self.memory[self.i as usize] = self.v[x] / 100;
                self.memory[self.i as usize + 1] = (self.v[x] / 10) % 10;
                self.memory[self.i as usize + 2] = (self.v[x] % 100) % 10;
                self.pc + 2
            }
            Instruction::StoreRegisters(x) => {
                for i in 0..=x {
                    self.memory[self.i as usize + i] = self.v[i]
                }
                self.i += x as u16 + 1;
                self.pc + 2
            }
            Instruction::LoadRegisters(x) => {
                for i in 0..=x {
                    self.v[i] = self.memory[self.i as usize + i];
                }
                self.i += x as u16 + 1;
                self.pc + 2
            }
        };
    }

Görüntü Yapısı

Emülatörümüzün temel fonksiyonlarını tamamladıktan sonra, ekrana görüntü vereceğimiz yapımızın tanımına geçebiliriz. Öncelikle kullanacağımız yapıları modülümüze ekleyelim:

use minifb::{Key, Scale, Window, WindowOptions};

minifb daha önce de bahsettiğimiz gibi en yaygın işletim sistemlerinde bir pencere açıp içerisine bir şeyler çizebileceğimiz basit bir paket. Aynı zamanda basılan tuşları da alabildiği için bizim için biçilmiş kaftan. Yapıları kullanıma aldıktan sonra bazı sabitleri tanımlayalım:

const WIDTH: usize = 64;
const HEIGHT: usize = 32;
const FOREGROUND_COLOR: u32 = 0x5294e2;
const BACKGROUND_COLOR: u32 = 0x282c34;

CHIP-8 64x32 piksel boyutunda bir ekrana sahip. Bu nedenle genişlik (WIDTH) ve yükseklik (HEIGHT) olarak iki sabit tanımlaması yaptık. Ayrıca arka plan rengi ve ön plan rengi olmak üzere iki 32-bit cinsinden sayı tanımladık. minifb 32-bit cinsinden olan değerleri ekrana cizebilir, isterseniz burada farklı renkler seçebilirsiniz.

Rust'ta sabit tanımlamaları const anahtar kelimesiyle yapılır. Tüm sabitler 'static ömrüne sahiptir.

Artık görüntü yapımızı tanımlayabiliriz:

pub struct Display {
    buffer: [[u8; WIDTH]; HEIGHT],
    window: Window,
}

CHIP-8 siyah beyaz bir ekrana sahip olduğundan görüntü buffer'ı 8-bitlik 64x32 boyutunda bir matris. Bu buffer içerisinde dolu pikseller için 1, boş pikseller için 0 değerini kullanacağız. window alanı için de minifb'nin Window tipini kullanıyoruz.

Görüntü Metodlarının Uygulanması

impl Display {
}

Bloğunu oluşturarak görüntü metodlarını eklemeye başlayalım.

    pub fn new() -> Self {
        Self {
            buffer: [[0; WIDTH]; HEIGHT],
            window: Window::new(
                "Rust ile CHIP-8",
                WIDTH,
                HEIGHT,
                WindowOptions {
                    scale: Scale::X16,
                    ..WindowOptions::default()
                },
            )
            .expect("Pencere oluşturulurken hata oluştu"),
        }
    }

new metodumuz yeni bir Display instance'ı oluşturmaya yarıyor. Burada kullanmış olduğumuz Self; Dislay ile aynı anlama geliyor. İsterseniz impl bloğunda tekrar tekrar tip ismini yazmak yerine Self anahtar kelimesini kullanabilirsiniz. Unutmayın ki birinci harf büyük olmalı ve bu anahtar kelime, metod argümanlarında kullanılan self ile karıştırılmamalıdır.

WindowOptions yapısı tamamen açık (public) alanlara sahip bir yapı. Bu nedenle bu yapı için herhangi bir yardımcı metod olmadan direkt instance oluşturabiliriz. Biz varsayılan (Default) özelliği eklenmiş bu yapının, sadece scale alanını değiştireceğimizden, geri kalan alanların varsayılan değeri alması için ..WindowOptions::default() yazımını kullandık. Rust'ta kullanılan bu yazım, yapının tanımlanmamış diğer alanları için varsayılan değerleri almasını sağlıyor. 64x32 piksel modern bir bilgisayarda çok küçük olacağı için, scale ile belirlediğimiz değerle, minifb paketi görütüyü belirlenen oranda arttırıyor (bizim belirlediğimiz X16 oranında).

Metodumuzda şu an için herhangi bir hata yönetimi yapmadığımızdan dolayı, pencere oluşturulurken karşılaşacağımız olası bir hata durumunda expect ile panikleyip çıkıyoruz.

Yardımcı Metodlar

    pub fn is_open(&self) -> bool {
        self.window.is_open() && !self.window.is_key_down(Key::Escape)
    }

    pub fn clear(&mut self) {
        self.buffer = [[0; WIDTH]; HEIGHT];
    }

is_open metodu adından da anlaşılabileceği gibi, penceremizin açık olup olmadığını kontrol ediyor. Aynı zamanda Esc tuşunun basılı olup olmadığını da kontrol eden bu metod bool dönüyor. Bu sayede oyuncu istediği zaman Esc tuşuna basarak pencereyi kapatabilir ve emülatörü sonlandırabilir.

clear metodu, yapımız içerisinde yer alan buffer'ı temizlemeye yarıyor.

Draw Instruction'ı ile Çizim

CHIP-8 draw instruction'ı daha önce belirlediğimiz gibi:

    /// 0xDxyn DRW: `x` ve `y` registerinden başayarak `n` adet byte sprite'ı ekranda
    /// gösterir. Çakışma (collision) durumu `F` registerinde tutulur.
    Draw(Register, Register, u8),

Şeklinde yapıyor. Aynı zamanda CHIP-8 sadece bu instruction ile collision (spriteların birbiriyle çakışma) durumunu kontrol edebiliyor. Bu işlemi şu şekilde yapabiliriz:

    pub fn draw(&mut self, x: usize, y: usize, sprite: &[u8]) -> u8 {
        let mut collision = 0;
        let mut xi: usize;
        let mut yj: usize;

        for (j, sprite) in sprite.iter().enumerate() {
            for i in 0..8 {
                xi = (x + i) % WIDTH;
                yj = (y + j) % HEIGHT;

                if sprite & (0x80 >> i) != 0 {
                    if self.buffer[yj][xi] == 1 {
                        collision = 1;
                    }
                    self.buffer[yj][xi] ^= 1;
                }
            }
        }

        self.draw_screen();
        collision
    }

sprite bellekten gelen 8-bitlik verilere sahip bir slice. Öncelikle for döngümüz her bir sprite için j sayacıyla çalışıyor. Ardından ikinci for döngüsünde 8-bitlik verinin her bir bitini kontrol etmeye başlıyoruz. xi ve yj ile yazılacak bit'in ekrandaki pozisyonu belirleniyor. Ardından her bir bit: 0x80: 10000000 üzerinde çalıştığımız bit kadar (i) sağa kaydırılarak sprite ile XOR işlemi sayesinde herhangi bir çizilecek piksel varsa 1, yoksa 0 sonucu elde ediliyor. Ardından daha önce aynı pozisyonda herhangi sprite çizildiyse çakışma değeri olan collision 1 yapılıyor.

Son olarak CHIP-8'de spritelar ekrana XOR ile çizildiği için, üzerinde çalıştığımız piksel 1 ile XOR işlemi ile belirleniyor.

buffer'ın Pencereye Çizimi

minifb &[u32] tipinden bir yapıyı ekrana çizebilir. Bu nedenle [[0; WIDTH]; HEIGHT] tipinden olan matrisimizi [u32] tipden bir array'a çevirmemiz gerekli. Bu işlemi daha önce sabit olarak belirlediğimiz arkaplan ve önplan rengini de kullanarak şu şekilde yapabiliriz:

    pub fn draw_screen(&mut self) {
        let mut buffer = [0; WIDTH * HEIGHT];
        let mut loc = 0;
        for y in 0..HEIGHT {
            for x in 0..WIDTH {
                buffer[loc] = if self.buffer[y][x] == 1 {
                    FOREGROUND_COLOR
                } else {
                    BACKGROUND_COLOR
                };
                loc += 1;
            }
        }
        self.window.update_with_buffer(&buffer).unwrap();
    }

Referanstan-referansa Dönüştürme ile Pencere Alanının Alımı

İleride yazacağımız emülasyon döngüsü için, Display yapımız içerisinde yer alan window alanına ulaşabilmemiz gerekli. Bu işlemi Rust'ın referanstan-referansa çevirme işlemi yapan AsMut özelliği ile yapacağız. Display yapımıza AsMut özelliğini katalım ve as_mut çağrıldığında Window tipini dönelim:

impl AsMut<Window> for Display {
    fn as_mut(&mut self) -> &mut Window {
        &mut self.window
    }
}

Klayve Yapısı

CHIP-8 daha önce bahsettiğimiz gibi:

+---+---+---+---+
| 1 | 2 | 3 | C |
| 4 | 5 | 6 | D |
| 7 | 8 | 9 | E |
| A | 0 | B | F |
+---+---+---+---+

Şeklinde bir klavyeye sahip. Tuşları alabilmek için minifb::Key yapısını kullanacağız. Modern bir bilgisayarda basılan tuşları, CHIP-8'in anlayabileceği türe çevirmek için basit bir yapı tanımlayalım:

use minifb::Key;

pub struct Keyboard(Option<u8>);

Yapımızda herhangi bir tuşun basılı olup olmadığını kontrol edebilmek için Option<u8> kullandık. Bu değer bir tuş basılıysa Some<u8>, değilse None değerlerini içerecek.

Bunun ardından klavye metodlarını ekleyelim:

impl Keyboard {
    pub fn new() -> Keyboard {
        Keyboard(None)
    }
    pub fn press_key(&mut self, key: Key) {
        self.0 = self.to_chip8_key(key);
    }
    pub fn release_key(&mut self) {
        self.0 = None;
    }
    pub fn pressed_key(&self) -> Option<u8> {
        self.0
    }
    fn to_chip8_key(&self, key: Key) -> Option<u8> {
        match key {
            Key::Key1 => Some(0x01),
            Key::Key2 => Some(0x02),
            Key::Key3 => Some(0x03),
            Key::Key4 => Some(0x0C),

            Key::Q => Some(0x04),
            Key::W => Some(0x05),
            Key::E => Some(0x06),
            Key::R => Some(0x0D),

            Key::A => Some(0x07),
            Key::S => Some(0x08),
            Key::D => Some(0x09),
            Key::F => Some(0x0E),

            Key::Z => Some(0x0A),
            Key::X => Some(0x00),
            Key::C => Some(0x0B),
            Key::V => Some(0x0F),

            _ => None,
        }
    }
}

to_chip8_key metodu, Key cinsinden alınan tuşu, CHIP-8'in anlayabileceği türe çevirmeye yarıyor ve basılan tuş eğer CHIP-8 tuş takımında yoksa, değeri None yapıyor.

Emülasyon Döngüsü

Tüm bileşenleri yazdığımıza göre artık emülasyon döngüsünü kurabiliriz. Öncelikle gerekli kullanımları yapalım:

use std::thread::sleep;
use std::time::Duration;

CHIP-8 oyunları kendi işlemci hızında çalışabilecek şekilde programlandığından, modern bir işlemcide çok hızlı çalışacaktır. Bu problemi her bir instructiondan sonra belirli bir süre bekleyerek çözebiliriz. Çalışan thread'ımızda hiç bir şey yapmadan bekleyebileceğimiz sleep fonksiyonunu modülümüze ekledik.

Ardıdan emülasyon metodunu impl Emulator bloğu içerisine oluşturalım:

    pub fn emulate(&mut self) {
        while self.display.is_open() {
            self.display.as_mut().update();

            if let Some(keys) = self.display.as_mut().get_keys() {
                keys.iter().for_each(|key| self.keyboard.press_key(*key));
                if keys.is_empty() {
                    self.keyboard.release_key();
                }
            }

            let instruction = self.instruction_oku().expect("Bilinmeyen Instruction");
            self.instruction_calistir(instruction);

            if self.delay_timer > 0 {
                self.delay_timer -= 1;
            }

            if self.sound_timer > 0 {
                if self.sound_timer == 1 {
                    println!("BEEP!");
                }
                self.sound_timer -= 1;
            }

            sleep(Duration::from_millis(5));
        }
    }

Döngümüz pencere açık olduğu süre boyunca çalışacak.

Display tipine eklemiş olduğumuz ve referanstan-referansa dönüştürme işlemi yapan AsMut özelliği sayesinde, self.display.as_mut() diyerek, pencere yapısına ulaşabiliyoruz. minifb'de yer alan update() metodu, çizim yapmasak bile basılan tuşları alabilmemiz için çalıştırmamız gereken bir metod.

            if let Some(keys) = self.display.as_mut().get_keys() {
                keys.iter().for_each(|key| self.keyboard.press_key(*key));
                if keys.is_empty() {
                    self.keyboard.release_key();
                }
            }

Burada penceremizden basılan tuşların bir listesini alıyoruz. Bu fonksiyon Option dönen bir fonksiyon olduğundan, Rust'a ait if let sözdizimini kullanabiliriz. Bu if bloğu sadece self.display.as_mut().get_keys() metodu Some döndüğünde çalışır. Ardından klavye alanına basılan tuşu aktarıyoruz. get_keys() metodunun boş dönmesi durumunda basılan tuşu geri bırakmak için klavye yapımızda kullandığımız release_key() metodunun çalıştırıyoruz.

            let instruction = self.instruction_oku().expect("Bilinmeyen Instruction");
            self.instruction_calistir(instruction);

Daha önce yazdığımız instructionların okunması ve çalıştırılması işlemi burada gerçekleştiriliyor. expect() metodu Option dönen instruction_oku() metodundan None dönülmesi durumunda, "Bilinmeyen Instruction" hata mesajıyla birlikte panikleyip çıkmamızı sağlıyor.

            if self.delay_timer > 0 {
                self.delay_timer -= 1;
            }

            if self.sound_timer > 0 {
                if self.sound_timer == 1 {
                    println!("BEEP!");
                }
                self.sound_timer -= 1;
            }

            sleep(Duration::from_millis(5));

Son olarak CHIP-8'in timerlarını birer sayı azaltıp, her instructiondan sonra 5 mili saniye bekleyerek döngümüzü tamamlıyoruz. Herhangi bir ses arabirimi kullanmadığımızdan, ses için şimdilik ekrana "BEEP!" mesajını yazdırıyoruz. İsterseniz siz bir ses paketi kullanarak bilgisayardan ses çıkmasını da sağlayabilirsiniz.

main() fonksiyonu

Artik emülatörümüz tüm bileşenleriyle hazır. Tek yapmamız gereken program başladığında çalışacak olan main fonksiyonunu tanımlamak. Bunun için program çalıştığında, program argümanlarını almamıza yarayacak std::env::args fonksiyonunu dahil edelim ve main fonksiyonumuzu yazalım:

use std::env::args;

fn main() {
    Emulator::new()
        .rom_oku(args().nth(1).unwrap_or_else(|| "brix.ch8".to_string()))
        .expect("ROM okurken hata oluştu")
        .emulate();
}

args().nth(1).unwrap_or_else(|| "brix.ch8".to_string()) söz dizimi, program argümanlarından ilkini almaya ve eğer herhangi bir argüman girilmezse de varsayılan olarak brix.ch8 stringini dönmeye yarıyor. Bu sayede emülatörümüz istenildiği taktirde farklı bir ROM'u oynatabilir. args() fonksiyonu String cinsinden değerler döndüğü için "brix.ch8"'in de String'e dönüştürülmesi gerekli. Bu işlem için to_string() metodunu kullandık.

Eğer hatırlarsanız rom_oku() metodunu yazarken instance sahipliğini almıştık ve dönüş değeri olarak yine almış olduğumuz sahipliği dönmüştük. Bu sayede expect() ile hatadan kurtarılan instance'a emulate() metodunu zincir halinde kullanabiliyoruz.