2015’deki ilk blog yazım olarak bunu öngörmemiştim, zira eski oyun konsollarına olan ilgim sadece oyun oynamak üzerine idi. Ta ki, yukarıdan vahiy gönderen bir push servisine yakalanmış gibi, “Niye emülatör yazmıyorum ki?” diye düşünene kadar.
Öncelikle söylemem lazım, benim şahsi favorim bir çok N64 hayranının aksine NES ve SNES ikilisi oluyor. Gerek mimarilerinin göreceli kolaylığı ve güzelliği, gerekse de efsanevi oyunlarıyla (Benim için bu efsane oyunlar, Mario ve Zelda serileri oluyor) beni benden alıyorlar. Tamam kabul ediyorum, o konsollar çıktığında ben portakaldaki vitamin bile değildim ama çakma konsollar ve emülatörler ile durumu kurtardık yine de. Bu sebeplerden dolayı, başta NES mimarisine ve emülatörüne giriştim fakat, göreceli olarak daha kolay olan mimarisi bile feleğimi şaşırttı. Onu öğrenirken, bir yandan da daha eski ve cidden daha kolay bir mimaride olan CHIP-8’i öğrenmeye başladım. Pong ve Space Invaders’ın platformu olan meşhur CHIP-8’den bahsediyorum.
Bilmeyenler için, CHIP-8 aslında gerçek bir oyun konsolu değil. Daha basit bir şekilde aslında, yorumlanan (interpreted) bir programlama dili ve bu dil ile yazılan programlar, CHIP-8 sanal bilgisayarı üstünde çalışıyor. Genellikle de oyunlar için kullandığından, çok rahat bir şekilde sanal bir oyun konsolu olduğu söylenebilir.
Dilin ilk kullanımı 70’lerin ortasına dayandığından, en baba ve eski bazı oyunlar (örnekteki gibi Pong ve Space Invaders) bu platform üzerinde geliştirildi. Bu oyunların niteliklerini de biliyorsanız ve hatırlayacak olursanız, platformun oldukça basit bir yapısı olduğunu tahmin edebilirsiniz.
Genel Mimari
Bellek:
CHIP-8 dili, 4 KB’lık bir belleği adresleyebiliyor. 0x000’dan başlayıp, 0xFFF’e kadar giden bu bellek bölümünün ilk 512 baytlık kısmı (0x000 – 0x200) yorumlayıcıya ayrıldığından, dışarıdan kullanılamıyor (Bazı varyasyonlarında 0x600’e kadar gidiyor.) Sonraki 3.5 KB’lık kısım ile programın kullanabileceği alan olarak duruyor.
Register’lar:
- 16 tane genel amaçlı (V0…VF) 8 bitlik
- Adres tutucu (I = Index) 16 bitlik
- 2 tane özel amaçlı 8 bitlik: Gecikme (delay) ve ses sayaçları
- Program sayacı (PC) 16 bitlik
- Yığın işaretçisi (SP = Stack Pointer) 8 bit
- Ve bir takım, varsayılan olarak dışarıdan erişilemeyen register’lar
Buna ek olarak, sistem 16 tane 2 baytlık veriyi tutabilecek bir yığın (stack) yapısına sahip. Bu şekilde en fazla 16 tane iç içe alt yordam kullanılabiliyor.
Girdi:
Sistemin girdisi 16 tuşluk bir klavyeden yapılıyor. Emülasyonda kolayca 0x0’dan 0xF’e kadar haritalanabilir.
Görüntü ve Ses:
Görüntü için 64 * 32 = 2048 piksellik siyah-beyaz bir ekran kullanıyor. Genel çizim için sprite’lar kullanılıyor ve bunların boyutu 15 bayta kadar çıkabiliyor. Dolayısıyla bir sprite en fazla 8 x 15 = 120 piksellik bir alan işgal edebiliyor.
Ses için ise, ses sayacı sıfıra vurduğunda tek bir belirli tonda ses çıkartılıyor. Yani genel olarak baktığımızda sistemin görüntü ve ses mimarisi 2048 piksel ve tek bir ses tonundan daha fazlasına ihtiyaç duymuyor. Bu yüzden implementasyonu oldukça kolay.
Ek olarak fontların oluşturulması için, yine sprite mantığında 5 tane bayt (120 piksel) kullanılıyor. Genel tablo için, şuraya gözatabilirsiniz.
Talimatlar (instructions):
Sistemde genel olarak 35 tane (bazı kaynaklarda 36 deniyor.) talimat (instruction) var. Tüm bu talimatlar, 2 bayt boyutunda ve Big Endian olarak, yani en önemli bayt en başta bulunacak şekilde çalışıyor. Bu kadar az sayıda talimat olduğundan dolayı, hepsini Wikipedia’da bile listelenmiş olarak bulabilirsiniz.
Emülatöre Giriş
Emülatörün nasıl çalıştığını şu sayfadaki Python örneğini çalışarak öğrendim. O örnekteki sistem emülasyonunda, girdi ve çıktı aygıtları olarak Pyglet kütüphanesi kullanılıyor. Yani aslında sadece temel işlemci ve belleği sanallaştırmamız (aslında zaten sanal da) yeterli oluyor.
İşe hemen bir sınıf oluşturarak ve nitelikleri tanımlayarak başladım.
1 2 3 4 5 6 7 | class CHIP8 attr_accessor :memory, :gpio, :display_buffer, :stack, :key_inputs, :fonts, :opcode, :index, :pc, :delay_timer, :sound_timer, :should_draw, :instructions, :vx, :vy end |
Ardından bu niteliklere, başlangıç değerlerini vermem gerekti. Mimarideki genel özellikleri göz önünde bulundurarak, niteliklere başlangıç değerlerini verdim. Ekstra olarak font şemasındaki onaltılık değerleri de ekledim.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | def initialize @memory = [0] * 4096 # 4096 Baytlık belleğimiz @gpio = [0] * 16 # 16 tane genel amaçlı register'ı tutan bir dizi @display_buffer = [0] * 2048 # 64 * 32 = 2048 piksellik ekranımız @stack = [] # Alt yordamları tutmamızı sağlayacak olan yığın @key_inputs = [0] * 16 # 16 girdili klavyemiz @fonts = [ # Her biri 5 bayt olarak şekilde, 16 tane fontumuz 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 ] @opcode = 0 # Çalıştırılacak olan işlemin kodu @index = 0 # Index register'ımız @pc = 0x200 # Program sayacımız. ilk 512 baytı es geçiyoruz. @should_draw = false # Görüntü çizilmesi için bir boolean değişkeni @vx, @vy = 0, 0 # Register'ları tutan değişkenler # Fontları belleğe yüklüyoruz (0...80).each { |i| @memory[i] = @fonts[i] } # Ve en baba kısım: Talimatları bir Hash'e koyarak # haritalama yapıyoruz. @instructions = { 0x0000 => :_0ZZZ, 0x00e0 => :_0ZZ0, 0x00ee => :_0ZZE, 0x1000 => :_1ZZZ, 0x2000 => :_2ZZZ, 0x3000 => :_3ZZZ, 0x4000 => :_4ZZZ, 0x5000 => :_5ZZZ, 0x6000 => :_6ZZZ, 0x7000 => :_7ZZZ, 0x8000 => :_8ZZZ, 0x8FF0 => :_8ZZ0, 0x8FF1 => :_8ZZ1, 0x8FF2 => :_8ZZ2, 0x8FF3 => :_8ZZ3, 0x8FF4 => :_8ZZ4, 0x8FF5 => :_8ZZ5, 0x8FF6 => :_8ZZ6, 0x8FF7 => :_8ZZ7, 0x8FFE => :_8ZZE, 0x9000 => :_9ZZZ, 0xA000 => :_AZZZ, 0xB000 => :_BZZZ, 0xC000 => :_CZZZ, 0xD000 => :_DZZZ, 0xE000 => :_EZZZ, 0xE00E => :_EZZE, 0xE001 => :_EZZ1, 0xF000 => :_FZZZ, 0xF007 => :_FZ07, 0xF00A => :_FZ0A, 0xF015 => :_FZ15, 0xF018 => :_FZ18, 0xF01E => :_FZ1E, 0xF029 => :_FZ29, 0xF033 => :_FZ33, 0xF055 => :_FZ55, 0xF065 => :_FZ65 } end |
Son değişkeni tanımladıktan sonra “oha falan olma hali” olduysa aldırmayın, gayet basit bir açıklaması var. Emülatörü hazırladıktan sonra, oyun dosyalarını programa yükleyeceğiz. Bu oyun dosyaları (genellikle konsollar için ROM olarak söylenir.) CHIP-8 mimarisinde çalışacak şekilde hazırlanmış programlar oluyor ve içlerinde talimatlar hazır olarak geliyor. Hangi onaltılık değerin hangi talimata (daha doğrusu burada methota) denk geldiğini bu hash ile belirliyoruz. Tabi bunlar (onaltılık kodlar) sallamasyon değerler değil, yazının sonunda paylaşacağım kaynaklardan, cidden değerler ile talimatların önceden belirli olarak eşleştirildiğini göreceksiniz.
Sonraki adım, verilen dosyayı (ROM’u) belleğe yüklemek oluyor. Hemen metodu tanımlayalım:
1 2 3 4 5 6 | def load_rom(path) rom_file = File.open(path, 'rb') { |i| i.read } rom_file.split('').each_with_index do |byte, index| @memory[index + 0x200] = byte.ord end end |
Temel olarak yaptığmız şey, dosyayı ikilik (binary) olarak açıp okumak ve tüm değerleri, 0x200’den sonraki bellek bölgelerine yerleştirmek.
Ardından sanal makinenin devir (cycle) sistemini oluşturan metodu yazmamız lazım:
1 2 3 4 5 6 7 8 9 10 11 12 | def cycle @opcode = (@memory[@pc] << 8) | @memory[@pc + 1] # İşlem kodunu alıyoruz @pc += 2 # Program sayacını arttırıyoruz @vx = (@opcode & 0x0F00) >> 8 # İşlem kodunu maskeleyip @vy = (@opcode & 0x00F0) >> 4 # register'ları değişkenlere atıyoruz ext_op = @opcode & 0xF000 # İşlem kodunu maskeleyip, talimatı alıyoruz run_instruction ext_op # Ve talimatı çalıştırıyoruz @delay_timer -= 1 if @delay_timer > 0 # Gecikme ve ses sayaçlarını @sound_timer -= 1 if @sound_timer > 0 # azaltıyoruz end |
Bu metodu dikkatlice incelediyseniz, talimatı çalıştıracak olan, özel bir metot yazmamız lazım. Bunu diğerleri gibi public yapmak yerine, private olarak yapmayı tercih ettim şahsen ben:
1 2 3 4 5 | private def run_instruction(ext_op) method(@instructions[ext_op]).call rescue puts "Unknown instruction: #{ext_op.to_s(16)}" end |
Ne olur ne olmaz diye, olası bir exception’dan kurtulmak için de, tek satırlık bir rescue kullandım. Ruby’nin bu tek satırlık işlem yaptırabilen metotlarının hastasıyım 🙂
Ek olarak, ufak bir metotla da hangi tuşun basıldığını bulmamız gerecek. Yine private altından yazalım:
1 2 3 4 | def get_key (0...16).each { |i| return i if @key_inputs[i] == 1 } return -1 end |
Şimdi ise en başa bela kısım geliyor. Başlangıçta, çalıştırılacak talimatları haritalamıştık. Şimdi ise o talimatların metotlarını yazmamız gerekecek. Hazır olun, 35 tane metot geliyor, bunları da private‘ın altına koyun:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 | def _0ZZZ ext_op = @opcode & 0xF0FF run_instruction ext_op end def _0ZZ0 @display_buffer = [0] * 64 * 32 @should_draw = true end def _0ZZE @pc = @stack.pop end def _1ZZZ @pc = @opcode & 0x0FFF end def _2ZZZ @stack.push @pc @pc = @opcode & 0x0FFF end def _3ZZZ @pc += 2 if @gpio[@vx] == (@opcode & 0xFF) end def _4ZZZ @pc += 2 if @gpio[@vx] != (@opcode & 0xFF) end def _5ZZZ @pc += 2 if @gpio[@vx] == @gpio[@vy] end def _6ZZZ @gpio[@vx] = @opcode & 0xFF end def _7ZZZ @gpio[@vx] += (@opcode % 0xFF) end def _8ZZZ ext_op = @opcode & 0xF00F ext_op += 0xFF0 run_instruction ext_op end def _8ZZ0 @gpio[@vx] = @gpio[@vy] & 0xFF end def _8ZZ1 @gpio[@vx] |= @gpio[@vy] @gpio[@vx] &= 0xFF end def _8ZZ2 @gpio[@vx] &= @gpio[@vy] @gpio[@vx] &= 0xFF end def _8ZZ3 @gpio[@vx] ^= @gpio[@vy] @gpio[@vx] &= 0xFF end def _8ZZ4 @gpio[0xF] = @gpio[@vx] + @gpio[@vy] > 0xFF ? 1 : 0 @gpio[@vx] += @gpio[@vy] @gpio[@vx] &= 0xFF end def _8ZZ5 @gpio[0xF] = @gpio[@vx] < @gpio[@vy] ? 0 : 1 @gpio[@vx] -= @gpio[@vy] @gpio[@vx] &= 0xFF end def _8ZZ6 @gpio[0xF] = @gpio[@vx] & 0x0001 @gpio[@vx] >>= 1 end def _8ZZ7 @gpio[0xF] = @gpio[@vx] > @gpio[@vy] ? 0 : 1 @gpio[@vx] = @gpio[@vy] - @gpio[@vx] end def _8ZZE @gpio[0xF] = (@gpio[@vx] & 0xF0) >> 7 @gpio[@vx] <<= 1 @gpio[@vx] &= 0xFF end def _9ZZZ @pc += 2 if @gpio[@vx] != @gpio[@vy] end def _AZZZ @index = @opcode & 0x0FFF end def _BZZZ @pc = (@opcode & 0x0FFF) + @gpio[0] end def _CZZZ r = (rand * 0xFF).to_i @gpio[@vx] = r & (@opcode & 0xFF) @gpio[@vx] &= 0xFF end def _DZZZ @gpio[0xF] = 0 x = @gpio[@vx] & 0xFF y = @gpio[@vy] & 0xFF height = @opcode & 0x000F row = 0 while row < height current_row = @memory[row + @index] pixel_offset = 0 while pixel_offset < 8 loc = x + pixel_offset + ((y + row) * 64) pixel_offset += 1 next if (y + row) >= 32 || (x + pixel_offset - 1) >= 64 mask = 1 << 8 - pixel_offset current_pixel = (current_row & mask) >> (8 - pixel_offset) @display_buffer[loc] ^= current_pixel @gpio[0xF] = @display_buffer[loc] == 0 ? 1 : 0 end row += 1 end @should_draw = true end def _EZZZ ext_op = @opcode & 0xF00F run_instruction ext_op end def _EZZE key = @gpio[@vx] & 0xF @pc += 2 if @key_inputs[key] == 1 end def _EZZ1 key = @gpio[@vx] & 0xF @pc += 2 if @key_inputs[key] == 1 end def _FZZZ ext_op = @opcode & 0xF0FF run_instruction ext_op end def _FZ07 @gpio[@vx] = @delay_timer end def _FZ0A ret = get_key if ret >= 0 @gpio[@vx] = ret else @pc -= 2 end end def _FZ15 @delay_timer = @gpio[@vx] end def _FZ18 @sound_timer = @gpio[@vx] end def _FZ1E @index += @gpio[@vx] if @index > 0x0FFF @gpio[0xF] = 1 @index &= 0xFFF else @gpio[0xF] = 0 end end def _FZ29 @index = (5 * (@gpio[@vx])) & 0x0FFF end def _FZ33 @memory[@index] = @gpio[@vx] / 100 @memory[@index + 1] = (@gpio[@vx] % 100) / 10 @memory[@index + 2] = @gpio[@vx] % 10 end def _FZ55 (0..@vx).each { |i| @memory[@index + i] = @gpio[i] } @index += @vx + 1 end def _FZ65 (0..@vx).each { |i| @gpio[i] = @memory[@index + 1] } @index += @vx + 1 end |
Tüm bu metotların ne yaptığını açıklayamayacağım, kaynaklardan bulabilirsiniz. Sadece implementasyonları Ruby’ye aktardım. Yine de sadece metota bakarak bile ne yaptığını kestirebilirsiniz. Bir de dikkat ettiyseniz, metot isimlerinin başına alt çizgi “_” koyduk. Çünkü Ruby’de metot isimleri, sayı ile başlayamaz. Talimat haritalandırmasını düzgün yapmamız gerektiğinden, hepsinin başına bir alt çizgi koyarak, isimleri aynı tutabildik. Ha bu arada, talimat isimleri, orjinal CHIP-8 talimat isimleriyle aynı değil, söylemiş olayım. Ama yine de bi’şey farketmiyor.
Peki bundan sonrasında ne olacak? Sınıftan yeni bir örnek oluşturacağız, ROM dosyasını yükleyeceğiz ve döngü ile çalıştıracağız. Hemen public bir metot yazalım:
1 2 3 4 5 6 | def test while true sleep 0.1 cycle end end |
Bu metodu çağırdığımızda, sonsuza kadar, 0.1 saniye aralıklarla, devir yapıp talimat çalıştıracak. Çalıştığını görebilmek için de, cycle metodunun içine bir puts ekleyip, hangi talimatın çalıştırıldığını görelim:
1 2 3 4 5 6 7 | def cycle ... ext_op = @opcode & 0xF000 puts "Running instruction: #{@instructions[ext_op].to_s[1..-1]}" rescue nil run_instruction ext_op ... end |
Son olarak da örneklemeyi yapıp, ROM’u yükleyelim ve çalıştıralım. Ben Pong oyununun ROM’unu kullandım, ROM’ları şuradan indirebilirsiniz. Dosyayı, Ruby dosyasıyla aynı yere koyun.
1 2 3 | emulator = CHIP8.new emulator.load_rom 'PONG' emulator.test |
Eğer herhangi bir yeri kaçırdıysanız, Ruby dosyasının tam hali burada. Dosyayı çalıştırın ve konsol çıktılarına dikkatlice gözatın. Şöyle bir şey çıkacak:
1 2 3 4 5 6 7 8 9 10 11 | Running instruction: 6ZZZ Running instruction: 6ZZZ Running instruction: 6ZZZ Running instruction: 6ZZZ Running instruction: AZZZ Running instruction: DZZZ Running instruction: DZZZ Running instruction: 6ZZZ Running instruction: 2ZZZ Running instruction: AZZZ ... |
Eğer hiçbir şey yapmazsanız, gerçek bir oyun olduğundan dolayı, asla sonlanmayacak ve sürekli talimatları çalıştıracaktır. Paylaştığım linkteki, PONG dışındaki diğer oyunları da denerseniz benzer sonuçlar elde edeceksiniz.
Bir sonraki yazıda, Gosu kütüphanesini kullanarak, girdi-çıktı sistemini de implemente edip, gerçekten emülatörü oynanabilir hale getireceğim. Gosu kütüphanesinde yaşadığım anormal bir hata yüzüden olmadı, ama garip bir şekilde Python SFML’de düzgün çalışıyor.