街機模擬器工作原理

轉自=easyrock(2路轉4路)=的原創,很少看見這麼深入底層的與性機制詳解,牛人啊,膜拜ing進而收藏之!

街機模擬器工作原理         這幾天學習了一下finalburn的源代碼,有一些心得,驚喜之餘,整理出來與大家分享。         我們常說的芯片,通常都是接受一定的輸入,完成特定的計算,併產生結果。輸入通常來自相連的外設,輸出也傳遞到外設。例如,最簡單的計算器,內部也有一個芯片,我們在計算器的鍵盤上輸入3+5=,就轉化成了計算器芯片的輸入參數,並啓動內部的計算邏輯,算出結果8,在屏幕上顯示出這個結果。作爲cpu的計算器芯片,在這個過程中已經與兩個外部設備進行了交互:鍵盤和顯示屏。         cpu與外圍設備交互,通常有特定的方法。像x86系列cpu,提供了io端口,中斷,內存映射這幾種方式。例如cpu與顯卡的交互,通常會使用到以上所有的方式:顯卡的幀緩存通過內存映射,使cpu可以尋址到;顯卡芯片的寄存器(顯卡芯片也是一個cpu,也有寄存器)通過io端口進行讀寫,通過讀寫寄存器這種低級的編程方式,顯卡開始各種工作(例如bitblt),cpu也並行的進行其他計算工作,當顯卡完成某個功能調用後,通過x86中斷通知cpu所需的請求已經完成,進而cpu可以再請求顯卡做下一步工作。通過中斷,cpu和顯卡處於異步的方式,cpu不必等待顯卡完成繪圖。         幾乎所有的芯片都是可編程的,也就是說,可以定製它們的工作方式。只是一些芯片提供了良好的編程接口。像x86系列,MC68000等這樣的芯片,可以運行一段事先寫好的程序,來完成某一特定的功能。而像早期的顯卡芯片,只能通過讀寫寄存器來調用顯卡的功能。         街機模擬器,最核心的功能就是模擬那些街機用到的芯片。比如《街霸2》這個遊戲,用到了MC68000做cpu,Z80做音頻處理,還用到了一個yamaha的音頻芯片,視頻方面應該用到了一個芯片做輸出。         遊戲的rom,包含音頻、視頻數據,和遊戲代碼。代碼就是在MC68000上跑的,因此先說一下MC68000的模擬。         MC68000(m68k)的模擬器是用匯編寫的,來自於MAME。從代碼中可以看出,m68k有一組寄存器、接受中斷輸入、通過內存映射與外設交互。m68k模擬器的內存接口由客戶端(使用該模擬器的代碼,在這裏就是街機模擬程序)提供,這樣一來,客戶端就可以接管m68k與外設的交互了。例如,用於音頻處理的z80,寄存器組被映射到m68k的某一地址段上。m68k在運行過程中讀寫z80的寄存器,實際上回調街機模擬程序的相應代碼,轉發給了z80模擬器。通過這種方式,就可以模擬m68k與其他外設的交互了。         m68k模擬器用本機代碼模擬m68k的指令集。每一條m68k指令用一段本機代碼來模擬。對於一段m68k代碼,結合ip(指令指針)找到對應的m68k指令,通過一個跳轉表,跳到對應的那一段模擬代碼執行。每一條m68k指令模擬出來的執行週期是原指令週期的數倍到數十倍。因爲本機cpu的速度通常是m68k速度的數百倍,所以這樣的模擬不會有問題。         那麼本機cpu(host)與被模擬的cpu(m68k)是如何聯繫的呢?首先必須讓模擬的m68k在正常的主頻下運行。host用指定的頻率(例如60Hz)來調用m68k模擬器,指定讓m68k運行多少個時鐘週期(cycle)。比如想讓m68k跑在8MHz,那麼每次要讓m68k跑8000000/60=133333個cycle。在m68k運行的過程中,自然會通過內存接口與外設交互,這樣就讓其他外設的模擬器開始相應的模擬工作。         視頻部分沒有用到模擬器,我推測是使用的幀緩存(framebuffer)。因爲2D遊戲的畫面都是通過貼圖(tile)完成的,不需要繪製。爲了達到60Hz的刷新率,模擬程序每次在m68k跑完時鐘週期後,讀取這個幀緩存,轉化成符合當前屏幕格式的位圖,blt到顯卡。這個過程還需要進一步的學習。      我的最終目的是想移植finalburn到linux上。國外早就有了這樣一個項目http://fblinux.emuunlim.com,但是爲了學習,一切都要自己去嘗試。         finalburn的源代碼直接在www.finalburn.com下載。邏輯上分爲幾塊:底層的模擬庫burn,上層的ui庫burner。其中burn用到了更底層的a68k和doze兩個模塊,他們分別是MC68000模擬器和Z80模擬器。         街機遊戲包含兩塊板卡:主機板和遊戲板。主機板上有cpu,dsp等處理器,遊戲板上存放的是遊戲rom和加密用的電路。我們玩模擬器,需要解密後的遊戲rom,相當於我們手頭上有了遊戲板,在模擬器中載入遊戲rom,就可以開始遊戲。所以模擬器還需要模擬主機板(基板),而不是光是單純的MC68000cpu和Z80cpu。         基板有點像電腦主板,上面不光有cpu,還有很多外設,像聲卡、網卡等。比如《街霸2》遊戲使用的cps1基板(Capcom   Play   System-1),主控cpu是用的MC68000,音效cpu是用的Z80。爲了能控制Z80,肯定是要把Z80的寄存器空間映射到MC68000的地址空間上來。MC68000讀寫某一段地址的內存,到底是要讀寫數據,還是訪問某個外設的寄存器?不同的基板,各有特定的外設,和映射方式。所以正確的模擬出這些遊戲機板,是模擬器的第二個重要工作(第一個當然是模擬各個獨立的cpu)。         因此,模擬器的底層模塊,分爲模擬cpu和模擬基板兩類。顯然模擬cpu的模塊是高度可複用的,比如mc68000的模擬模塊,可以用於模擬cps1,cps2和mvs(neogeo)基板,因爲他們都用到MC68000cpu。針對新出現的基板,如果用到的某個cpu已經有了模擬的模塊,同樣可以直接使用。MAME就是不斷的模擬各種cpu和基板,以支持更多的遊戲。         對於模擬的每一個遊戲,也需要一個單獨的模塊。提供一些必要的信息和特定的處理。比如這個遊戲有哪些rom,哪些用於視頻,哪些用於音頻,哪些是代碼。有些遊戲也需要特定的前期和後期處理。比如1944這個縱版遊戲,在完成每一幀畫面的組織後,要翻轉90度。這些都是在特定遊戲的模塊中完成的。         burn庫模擬了cps1、cps2和system16基板。這三款基板都是用的MC68000cpu。加入更多的cpu模塊,基版模塊和遊戲模塊,就可以模擬更多的遊戲了。         對於2D遊戲的視頻部分,因爲不像3D遊戲畫面那樣需要動態計算,2D遊戲的所有圖像數據都是事先畫好的。通常分爲幾層,比如角色層、背景層等。每一幀的繪製都是按Z序來繪製這幾層的。用“貼圖”來形容這個過程比用“繪製”更貼切。因爲每一層都被分成了NxM個方塊,按照索引保存在rom的視頻數據區。對於每一幀,計算出出現在屏幕上的那些方塊,按照索引,就能找到每一個方塊對應的圖像。把這些方塊貼到framebuffer中,就形成了生動的圖像。    

目前流行的模擬器都有Save/Load   Game的功能,和錄像功能,這些是如何實現的呢?         先談談遊戲中隨機事件的產生。比如玩《街霸2》,每次我們選同一個人物,第一關的對手也不一定是相同的。又比如,第一關對手即使相同,電腦每次的進攻策略也不一樣。這些都是相當隨機的。可以想象成遊戲使用了一個類似於rand()的函數,根據函數產生的隨機值作出隨機的反應。當然電腦對手的動作不是完全依賴於這個隨機值,肯定還依賴於遊戲者的輸入動作,比如遊戲者跳重腿,電腦對手通常會發出對應的招式來反擊,或者擋,而不是隨機的亂動。當然決定是反擊還是擋,這本身也存在一個隨機的選擇。正是因爲遊戲中處處充滿的不可預測性,才使得遊戲更具有娛樂性。         然而,事實上,真正的隨機是不存在的。這些隨機都是根據一個種子值,經過各種不相關的運算產生的僞隨機值,計算出的值又作爲計算下一個“隨機”值的種子。也就是,只要初始給的那個種子相同,每次計算出的隨機序列都是一樣的。這樣隨機就變成可預見和可再現的了。如果讓初始種子與時間相關,那麼只有相同的時間計算出的隨機序列才相同,這樣就大大降低了隨機重複的可能性。可以推測,遊戲中也用到了時間值來做爲隨機數的種子。這個時間值要麼來自於一個單獨的計時器,要麼就來自於cpu——不管來自於哪裏,每次復位後,這些值都變成初始值了。         打開兩個模擬器,載入相同的遊戲,開始運行。這兩個遊戲運行的畫面肯定是完全相同的。當然這還不足以說明問題。但是你投幣,開始遊戲,即使選擇相同的人物,相同的劇情,遊戲的發展也可能完全不同。這個不同來自於另一種類型的隨機——用戶輸入。因爲用戶的輸入序列不太可能做到每次都相同,即使按鍵的序列相同,按鍵時間幾乎不可能是一樣的(這個時間顯然是相對於遊戲復位的時間)。如果能夠做到在每次復位後,輸入的按鍵和按鍵的時間都相同,那麼遊戲就變成了重複的視頻和音頻序列,也就是——錄像回放。模擬器要做到精確到幀的輸入是很容易的,所以錄像功能的實現,就是記錄遊戲者每次輸入的精確幀號(或時間)和按鍵。播放錄像時,首先復位,然後在適當的幀(或時間)上輸入適當的按鍵。就這麼簡單了。         這個功能可以進一步發展到網絡對戰的實現。首先,連網的多個模擬器需要復位,然後每一幀都需要同步,這個同步就包括很重要的遊戲者輸入信息。首先,幀的同步確保了遊戲時間的一致,也就確保產生一致的隨機序列。再加上用戶輸入的同步,另一個隨機源也達到一致了,所以每一個模擬器上的運行效果就都是相同的“隨機”情況了。當然,網絡對戰實現起來遠比這裏提到的複雜,需要容忍網絡延時,和處理網絡數據的高效率傳送,同步本身也是一個相當傷腦筋的事情。         遊戲的Save/Load功能相對簡單,Save只需記錄當前遊戲的運行數據,以便下次Load時恢復。需要記錄的數據首先是內存。把所有的RAM保存下來,這樣包括那些映射的外設的寄存器也一併存了,恢復時外設也恢復到了相應的狀態。最後還應該保存cpu的寄存器狀態。但是我在finalburn的Load/Save代碼裏沒有找到保存68000cpu寄存器的相應代碼。推測:開始運行後,寄存器就處於穩定的狀態,或者處於一個簡單的循環——讀取RAM和ROM數據,分派到相應的子程序進行處理。所以只需要把RAM數據替換,遊戲就來到了保存時的狀態。這個推測不太合理......  

2D街機遊戲都是以恆定的幀率運行的。比如《街霸2》這款格鬥遊戲,幀率爲60。顯然遊戲運行過程中,CPU並不是一直處於忙碌的狀態,而是等待一個60Hz的時鐘,每次到了時間點上,才運行一幀。這裏的運行一幀,包括檢測玩家輸入,運行遊戲邏輯,產生一幀圖像,和產生時間長度爲1/60秒的音頻數據。這個60Hz的時鐘,來自一個外部設備的中斷。通常這個外部設備就是街機的顯示器。該顯示器刷新率爲60Hz,每秒產生60次VBlank信號,中斷到CPU,驅動遊戲運行。         模擬器的工作循環的僞代碼類似於這樣:     while   (1)   {     休眠直到時間點();             掃描輸入();             設置虛擬機VBlank中斷();             運行虛擬機n個週期(8000000/60);             播放音頻採樣();             更新視頻畫面();     }         但是有幾個問題需要考慮。首先,音頻的播放比較麻煩。聲卡以一定的速率,從一個緩衝區中循環讀取PCM採樣,然後播放。這個恆定的讀取速率由採樣率決定。比如,採樣率爲44100Hz,速率就是每秒44100個採樣。反過來也決定了,聲卡每讀取44100個採樣,時間就是1秒。1/60秒的時間,就是聲卡讀取44100/60=735個採樣的時間。所以,“休眠直到時間點”這個過程,就是等待聲卡讀取完735個採樣的時間,而不是系統時鐘的1/60秒。同時使用兩個時鐘源的結果將是視頻和音頻不同步。         一方面要以聲卡作爲時鐘源,另一方面還要確保聲卡的緩衝區總是有正確的數據可供讀取。不能等到聲卡播放完當前一幀的數據,纔去準備下一幀數據。需要提前準備好即將播放的數據,也就是緩衝一定量的數據。但是緩衝的時間應該儘可能小,以保持模擬器的低延遲性。         通過查詢當前聲卡讀取緩衝區的位置,可以計算大概的時間,以確定是休眠等待還是運行下一幀。但是這個時間只能是大概的,而聲卡的播放速度決定的時間是精確的。每運行一幀,把產生的音頻數據寫到緩衝區,這個過程重複的速度只有跟聲卡讀取數據的速度一模一樣,纔不會導致緩衝區溢出。速度不匹配,就需要控制了,如果產生數據的速度太快,就需要休眠一些時間,等待聲卡播放到合適的位置,反之,則要快速運行幾幀,準備好需要的音頻數據,同時只更新最後一幀的視頻畫面。這種情況下,視頻就丟幀了,但是幾幀的丟失不會影響視頻的流暢性。另外值得注意的是,即使視頻的幀率因爲丟幀而達不到60,虛擬機的運行還是要保持每秒運行60幀的,這樣確保音頻,輸入和其他邏輯的正常運行。         如果加入網絡對戰的支持,“掃描輸入”這個過程,需要通過網絡通信來得到對方的輸入,並同步各方的運行。網絡的延遲是不可預測的,所以在延遲較大的情況下,很難保持音頻的流暢。如果通過緩衝數據來緩解這個問題,又將帶來遊戲的延遲。在這種限制下實現的最好效果,因該是不超過100ms的延遲,和偶爾的停頓。 

發佈了25 篇原創文章 · 獲贊 7 · 訪問量 11萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章