بناء محاكي Chip-8 الخاص بك: دليل شامل خطوة بخطوة
مقدمة إلى عالم المحاكاة وبناء محاكي Chip-8
قبل الغوص في تفاصيل هذا المقال التقني، دعونا نقدم لمحة سريعة عن مفهوم المحاكيات. ببساطة، المحاكي هو برنامج يسمح لنظام حاسوبي واحد بالتصرف وكأنه نظام آخر. من الاستخدامات الشائعة للمحاكيات اليوم محاكاة أنظمة ألعاب الفيديو القديمة مثل Nintendo 64 و Gamecube وغيرها. على سبيل المثال، باستخدام محاكي Nintendo 64، يمكننا تشغيل ألعاب Nintendo 64 مباشرة على جهاز كمبيوتر يعمل بنظام Windows 10، دون الحاجة إلى الجهاز الفعلي.
في سياق مقالنا هذا، سنقوم بمحاكاة نظام Chip-8 على نظامنا المضيف من خلال المحاكي الذي سنقوم ببنائه. يُعد Chip-8 نقطة انطلاق ممتازة لتعلم كيفية إنشاء المحاكيات الخاصة بك. بفضل ذاكرته المحدودة التي تبلغ 4 كيلوبايت (4KB) و36 تعليمة برمجية فقط، يمكنك تشغيل محاكي Chip-8 الخاص بك في أقل من يوم. سيكتسب هذا المشروع المبتدئين المعرفة الأساسية اللازمة للانتقال إلى محاكيات أكبر وأكثر تعقيدًا.
يهدف هذا المقال إلى أن يكون دليلاً شاملاً ومفصلاً لتبسيط جميع المفاهيم. سيكون من المفيد جدًا أن يكون لديك فهم أساسي للأنظمة السداسية عشرية (hex)، الثنائية (binary)، والعمليات على مستوى البت (bitwise operations). سيتم تقسيم كل قسم من المقال حسب الملف الذي نعمل عليه، ثم تقسيمه مرة أخرى حسب الدالة التي نقوم بتطويرها، مما يسهل عملية المتابعة. بمجرد الانتهاء من كل ملف، سأقدم رابطًا للرمز البرمجي الكامل مع التعليقات التوضيحية.
طوال هذا المقال، سنعتمد على المرجع التقني لـ Chip-8 من Cowgod، والذي يشرح كل تفاصيل Chip-8. يمكنك استخدام أي لغة برمجة لإنشاء المحاكي، ولكن هذا المقال سيستخدم لغة JavaScript. أرى أنها أبسط لغة للاستخدام عند إنشاء المحاكي لأول مرة، نظرًا لدعمها الفطري للرسوميات، لوحة المفاتيح، والصوت. الأهم هو أن تفهم عملية المحاكاة، لذا استخدم اللغة التي تشعر بالراحة معها أكثر.
إذا قررت استخدام JavaScript، فستحتاج إلى تشغيل خادم ويب محلي للاختبار. أستخدم Python لهذا الغرض، والذي يسمح لك ببدء خادم ويب في المجلد الحالي عن طريق تشغيل الأمر .python3 -m http.server
سنبدأ بإنشاء ملفي و index.html، ثم ننتقل إلى مكونات العرض (style.cssrenderer)، لوحة المفاتيح (keyboard)، مكبر الصوت (speaker)، وأخيرًا وحدة المعالجة المركزية الفعلية (CPU). سيبدو هيكل مشروعنا كالتالي:
- roms
- scripts
chip8.js
cpu.js
keyboard.js
renderer.js
speaker.js
index.html
style.css
ملفات البدء: Index و Styles
لا يوجد شيء معقد في هذين الملفين؛ إنهما أساسيان للغاية. يقوم ملف ببساطة بتحميل الأنماط (index.htmlstyles)، وإنشاء عنصر ، وتحميل ملف canvas.chip8.js
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<canvas></canvas>
<script type="module" src="scripts/chip8.js"></script>
</body>
</html>
ملف أبسط من ذلك، حيث أن الشيء الوحيد الذي يتم تنسيقه هو عنصر الـ style.css لجعله أسهل في التحديد والرؤية.canvas
canvas {
border: 2px solid black;
}
لن تحتاج إلى تعديل هذين الملفين مرة أخرى طوال هذا المقال، ولكن لا تتردد في تنسيق الصفحة بالطريقة التي تفضلها.
مكون العرض (Renderer): إدارة الرسوميات
سيتولى مكون العرض (renderer) لدينا التعامل مع كل ما يتعلق بالرسوميات. سيقوم بتهيئة عنصر الـ الخاص بنا، وتبديل وحدات البكسل (canvaspixels) داخل شاشتنا، وعرض تلك البكسلات على الـ .canvas
class Renderer {
}
export default Renderer;
الدالة البانية (Constructor): تهيئة شاشة العرض
أول خطوة هي بناء مكون العرض الخاص بنا. ستقبل هذه الدالة البانية وسيطًا واحدًا، وهو ، والذي سيسمح لنا بتكبير أو تصغير شاشة العرض، مما يجعل وحدات البكسل أكبر أو أصغر.scale
class Renderer {
constructor(scale) {
}
}
export default Renderer;
نحتاج إلى تهيئة بعض الأمور داخل هذه الدالة البانية. أولاً، حجم الشاشة، والذي يبلغ 64×32 بكسل لنظام Chip-8.
this.cols = 64;
this.rows = 32;
على نظام حديث، هذا الحجم صغير جدًا ويصعب رؤيته، ولهذا السبب نريد تكبير الشاشة لجعلها أكثر سهولة في الاستخدام. ضمن الدالة البانية، نريد تعيين المقياس (scale)، والحصول على عنصر الـ ، والحصول على سياق الرسم (canvascontext)، وتعيين عرض وارتفاع الـ .canvas
this.scale = scale;
this.canvas = document.querySelector('canvas');
this.ctx = this.canvas.getContext('2d');
this.canvas.width = this.cols * this.scale;
this.canvas.height = this.rows * this.scale;
كما ترون، نحن نستخدم المتغير لزيادة عرض وارتفاع الـ scale. سنستخدم canvas مرة أخرى عندما نبدأ في عرض وحدات البكسل على الشاشة. العنصر الأخير الذي نحتاج إلى إضافته إلى الدالة البانية هو مصفوفة ستعمل كشاشتنا. نظرًا لأن شاشة scaleChip-8 هي 64×32 بكسل، فإن حجم مصفوفتنا هو ببساطة 64 × 32 (الأعمدة × الصفوف)، أو 2048. بشكل أساسي، نحن نمثل كل بكسل، سواء كان قيد التشغيل (1) أو إيقاف التشغيل (0)، على شاشة Chip-8 بهذه المصفوفة.
this.display = new Array(this.cols * this.rows);
سيتم استخدام هذا لاحقًا لعرض وحدات البكسل داخل الـ في الأماكن الصحيحة.canvas
الدالة setPixel(x, y): تبديل حالة البكسل
كلما قام المحاكي بتبديل بكسل إلى حالة التشغيل أو الإيقاف، سيتم تعديل مصفوفة العرض لتمثل ذلك. بالحديث عن تبديل وحدات البكسل، دعنا ننشئ الدالة المسؤولة عن ذلك. سنسمي الدالة وستأخذ موضعين setPixel و x كمعاملات.y
setPixel(x, y) {
}
وفقًا للمرجع التقني، إذا كان البكسل موجودًا خارج حدود الشاشة، فيجب أن يلتف إلى الجانب المقابل، لذلك نحتاج إلى أخذ ذلك في الاعتبار.
if (x > this.cols) {
x -= this.cols;
} else if (x < 0) {
x += this.cols;
}
if (y > this.rows) {
y -= this.rows;
} else if (y < 0) {
y += this.rows;
}
بعد تحديد ذلك، يمكننا حساب موقع البكسل على الشاشة بشكل صحيح.
let pixelLoc = x + (y * this.cols);
إذا لم تكن على دراية بالعمليات على مستوى البت (bitwise operations)، فقد يكون هذا الجزء التالي من الرمز مربكًا. وفقًا للمرجع التقني، يتم تطبيق عملية XOR على الرسوميات (sprites) على الشاشة:
this.display[pixelLoc] ^= 1;
كل ما يفعله هذا السطر هو تبديل القيمة في (من pixelLoc0 إلى 1 أو من 1 إلى 0). القيمة 1 تعني أنه يجب رسم بكسل، والقيمة 0 تعني أنه يجب مسح بكسل. من هنا، نقوم ببساطة بإرجاع قيمة للدلالة على ما إذا كان البكسل قد تم مسحه أم لا. هذا الجزء، على وجه الخصوص، مهم لاحقًا عندما نصل إلى وحدة المعالجة المركزية (CPU) وكتابة التعليمات المختلفة.
return !this.display[pixelLoc];
إذا كانت هذه الدالة تُرجع ، فهذا يعني أن بكسلًا قد تم مسحه. إذا كانت تُرجع true، فلم يتم مسح أي شيء. عندما نصل إلى التعليمة التي تستخدم هذه الدالة، سيصبح الأمر أكثر وضوحًا.false
الدالة clear(): مسح الشاشة
تقوم هذه الدالة بمسح مصفوفة بالكامل عن طريق إعادة تهيئتها.display
clear() {
this.display = new Array(this.cols * this.rows);
}
الدالة render(): عرض البكسلات
الدالة مسؤولة عن عرض وحدات البكسل في مصفوفة render على الشاشة. لهذا المشروع، ستعمل 60 مرة في الثانية.display
render() {
// Clears the display every render cycle. Typical for a render loop.
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Loop through our display array
for (let i = 0; i < this.cols * this.rows; i++) {
// Grabs the x position of the pixel based off of `i`
let x = (i % this.cols) * this.scale;
// Grabs the y position of the pixel based off of `i`
let y = Math.floor(i / this.cols) * this.scale;
// If the value at this.display[i] == 1, then draw a pixel.
if (this.display[i]) {
// Set the pixel color to black
this.ctx.fillStyle = '#000';
// Place a pixel at position (x, y) with a width and height of scale
this.ctx.fillRect(x, y, this.scale, this.scale);
}
}
}
الدالة testRender(): اختبار العرض
لأغراض الاختبار، دعنا ننشئ دالة سترسم بكسلين على الشاشة.
testRender() {
this.setPixel(0, 0);
this.setPixel(5, 2);
}
الرمز البرمجي الكامل لملف :renderer.js
class Renderer {
constructor(scale) {
this.cols = 64;
this.rows = 32;
this.scale = scale;
this.canvas = document.querySelector('canvas');
this.ctx = this.canvas.getContext('2d');
this.canvas.width = this.cols * this.scale;
this.canvas.height = this.rows * this.scale;
this.display = new Array(this.cols * this.rows);
}
setPixel(x, y) {
if (x > this.cols) {
x -= this.cols;
} else if (x < 0) {
x += this.cols;
}
if (y > this.rows) {
y -= this.rows;
} else if (y < 0) {
y += this.rows;
}
let pixelLoc = x + (y * this.cols);
this.display[pixelLoc] ^= 1;
return !this.display[pixelLoc];
}
clear() {
this.display = new Array(this.cols * this.rows);
}
render() {
// Clears the display every render cycle. Typical for a render loop.
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Loop through our display array
for (let i = 0; i < this.cols * this.rows; i++) {
// Grabs the x position of the pixel based off of `i`
let x = (i % this.cols) * this.scale;
// Grabs the y position of the pixel based off of `i`
let y = Math.floor(i / this.cols) * this.scale;
// If the value at this.display[i] == 1, then draw a pixel.
if (this.display[i]) {
// Set the pixel color to black
this.ctx.fillStyle = '#000';
// Place a pixel at position (x, y) with a width and height of scale
this.ctx.fillRect(x, y, this.scale, this.scale);
}
}
}
testRender() {
this.setPixel(0, 0);
this.setPixel(5, 2);
}
}
export default Renderer;
دمج مكون العرض (Renderer) في الملف الرئيسي (chip8.js)
الآن بعد أن أصبح لدينا مكون العرض (renderer)، نحتاج إلى تهيئته داخل ملف الخاص بنا.chip8.js
import Renderer from './renderer.js';
const renderer = new Renderer(10);
من هنا، نحتاج إلى إنشاء حلقة (loop) تعمل، وفقًا للمرجع التقني، بمعدل 60Hz أو 60 إطارًا في الثانية. تمامًا مثل دالة العرض (render) الخاصة بنا، هذا ليس خاصًا بـ Chip-8 ويمكن تعديله قليلاً ليعمل مع أي مشروع آخر تقريبًا.
let loop;
let fps = 60, fpsInterval, startTime, now, then, elapsed;
function init() {
fpsInterval = 1000 / fps;
then = Date.now();
startTime = then;
// TESTING CODE. REMOVE WHEN DONE TESTING.
renderer.testRender();
renderer.render();
// END TESTING CODE
loop = requestAnimationFrame(step);
}
function step() {
now = Date.now();
elapsed = now - then;
if (elapsed > fpsInterval) {
// Cycle the CPU. We'll come back to this later and fill it out.
}
loop = requestAnimationFrame(step);
}
init();
إذا قمت بتشغيل خادم الويب وتحميل الصفحة في متصفح الويب، يجب أن تشاهد بكسلين مرسومين على الشاشة. إذا أردت، العب بالمقياس (scale) وابحث عن ما يناسبك.
مكون لوحة المفاتيح (Keyboard): معالجة المدخلات
مرجع لوحة المفاتيح
يخبرنا المرجع التقني أن Chip-8 يستخدم لوحة مفاتيح سداسية عشرية (hex keypad) مكونة من 16 مفتاحًا، مرتبة كالتالي:
1 2 3 C4 5 6 D7 8 9 EA 0 B F
لجعل هذا يعمل على الأنظمة الحديثة، يجب علينا ربط مفتاح على لوحة مفاتيحنا بكل من مفاتيح Chip-8 هذه. سنفعل ذلك داخل الدالة البانية (constructor)، بالإضافة إلى بعض الأمور الأخرى.
الدالة البانية (Constructor)
class Keyboard {
constructor() {
this.KEYMAP = {
49: 0x1, // 1
50: 0x2, // 2
51: 0x3, // 3
52: 0xc, // 4
81: 0x4, // Q
87: 0x5, // W
69: 0x6, // E
82: 0xD, // R
65: 0x7, // A
83: 0x8, // S
68: 0x9, // D
70: 0xE, // F
90: 0xA, // Z
88: 0x0, // X
67: 0xB, // C
86: 0xF // V
}
this.keysPressed = [];
// Some Chip-8 instructions require waiting for the next keypress. We initialize this function elsewhere when needed.
this.onNextKeyPress = null;
window.addEventListener('keydown', this.onKeyDown.bind(this), false);
window.addEventListener('keyup', this.onKeyUp.bind(this), false);
}
}
export default Keyboard;
داخل الدالة البانية، أنشأنا خريطة مفاتيح (keymap) تربط مفاتيح لوحة مفاتيحنا بمفاتيح لوحة مفاتيح Chip-8. بالإضافة إلى ذلك، لدينا مصفوفة لتتبع المفاتيح المضغوطة (keysPressed)، ومتغير بقيمة (سنتحدث عنه لاحقًا)، واثنين من مستمعي الأحداث (nullevent listeners) للتعامل مع إدخال لوحة المفاتيح.
الدالة isKeyPressed(keyCode): التحقق من ضغط المفتاح
نحتاج إلى طريقة للتحقق مما إذا كان مفتاح معين مضغوطًا. ستقوم هذه الدالة ببساطة بفحص مصفوفة بحثًا عن keysPressed المحدد لـ keyCodeChip-8.
isKeyPressed(keyCode) {
return this.keysPressed[keyCode];
}
الدالة onKeyDown(event): معالجة ضغط المفتاح
في الدالة البانية لدينا، أضفنا مستمع حدث الذي سيستدعي هذه الدالة عند تشغيله.keydown
onKeyDown(event) {
let key = this.KEYMAP[event.which];
this.keysPressed[key] = true;
// Make sure onNextKeyPress is initialized and the pressed key is actually mapped to a Chip-8 key
if (this.onNextKeyPress !== null && key) {
this.onNextKeyPress(parseInt(key));
this.onNextKeyPress = null;
}
}
كل ما نفعله هنا هو إضافة المفتاح المضغوط إلى مصفوفة ، وتشغيل keysPressed إذا تم تهيئته وتم الضغط على مفتاح صالح. دعنا نتحدث عن عبارة onNextKeyPress تلك. إحدى تعليمات ifChip-8 () تنتظر ضغطة مفتاح قبل متابعة التنفيذ. سنجعل تعليمة Fx0A تهيئ دالة Fx0A، مما سيسمح لنا بمحاكاة هذا السلوك المتمثل في الانتظار حتى ضغطة المفتاح التالية. بمجرد كتابة هذه التعليمة، سأشرح هذا بمزيد من التفصيل حيث يجب أن يكون أكثر منطقية عندما تراه.onNextKeyPress
الدالة onKeyUp(event): معالجة تحرير المفتاح
لدينا أيضًا مستمع حدث للتعامل مع أحداث ، وسيتم استدعاء هذه الدالة عند تشغيل هذا الحدث.keyup
onKeyUp(event) {
let key = this.KEYMAP[event.which];
this.keysPressed[key] = false;
}
الرمز البرمجي الكامل لملف :keyboard.js
class Keyboard {
constructor() {
this.KEYMAP = {
49: 0x1, // 1
50: 0x2, // 2
51: 0x3, // 3
52: 0xc, // 4
81: 0x4, // Q
87: 0x5, // W
69: 0x6, // E
82: 0xD, // R
65: 0x7, // A
83: 0x8, // S
68: 0x9, // D
70: 0xE, // F
90: 0xA, // Z
88: 0x0, // X
67: 0xB, // C
86: 0xF // V
}
this.keysPressed = [];
// Some Chip-8 instructions require waiting for the next keypress. We initialize this function elsewhere when needed.
this.onNextKeyPress = null;
window.addEventListener('keydown', this.onKeyDown.bind(this), false);
window.addEventListener('keyup', this.onKeyUp.bind(this), false);
}
isKeyPressed(keyCode) {
return this.keysPressed[keyCode];
}
onKeyDown(event) {
let key = this.KEYMAP[event.which];
this.keysPressed[key] = true;
// Make sure onNextKeyPress is initialized and the pressed key is actually mapped to a Chip-8 key
if (this.onNextKeyPress !== null && key) {
this.onNextKeyPress(parseInt(key));
this.onNextKeyPress = null;
}
}
onKeyUp(event) {
let key = this.KEYMAP[event.which];
this.keysPressed[key] = false;
}
}
export default Keyboard;
دمج مكون لوحة المفاتيح (Keyboard) في الملف الرئيسي (chip8.js)
مع إنشاء فئة لوحة المفاتيح (Keyboard)، يمكننا العودة إلى ملف وربط لوحة المفاتيح.chip8.js
import Renderer from './renderer.js';
import Keyboard from './keyboard.js'; // NEW
const renderer = new Renderer(10);
const keyboard = new Keyboard(); // NEW
مكون مكبر الصوت (Speaker): توليد الأصوات
دعنا الآن ننشئ بعض الأصوات. هذا الملف مباشر إلى حد ما ويتضمن إنشاء صوت بسيط وبدء/إيقافه.
الدالة البانية (Constructor)
class Speaker {
constructor() {
const AudioContext = window.AudioContext || window.webkitAudioContext;
this.audioCtx = new AudioContext();
// Create a gain, which will allow us to control the volume
this.gain = this.audioCtx.createGain();
this.finish = this.audioCtx.destination;
// Connect the gain to the audio context
this.gain.connect(this.finish);
}
}
export default Speaker;
كل ما نفعله هنا هو إنشاء وربط كسب (AudioContextgain) به حتى نتمكن من التحكم في مستوى الصوت. لن أضيف التحكم في مستوى الصوت في هذا البرنامج التعليمي، ولكن إذا كنت ترغب في إضافته بنفسك، فما عليك سوى استخدام ما يلي:
// Mute the audio
this.gain.setValueAtTime(0, this.audioCtx.currentTime);
// Unmute the audio
this.gain.setValueAtTime(1, this.audioCtx.currentTime);
الدالة play(frequency): تشغيل الصوت
تقوم هذه الدالة بما يوحي به اسمها تمامًا: تشغيل صوت بالتردد المطلوب.
play(frequency) {
if (this.audioCtx && !this.oscillator) {
this.oscillator = this.audioCtx.createOscillator();
// Set the frequency
this.oscillator.frequency.setValueAtTime(frequency || 440, this.audioCtx.currentTime);
// Square wave
this.oscillator.type = 'square';
// Connect the gain and start the sound
this.oscillator.connect(this.gain);
this.oscillator.start();
}
}
نحن ننشئ مذبذبًا (oscillator) وهو الذي سيقوم بتشغيل صوتنا. نحدد تردده ونوعه ونوصله بالكسب (gain)، ثم أخيرًا نشغل الصوت. لا يوجد شيء معقد هنا.
الدالة stop(): إيقاف الصوت
يجب علينا في النهاية إيقاف الصوت حتى لا يستمر في التشغيل باستمرار.
stop() {
if (this.oscillator) {
this.oscillator.stop();
this.oscillator.disconnect();
this.oscillator = null;
}
}
كل ما يفعله هذا هو إيقاف الصوت، وفصله، وتعيينه إلى حتى يمكن إعادة تهيئته في دالة null.play()
الرمز البرمجي الكامل لملف :speaker.js
class Speaker {
constructor() {
const AudioContext = window.AudioContext || window.webkitAudioContext;
this.audioCtx = new AudioContext();
// Create a gain, which will allow us to control the volume
this.gain = this.audioCtx.createGain();
this.finish = this.audioCtx.destination;
// Connect the gain to the audio context
this.gain.connect(this.finish);
}
play(frequency) {
if (this.audioCtx && !this.oscillator) {
this.oscillator = this.audioCtx.createOscillator();
// Set the frequency
this.oscillator.frequency.setValueAtTime(frequency || 440, this.audioCtx.currentTime);
// Square wave
this.oscillator.type = 'square';
// Connect the gain and start the sound
this.oscillator.connect(this.gain);
this.oscillator.start();
}
}
stop() {
if (this.oscillator) {
this.oscillator.stop();
this.oscillator.disconnect();
this.oscillator = null;
}
}
}
export default Speaker;
دمج مكون مكبر الصوت (Speaker) في الملف الرئيسي (chip8.js)
يمكننا الآن ربط مكبر الصوت بملف الرئيسي الخاص بنا.chip8.js
import Renderer from './renderer.js';
import Keyboard from './keyboard.js';
import Speaker from './speaker.js'; // NEW
const renderer = new Renderer(10);
const keyboard = new Keyboard();
const speaker = new Speaker(); // NEW
وحدة المعالجة المركزية (CPU): قلب المحاكي
الآن نصل إلى الجزء الفعلي من محاكي Chip-8. هنا تصبح الأمور أكثر تعقيدًا بعض الشيء، لكنني سأبذل قصارى جهدي لشرح كل شيء بطريقة تكون منطقية ومفهومة.
الدالة البانية (Constructor): تهيئة المكونات الأساسية
نحتاج إلى تهيئة بعض المتغيرات الخاصة بـ Chip-8 داخل الدالة البانية الخاصة بنا، بالإضافة إلى بعض المتغيرات الأخرى. سننظر إلى القسم 2 من المرجع التقني لتحديد مواصفات محاكي Chip-8 الخاص بنا. فيما يلي مواصفات Chip-8:
- ذاكرة بحجم 4 كيلوبايت (
4096 bytes) - 16 سجلًا (
registers) بحجم 8 بت - سجل واحد بحجم 16 بت (
) لتخزين عناوين الذاكرةthis.i - مؤقتان. أحدهما للتأخير (
delay)، والآخر للصوت (sound). - عداد برنامج (
program counter) يخزن العنوان الذي يتم تنفيذه حاليًا - مصفوفة لتمثيل المكدس (
stack)
لدينا أيضًا متغير يخزن ما إذا كان المحاكي متوقفًا مؤقتًا أم لا (paused)، وسرعة تنفيذ المحاكي (speed).
class CPU {
constructor(renderer, keyboard, speaker) {
this.renderer = renderer;
this.keyboard = keyboard;
this.speaker = speaker;
// 4KB (4096 bytes) of memory
this.memory = new Uint8Array(4096);
// 16 8-bit registers
this.v = new Uint8Array(16);
// Stores memory addresses. Set this to 0 since we aren't storing anything at initialization.
this.i = 0;
// Timers
this.delayTimer = 0;
this.soundTimer = 0;
// Program counter. Stores the currently executing address.
this.pc = 0x200;
// Don't initialize this with a size in order to avoid empty results.
this.stack = new Array();
// Some instructions require pausing, such as Fx0A.
this.paused = false;
this.speed = 10;
}
}
export default CPU;
الدالة loadSpritesIntoMemory(): تحميل الرسوميات إلى الذاكرة
لهذه الدالة، سنرجع إلى القسم 2.4 من المرجع التقني. يستخدم Chip-8 16 رسومية (sprites)، كل منها بحجم 5 بايت. هذه الرسوميات هي ببساطة الأرقام السداسية عشرية من 0 إلى F. يمكنك رؤية جميع الرسوميات، بقيمها الثنائية والسداسية عشرية، في القسم 2.4. في الكود الخاص بنا، نقوم ببساطة بتخزين القيم السداسية عشرية للرسوميات التي يوفرها المرجع التقني في مصفوفة. إذا كنت لا ترغب في كتابتها كلها يدويًا، فلا تتردد في نسخ ولصق المصفوفة في مشروعك. ينص المرجع على أن هذه الرسوميات يتم تخزينها في قسم المترجم (interpreter section) من الذاكرة (من إلى 0x000). دعنا نلقي نظرة على الكود لهذه الدالة لنرى كيف يتم ذلك.0x1FFF
loadSpritesIntoMemory() {
// Array of hex values for each sprite. Each sprite is 5 bytes.
// The technical reference provides us with each one of these values.
const sprites = [
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
];
// According to the technical reference, sprites are stored in the interpreter section of memory starting at hex 0x000
for (let i = 0; i < sprites.length; i++) {
this.memory[i] = sprites[i];
}
}
كل ما فعلناه هو المرور عبر كل بايت في مصفوفة وتخزينه في الذاكرة بدءًا من العنوان السداسي عشري sprites.0x000
الدالة loadProgramIntoMemory(program): تحميل البرامج إلى الذاكرة
لتشغيل ملفات الـ ROM، يجب علينا تحميلها في الذاكرة. هذا أسهل بكثير مما قد يبدو. كل ما علينا فعله هو المرور عبر محتويات الـ ROM/البرنامج وتخزينها في الذاكرة. يخبرنا المرجع التقني على وجه التحديد أن "معظم برامج Chip-8 تبدأ في الموقع 0x200". لذلك عندما نقوم بتحميل الـ ROM في الذاكرة، نبدأ من ونزيد من هناك.0x200
loadProgramIntoMemory(program) {
for (let loc = 0; loc < program.length; loc++) {
this.memory[0x200 + loc] = program[loc];
}
}
الدالة loadRom(romName): جلب وتشغيل ملفات الـ ROM
الآن لدينا طريقة لتحميل الـ ROM في الذاكرة، ولكن يجب علينا أولاً جلب الـ ROM من نظام الملفات قبل أن يتم تحميله في الذاكرة. لكي يعمل هذا، يجب أن يكون لديك ملف ROM. لقد قمت بتضمين بعضها في مستودع GitHub لتنزيلها ووضعها في مجلد الخاص بمشروعك. توفر romsJavaScript طريقة لإجراء طلب HTTP واسترداد ملف. لقد أضفت تعليقات إلى الكود أدناه لشرح ما يحدث:
loadRom(romName) {
var request = new XMLHttpRequest;
var self = this;
// Handles the response received from sending (request.send()) our request
request.onload = function() {
// If the request response has content
if (request.response) {
// Store the contents of the response in an 8-bit array
let program = new Uint8Array(request.response);
// Load the ROM/program into memory
self.loadProgramIntoMemory(program);
}
}
// Initialize a GET request to retrieve the ROM from our roms folder
request.open('GET', 'roms/' + romName);
request.responseType = 'arraybuffer';
// Send the GET request
request.send();
}
من هنا، يمكننا البدء في دورة وحدة المعالجة المركزية (CPU cycle) التي ستتعامل مع تنفيذ التعليمات، بالإضافة إلى بعض الأمور الأخرى.
الدالة cycle(): دورة عمل وحدة المعالجة المركزية
أعتقد أنه سيكون من الأسهل فهم كل شيء إذا رأيت ما يحدث في كل مرة تدور فيها وحدة المعالجة المركزية. هذه هي الدالة التي سنستدعيها في دالة في ملف step، والتي إذا تذكرت، يتم تنفيذها حوالي 60 مرة في الثانية. سنتناول هذه الدالة جزءًا تلو الآخر. في هذه المرحلة، لم يتم إنشاء الدوال التي يتم استدعاؤها داخل chip8.js بعد. سنقوم بإنشائها قريبًا. أول جزء من الكود داخل دالة cycle هو حلقة cycle تتعامل مع تنفيذ التعليمات. هنا يأتي دور متغير for الخاص بنا. كلما زادت هذه القيمة، زاد عدد التعليمات التي سيتم تنفيذها في كل دورة.speed
cycle() {
for (let i = 0; i < this.speed; i++) {
}
}
نريد أيضًا أن نضع في اعتبارنا أنه يجب تنفيذ التعليمات فقط عندما يكون المحاكي قيد التشغيل.
cycle() {
for (let i = 0; i < this.speed; i++) {
if (!this.paused) {
}
}
}
إذا ألقيت نظرة على القسم 3.1، يمكنك رؤية جميع التعليمات المختلفة ورموز التشغيل (opcodes) الخاصة بها. تبدو شيئًا مثل أو 00E0 على سبيل المثال. لذا فإن مهمتنا هي الحصول على رمز التشغيل هذا من الذاكرة وتمريره إلى دالة أخرى ستتعامل مع تنفيذ تلك التعليمة. دعنا نلقي نظرة على الكود أولاً، ثم سأشرحه:9xy0
cycle() {
for (let i = 0; i < this.speed; i++) {
if (!this.paused) {
let opcode = (this.memory[this.pc] << 8 | this.memory[this.pc + 1]);
this.executeInstruction(opcode);
}
}
}
دعنا نلقي نظرة على هذا السطر على وجه الخصوص: . بالنسبة لأولئك الذين ليسوا على دراية كبيرة بالعمليات على مستوى البت (let opcode = (this.memory[this.pc] << 8 | this.memory[this.pc + 1]);bitwise operations)، قد يكون هذا مخيفًا للغاية. أولاً وقبل كل شيء، كل تعليمة يبلغ طولها 16 بت (2 بايت) (القسم 3.0)، ولكن ذاكرتنا تتكون من أجزاء بحجم 8 بت (1 بايت). هذا يعني أننا يجب أن نجمع قطعتين من الذاكرة للحصول على رمز التشغيل الكامل. هذا هو السبب في وجود و this.pc في سطر الكود أعلاه. نحن ببساطة نأخذ نصفي رمز التشغيل. ولكن لا يمكنك مجرد دمج قيمتين بحجم 1 بايت للحصول على قيمة بحجم 2 بايت. للقيام بذلك بشكل صحيح، نحتاج إلى إزاحة الجزء الأول من الذاكرة، this.pc + 1، 8 بتات إلى اليسار لجعله بحجم 2 بايت. بأبسط العبارات، سيضيف هذا صفرين، أو بشكل أكثر دقة القيمة السداسية عشرية this.memory[this.pc] إلى الجانب الأيمن من قيمتنا ذات البايت الواحد، مما يجعلها 2 بايت. على سبيل المثال، إزاحة القيمة السداسية عشرية 0x00 8 بتات إلى اليسار ستعطينا القيمة السداسية عشرية 0x11. من هناك، نقوم بعملية 0x1100OR على مستوى البت () مع الجزء الثاني من الذاكرة، |.this.memory[this.pc + 1]
إليك مثال خطوة بخطوة سيساعدك على فهم ما يعنيه كل هذا بشكل أفضل. لنفترض بعض القيم، كل منها بحجم 1 بايت:
this.memory[this.pc] = PC = 0x10this.memory[this.pc + 1] = PC + 1 = 0xF0
إزاحة 8 بتات (1 بايت) إلى اليسار لجعله 2 بايت:PC
PC = 0x1000
عملية OR على مستوى البت لـ و PC:PC + 1
أوPC | PC + 1 = 0x10F00x1000 | 0xF0 = 0x10F0
أخيرًا، نريد تحديث مؤقتاتنا عندما يكون المحاكي قيد التشغيل (وليس متوقفًا مؤقتًا)، وتشغيل الأصوات، وعرض الرسوميات على الشاشة:
cycle() {
for (let i = 0; i < this.speed; i++) {
if (!this.paused) {
let opcode = (this.memory[this.pc] << 8 | this.memory[this.pc + 1]);
this.executeInstruction(opcode);
}
}
if (!this.paused) {
this.updateTimers();
}
this.playSound();
this.renderer.render();
}
تعمل هذه الدالة كدماغ لمحاكينا بطريقة ما. إنها تتعامل مع تنفيذ التعليمات، وتحديث المؤقتات، وتشغيل الصوت، وعرض المحتوى على الشاشة. لم نقم بإنشاء أي من هذه الدوال بعد، ولكن رؤية كيفية دورة وحدة المعالجة المركزية عبر كل شيء ستجعل هذه الدوال أكثر منطقية عندما ننشئها.
الدالة updateTimers(): تحديث المؤقتات
دعنا ننتقل إلى القسم 2.5 ونقوم بإعداد منطق المؤقتات والصوت. ينخفض كل مؤقت، مؤقت التأخير (delay) ومؤقت الصوت (sound)، بمقدار 1 بمعدل 60Hz. بعبارة أخرى، كل 60 إطارًا ستنخفض مؤقتاتنا بمقدار 1.
updateTimers() {
if (this.delayTimer > 0) {
this.delayTimer -= 1;
}
if (this.soundTimer > 0) {
this.soundTimer -= 1;
}
}
يستخدم مؤقت التأخير لتتبع متى تحدث أحداث معينة. يستخدم هذا المؤقت في تعليمات فقط: مرة لتعيين قيمته، ومرة أخرى لقراءة قيمته والتفرع إلى تعليمة أخرى إذا كانت قيمة معينة موجودة. مؤقت الصوت هو الذي يتحكم في طول الصوت. طالما أن قيمة أكبر من الصفر، سيستمر الصوت في التشغيل. عندما يصل مؤقت الصوت إلى الصفر، سيتوقف الصوت. هذا يقودنا إلى دالتنا التالية حيث سنفعل ذلك بالضبط.this.soundTimer
الدالة playSound(): تشغيل الأصوات
للتأكيد، طالما أن مؤقت الصوت أكبر من الصفر، نريد تشغيل صوت. سنستخدم دالة من فئة play التي أنشأناها سابقًا لتشغيل صوت بتردد 440.Speaker
playSound() {
if (this.soundTimer > 0) {
this.speaker.play(440);
} else {
this.speaker.stop();
}
}
الدالة executeInstruction(opcode): تنفيذ التعليمات
لهذه الدالة بأكملها، سنرجع إلى القسمين 3.0 و 3.1 من المرجع التقني. هذه هي الدالة الأخيرة التي نحتاجها لهذا الملف، وهي طويلة. يجب علينا كتابة المنطق لجميع تعليمات Chip-8 البالغ عددها 36. لحسن الحظ، تتطلب معظم هذه التعليمات بضعة أسطر فقط من الكود. أول معلومة يجب أن تكون على دراية بها هي أن جميع التعليمات يبلغ طولها 2 بايت. لذلك في كل مرة ننفذ فيها تعليمة، أو نشغل هذه الدالة، يجب علينا زيادة عداد البرنامج () بمقدار 2 حتى تعرف وحدة المعالجة المركزية مكان التعليمة التالية.this.pc
executeInstruction(opcode) {
// Increment the program counter to prepare it for the next instruction.
// Each instruction is 2 bytes long, so increment it by 2.
this.pc += 2;
}
دعنا نلقي نظرة على هذا الجزء من القسم 3.0 الآن:
In these listings, the following variables are used:
nnnn or addr - A 12-bit value, the lowest 12 bits of the instruction
n or nibble - A 4-bit value, the lowest 4 bits of the instruction
x - A 4-bit value, the lower 4 bits of the high byte of the instruction
y - A 4-bit value, the upper 4 bits of the low byte of the instruction
kk or byte - An 8-bit value, the lowest 8 bits of the instruction
لتجنب تكرار الكود، يجب أن ننشئ متغيرات لقيم و x لأنها تستخدمها جميع التعليمات تقريبًا. المتغيرات الأخرى المذكورة أعلاه لا تستخدم بما يكفي لتبرير حساب قيمها في كل مرة. هاتان القيمتان كل منهما بحجم 4 بت (أي نصف بايت أو ynibble). تقع قيمة في الأربع بتات الدنيا من البايت الأعلى (xhigh byte) وتقع في الأربع بتات العليا من البايت الأدنى (ylow byte). على سبيل المثال، إذا كان لدينا تعليمة ، فسيكون البايت الأعلى هو 0x5460 والبايت الأدنى هو 0x54. الأربع بتات الدنيا، أو الـ 0x60nibble، من البايت الأعلى ستكون والأربع بتات العليا من البايت الأدنى ستكون 0x4. لذلك، في هذا المثال، 0x6 و x = 0x4. مع معرفة كل ذلك، دعنا نكتب الكود الذي سيحصل على قيم y = 0x6 و x.y
executeInstruction(opcode) {
this.pc += 2;
// We only need the 2nd nibble, so grab the value of the 2nd nibble
// and shift it right 8 bits to get rid of everything but that 2nd nibble.
let x = (opcode & 0x0F00) >> 8;
// We only need the 3rd nibble, so grab the value of the 3rd nibble
// and shift it right 4 bits to get rid of everything but that 3rd nibble.
let y = (opcode & 0x00F0) >> 4;
}
لشرح هذا، دعنا نفترض مرة أخرى أن لدينا تعليمة . إذا قمنا بعملية 0x5460 ( & bitwise AND) لهذه التعليمة مع القيمة السداسية عشرية ، فسنحصل على 0x0F00. قم بإزاحة ذلك 8 بتات إلى اليمين وسنحصل على 0x0400 أو 0x04. نفس الشيء مع 0x4. نقوم بعملية y للتعليمة مع القيمة السداسية عشرية & ونحصل على 0x00F0. قم بإزاحة ذلك 4 بتات إلى اليمين وسنحصل على 0x0060 أو 0x006. الآن نأتي إلى الجزء الممتع، كتابة المنطق لجميع التعليمات الـ 36. لكل تعليمة، قبل كتابة الكود، أوصي بشدة بقراءة ما تفعله تلك التعليمة في المرجع التقني حيث ستفهمها بشكل أفضل بكثير. سأقدم لك عبارة 0x6 الفارغة التي ستستخدمها لأنها طويلة جدًا.switch
switch (opcode & 0xF000) {
case 0x0000:
switch (opcode) {
case 0x00E0:
break;
case 0x00EE:
break;
}
break;
case 0x1000:
break;
case 0x2000:
break;
case 0x3000:
break;
case 0x4000:
break;
case 0x5000:
break;
case 0x6000:
break;
case 0x7000:
break;
case 0x8000:
switch (opcode & 0xF) {
case 0x0:
break;
case 0x1:
break;
case 0x2:
break;
case 0x3:
break;
case 0x4:
break;
case 0x5:
break;
case 0x6:
break;
case 0x7:
break;
case 0xE:
break;
}
break;
case 0x9000:
break;
case 0xA000:
break;
case 0xB000:
break;
case 0xC000:
break;
case 0xD000:
break;
case 0xE000:
switch (opcode & 0xFF) {
case 0x9E:
break;
case 0xA1:
break;
}
break;
case 0xF000:
switch (opcode & 0xFF) {
case 0x07:
break;
case 0x0A:
break;
case 0x15:
break;
case 0x18:
break;
case 0x1E:
break;
case 0x29:
break;
case 0x33:
break;
case 0x55:
break;
case 0x65:
break;
}
break;
default:
throw new Error('Unknown opcode ' + opcode);
}
كما ترون من ، نحن نأخذ الأربع بتات العليا من البايت الأكثر أهمية في رمز التشغيل (switch (opcode & 0xF000)opcode). إذا ألقيت نظرة على التعليمات المختلفة في المرجع التقني، ستلاحظ أنه يمكننا تضييق نطاق رموز التشغيل المختلفة بواسطة هذا الـ nibble الأول.
0nnn - SYS addr: استدعاء روتين نظام (تجاهل)
يمكن تجاهل رمز التشغيل هذا.
00E0 - CLS: مسح الشاشة
مسح شاشة العرض.
case 0x00E0:
this.renderer.clear();
break;
00EE - RET: العودة من روتين فرعي
إخراج العنصر الأخير من مصفوفة وتخزينه في stack. سيعيدنا هذا من روتين فرعي.this.pc
case 0x00EE:
this.pc = this.stack.pop();
break;
ينص المرجع التقني على أن هذه التعليمة أيضًا "تطرح 1 من مؤشر المكدس (stack pointer)". يستخدم مؤشر المكدس للإشارة إلى أعلى مستوى في المكدس. ولكن بفضل مصفوفة الخاصة بنا، لا داعي للقلق بشأن مكان قمة المكدس حيث يتم التعامل معها بواسطة المصفوفة. لذلك بالنسبة لبقية التعليمات، إذا ذكرت شيئًا عن مؤشر المكدس، يمكنك تجاهله بأمان.stack
1nnn - JP addr: القفز إلى عنوان
تعيين عداد البرنامج (program counter) إلى القيمة المخزنة في .nnn
case 0x1000:
this.pc = (opcode & 0xFFF);
break;
تأخذ قيمة 0xFFF. لذا فإن nnn ستعطينا 0x1426 & 0xFFF ثم نقوم بتخزين ذلك في 0x426.this.pc
2nnn - CALL addr: استدعاء روتين فرعي
لهذا، ينص المرجع التقني على أنه يجب علينا زيادة مؤشر المكدس (stack pointer) بحيث يشير إلى القيمة الحالية لـ . مرة أخرى، نحن لا نستخدم مؤشر مكدس في مشروعنا حيث تتعامل مصفوفة this.pc مع ذلك نيابة عنا. لذلك بدلاً من زيادته، نقوم فقط بدفع stack إلى المكدس مما سيعطينا نفس النتيجة. ومثلما هو الحال مع رمز التشغيل this.pc، نأخذ قيمة 1nnn ونخزنها في nnn.this.pc
case 0x2000:
this.stack.push(this.pc);
this.pc = (opcode & 0xFFF);
break;
3xkk - SE Vx, byte: تخطي إذا كان Vx يساوي kk
هنا يأتي دور قيمة التي حسبناها أعلاه. تقارن هذه التعليمة القيمة المخزنة في السجل x (x) بقيمة Vx. لاحظ أن kk يشير إلى سجل، والقيمة التي تليه، في هذه الحالة V، هي رقم السجل. إذا كانت متساوية، نزيد عداد البرنامج بمقدار 2، متجاوزين التعليمة التالية بشكل فعال.x
case 0x3000:
if (this.v[x] === (opcode & 0xFF)) {
this.pc += 2;
}
break;
جزء في عبارة opcode & 0xFF يأخذ ببساطة البايت الأخير من رمز التشغيل. هذا هو جزء if من رمز التشغيل.kk
4xkk - SNE Vx, byte: تخطي إذا كان Vx لا يساوي kk
هذه التعليمة مشابهة جدًا لـ ، ولكنها تتخطى التعليمة التالية إذا كان 3xkk و Vx غير متساويين.kk
case 0x4000:
if (this.v[x] !== (opcode & 0xFF)) {
this.pc += 2;
}
break;
5xy0 - SE Vx, Vy: تخطي إذا كان Vx يساوي Vy
الآن نستخدم كلاً من و x. هذه التعليمة، مثل الاثنتين السابقتين، ستتخطى التعليمة التالية إذا تم استيفاء شرط. في حالة هذه التعليمة، إذا كان y يساوي Vx، فإننا نتخطى التعليمة التالية.Vy
case 0x5000:
if (this.v[x] === this.v[y]) {
this.pc += 2;
}
break;
6xkk - LD Vx, byte: تعيين قيمة Vx
ستقوم هذه التعليمة بتعيين قيمة إلى قيمة Vx.kk
case 0x6000:
this.v[x] = (opcode & 0xFF);
break;
7xkk - ADD Vx, byte: إضافة إلى Vx
تضيف هذه التعليمة إلى kk.Vx
case 0x7000:
this.v[x] += (opcode & 0xFF);
break;
8xy0 - LD Vx, Vy: تعيين Vx من Vy
قبل مناقشة هذه التعليمة، أود أن أشرح ما يحدث مع . لماذا عبارة switch (opcode & 0xF) داخل switch؟ السبب وراء ذلك هو أن لدينا عددًا قليلاً من التعليمات المختلفة التي تندرج تحت switch. إذا ألقيت نظرة على تلك التعليمات في المرجع التقني، ستلاحظ أن الـ case 0x8000:nibble الأخير لكل من هذه التعليمات ينتهي بقيمة من 0-7 أو E. لدينا هذه الـ switch لأخذ هذا الـ nibble الأخير، ثم إنشاء حالة لكل منها للتعامل معها بشكل صحيح. سنفعل هذا عدة مرات أخرى طوال عبارة الرئيسية. مع شرح ذلك، دعنا ننتقل إلى التعليمة. لا يوجد شيء معقد هنا، فقط تعيين قيمة switch لتكون مساوية لقيمة Vx.Vy
case 0x0:
this.v[x] = this.v[y];
break;
8xy1 - OR Vx, Vy: عملية OR بين Vx و Vy
تعيين إلى قيمة Vx.Vx OR Vy
case 0x1:
this.v[x] |= this.v[y];
break;
8xy2 - AND Vx, Vy: عملية AND بين Vx و Vy
تعيين ليكون مساويًا لقيمة Vx.Vx AND Vy
case 0x2:
this.v[x] &= this.v[y];
break;
8xy3 - XOR Vx, Vy: عملية XOR بين Vx و Vy
تعيين ليكون مساويًا لقيمة Vx.Vx XOR Vy
case 0x3:
this.v[x] ^= this.v[y];
break;
8xy4 - ADD Vx, Vy: إضافة Vy إلى Vx مع التعامل مع الحمل
تقوم هذه التعليمة بتعيين إلى Vx. يبدو الأمر سهلاً، ولكن هناك القليل من التفاصيل الإضافية. إذا قرأنا وصف هذه التعليمة الوارد في المرجع التقني، فإنه ينص على ما يلي:Vx + Vy
إذا كانت النتيجة أكبر من 8 بتات (أي، > 255)، يتم تعيين VF إلى 1، وإلا 0. يتم الاحتفاظ بالبتات الثمانية الأقل أهمية فقط من النتيجة، وتخزينها في Vx.
case 0x4:
let sum = (this.v[x] += this.v[y]);
this.v[0xF] = 0;
if (sum > 0xFF) {
this.v[0xF] = 1;
}
this.v[x] = sum;
break;
بأخذ هذا السطر تلو الآخر، نقوم أولاً بإضافة إلى this.v[y] وتخزين تلك القيمة في متغير this.v[x]. من هناك نعيّن sum، أو this.v[0xF]، إلى 0. نفعل ذلك لتجنب الاضطرار إلى استخدام عبارة VF في السطر التالي. إذا كان المجموع أكبر من 255، أو القيمة السداسية عشرية if-else، نعيّن 0xFF إلى 1. أخيرًا، نعيّن VF، أو this.v[x]، إلى المجموع. قد تتساءل كيف نضمن "الاحتفاظ بالبتات الثمانية الأقل أهمية فقط من النتيجة، وتخزينها في VxVx". بفضل كون مصفوفة من النوع this.v، فإن أي قيمة تزيد عن 8 بتات تأخذ تلقائيًا البتات الثمانية الأقل أهمية (اليمنى) وتخزنها في المصفوفة. لذلك لا نحتاج إلى فعل أي شيء خاص بها. دعني أقدم لك مثالاً لتوضيح هذا بشكل أفضل. لنفترض أننا نحاول وضع القيمة العشرية 257 في مصفوفة Uint8Array. في النظام الثنائي، هذه القيمة هي this.v، وهي قيمة 9 بتات. عندما نحاول تخزين هذه القيمة ذات الـ 9 بتات في المصفوفة، ستأخذ فقط البتات الثمانية الأقل أهمية. هذا يعني أن القيمة الثنائية 100000001، وهي 1 في النظام العشري، ستُخزن في 00000001.this.v
8xy5 - SUB Vx, Vy: طرح Vy من Vx مع التعامل مع الاستعارة
تطرح هذه التعليمة من Vy. تمامًا كما يتم التعامل مع تجاوز السعة (Vxoverflow) في التعليمة السابقة، يجب علينا التعامل مع نقص السعة (underflow) لهذه التعليمة.
case 0x5:
this.v[0xF] = 0;
if (this.v[x] > this.v[y]) {
this.v[0xF] = 1;
}
this.v[x] -= this.v[y];
break;
مرة أخرى، نظرًا لأننا نستخدم ، لا يتعين علينا فعل أي شيء للتعامل مع نقص السعة حيث يتم الاعتناء به نيابة عنا. لذلك، ستصبح Uint8Array-1 هي 255، و -2 تصبح 254، وهكذا.
8xy6 - SHR Vx {, Vy}: إزاحة Vx إلى اليمين
case 0x6:
this.v[0xF] = (this.v[x] & 0x1);
this.v[x] >>= 1;
break;
سيحدد هذا السطر البت الأقل أهمية (this.v[0xF] = (this.v[x] & 0x1);least-significant bit) ويعين وفقًا لذلك. هذا أسهل بكثير للفهم إذا نظرت إلى تمثيله الثنائي. إذا كانت VF، في النظام الثنائي، هي Vx، فسيتم تعيين 1001 إلى 1 لأن البت الأقل أهمية هو 1. إذا كانت VF هي Vx، فسيتم تعيين 1000 إلى 0.VF
8xy7 - SUBN Vx, Vy: طرح Vx من Vy
case 0x7:
this.v[0xF] = 0;
if (this.v[y] > this.v[x]) {
this.v[0xF] = 1;
}
this.v[x] = this.v[y] - this.v[x];
break;
تطرح هذه التعليمة من Vx وتخزن النتيجة في Vy. إذا كانت Vx أكبر من Vy، نحتاج إلى تخزين 1 في Vx، وإلا نخزن 0.VF
8xyE - SHL Vx {, Vy}: إزاحة Vx إلى اليسار
لا تقوم هذه التعليمة بإزاحة إلى اليسار بمقدار 1 فحسب، بل تقوم أيضًا بتعيين Vx إلى 0 أو 1 اعتمادًا على ما إذا كان الشرط مستوفيًا.VF
case 0xE:
this.v[0xF] = (this.v[x] & 0x80);
this.v[x] <<= 1;
break;
السطر الأول من الكود، ، يأخذ البت الأكثر أهمية (this.v[0xF] = (this.v[x] & 0x80);most significant bit) من ويخزنه في Vx. لشرح هذا، لدينا سجل 8 بتات، VF، ونريد الحصول على البت الأكثر أهمية، أو الأيسر. للقيام بذلك، نحتاج إلى إجراء عملية VxAND بين والقيمة الثنائية Vx، أو 10000000 في النظام السداسي عشري. سيحقق هذا تعيين 0x80 إلى القيمة الصحيحة. بعد ذلك، نقوم ببساطة بضرب VF في 2 عن طريق إزاحته إلى اليسار بمقدار 1.Vx
9xy0 - SNE Vx, Vy: تخطي إذا كان Vx لا يساوي Vy
تزيد هذه التعليمة ببساطة عداد البرنامج بمقدار 2 إذا كان و Vx غير متساويين.Vy
case 0x9000:
if (this.v[x] !== this.v[y]) {
this.pc += 2;
}
break;
Annn - LD I, addr: تعيين قيمة I
تعيين قيمة السجل إلى i. إذا كان رمز التشغيل هو nnn، فستُرجع 0xA740 القيمة (opcode & 0xFFF).0x740
case 0xA000:
this.i = (opcode & 0xFFF);
break;
Bnnn - JP V0, addr: القفز إلى عنوان مع V0
تعيين عداد البرنامج () إلى this.pc بالإضافة إلى قيمة السجل 0 (nnn).V0
case 0xB000:
this.pc = (opcode & 0xFFF) + this.v[0];
break;
Cxkk - RND Vx, byte: توليد رقم عشوائي
case 0xC000:
let rand = Math.floor(Math.random() * 0xFF);
this.v[x] = rand & (opcode & 0xFF);
break;
توليد رقم عشوائي في النطاق 0-255 ثم إجراء عملية AND له مع البايت الأدنى من رمز التشغيل. على سبيل المثال، إذا كان رمز التشغيل هو ، فستُرجع 0xB849 القيمة (opcode & 0xFF).0x49
Dxyn - DRW Vx, Vy, nibble: رسم الرسوميات
هذه تعليمة كبيرة! تتعامل هذه التعليمة مع رسم ومسح وحدات البكسل على الشاشة. سأقدم لك الكود بالكامل وأشرحه سطرًا بسطر.
case 0xD000:
let width = 8;
let height = (opcode & 0xF);
this.v[0xF] = 0;
for (let row = 0; row < height; row++) {
let sprite = this.memory[this.i + row];
for (let col = 0; col < width; col++) {
// If the bit (sprite) is not 0, render/erase the pixel
if ((sprite & 0x80) > 0) {
// If setPixel returns 1, which means a pixel was erased, set VF to 1
if (this.renderer.setPixel(this.v[x] + col, this.v[y] + row)) {
this.v[0xF] = 1;
}
}
// Shift the sprite left 1. This will move the next next col/bit of the sprite into the first position.
// Ex. 10010000 << 1 will become 0010000
sprite <<= 1;
}
}
break;
لدينا متغير تم تعيينه إلى 8 لأن كل رسومية (widthsprite) عرضها 8 بكسلات، لذا من الآمن ترميز هذه القيمة. بعد ذلك، نعيّن إلى قيمة الـ heightnibble الأخير () من رمز التشغيل. إذا كان رمز التشغيل لدينا هو n، فسيتم تعيين 0xD235 إلى 5. من هناك نعيّن height إلى 0، والذي إذا لزم الأمر، سيتم تعيينه إلى 1 لاحقًا إذا تم مسح وحدات البكسل. الآن ننتقل إلى حلقات VF. تذكر أن الرسومية تبدو شيئًا كهذا:for
11110000
10010000
10010000
10010000
11110000
الكود الخاص بنا يمر صفًا بصف (الحلقة الأولى)، ثم يمر بتًا بتًا أو عمودًا بعمود (الحلقة for الثانية) عبر تلك الرسومية. هذا الجزء من الكود، for، يأخذ 8 بتات من الذاكرة، أو صفًا واحدًا من الرسومية، المخزنة في let sprite = this.memory[this.i + row];. ينص المرجع التقني على أننا نبدأ من العنوان المخزن في this.i + row، أو I في حالتنا، عندما نقرأ الرسوميات من الذاكرة. ضمن حلقتنا this.i الثانية، لدينا عبارة for تأخذ البت الأيسر وتتحقق مما إذا كان أكبر من 0. تشير القيمة 0 إلى أن الرسومية لا تحتوي على بكسل في ذلك الموقع، لذلك لا داعي للقلق بشأن رسمه أو مسحه. إذا كانت القيمة 1، ننتقل إلى عبارة if أخرى تتحقق من القيمة المرجعة من if. دعنا ننظر إلى القيم التي تم تمريرها إلى تلك الدالة. يبدو استدعاء setPixel الخاص بنا كالتالي: setPixel. وفقًا للمرجع التقني، تقع مواضع this.renderer.setPixel(this.v[x] + col, this.v[y] + row) و x في y و Vx على التوالي. أضف رقم الـ Vy إلى col ورقم الـ Vx إلى row، وستحصل على الموضع المطلوب لرسم/مسح بكسل. إذا أعادت Vy القيمة 1، فإننا نمسح البكسل ونعيّن setPixel إلى 1. إذا أعادت 0، لا نفعل شيئًا، مع الحفاظ على قيمة VF مساوية لـ 0. أخيرًا، نقوم بإزاحة الرسومية إلى اليسار بمقدار 1 بت. يسمح لنا هذا بالمرور عبر كل بت من الرسومية. على سبيل المثال، إذا كانت VF مضبوطة حاليًا على sprite، فستصبح 10010000 بعد إزاحتها إلى اليسار. من هناك، يمكننا المرور عبر تكرار آخر من حلقتنا 0010000 الداخلية لتحديد ما إذا كنا سنرسم بكسل أم لا. ونستمر في هذه العملية حتى نصل إلى النهاية أو الرسومية الخاصة بنا.for
Ex9E - SKP Vx: تخطي إذا كان المفتاح في Vx مضغوطًا
هذه التعليمة بسيطة إلى حد ما وتتخطى التعليمة التالية إذا كان المفتاح المخزن في مضغوطًا، عن طريق زيادة عداد البرنامج بمقدار 2.Vx
case 0x9E:
if (this.keyboard.isKeyPressed(this.v[x])) {
this.pc += 2;
}
break;
ExA1 - SKNP Vx: تخطي إذا كان المفتاح في Vx غير مضغوط
هذه التعليمة تفعل عكس التعليمة السابقة. إذا لم يكن المفتاح المحدد مضغوطًا، فتخطى التعليمة التالية.
case 0xA1:
if (!this.keyboard.isKeyPressed(this.v[x])) {
this.pc += 2;
}
break;
Fx07 - LD Vx, DT: تعيين Vx من مؤقت التأخير
تعليمة بسيطة أخرى. نحن فقط نعيّن إلى القيمة المخزنة في Vx.delayTimer
case 0x07:
this.v[x] = this.delayTimer;
break;
Fx0A - LD Vx, K: انتظار ضغطة مفتاح
بالنظر إلى المرجع التقني، توقف هذه التعليمة المحاكي مؤقتًا حتى يتم الضغط على مفتاح. إليك الكود الخاص بها:
case 0x0A:
this.paused = true;
this.keyboard.onNextKeyPress = function(key) {
this.v[x] = key;
this.paused = false;
}.bind(this);
break;
نقوم أولاً بتعيين إلى paused لإيقاف المحاكي مؤقتًا. ثم، إذا تذكرت من ملف true الخاص بنا حيث قمنا بتعيين keyboard.js إلى onNextKeyPress، فهذا هو المكان الذي نقوم فيه بتهيئته. مع تهيئة دالة null، في المرة التالية التي يتم فيها تشغيل حدث onNextKeyPress، سيتم تشغيل الكود التالي في ملف keydown الخاص بنا:keyboard.js
// keyboard.js
if (this.onNextKeyPress !== null && key) {
this.onNextKeyPress(parseInt(key));
this.onNextKeyPress = null;
}
من هناك، نعيّن إلى رمز المفتاح المضغوط (Vxkeycode) وأخيرًا نعيد تشغيل المحاكي عن طريق تعيين إلى paused.false
Fx15 - LD DT, Vx: تعيين مؤقت التأخير من Vx
تقوم هذه التعليمة ببساطة بتعيين قيمة مؤقت التأخير إلى القيمة المخزنة في السجل .Vx
case 0x15:
this.delayTimer = this.v[x];
break;
Fx18 - LD ST, Vx: تعيين مؤقت الصوت من Vx
هذه التعليمة مشابهة جدًا لـ ولكنها تعين مؤقت الصوت إلى Fx15 بدلاً من مؤقت التأخير.Vx
case 0x18:
this.soundTimer = this.v[x];
break;
Fx1E - ADD I, Vx: إضافة Vx إلى I
إضافة إلى Vx.I
case 0x1E:
this.i += this.v[x];
break;
Fx29 - LD F, Vx: تعيين I إلى موقع الرسومية
لهذه التعليمة، نحن نعيّن إلى موقع الرسومية في I. يتم ضربها في 5 لأن كل رسومية يبلغ طولها 5 بايتات.Vx
case 0x29:
this.i = this.v[x] * 5;
break;
Fx33 - LD B, Vx: تخزين تمثيل BCD لـ Vx
ستأخذ هذه التعليمة المئات والعشرات والآحاد من السجل وتخزنها في السجلات Vx و I و I+1 على التوالي.I+2
case 0x33:
// Get the hundreds digit and place it in I.
this.memory[this.i] = parseInt(this.v[x] / 100);
// Get tens digit and place it in I+1. Gets a value between 0 and 99,
// then divides by 10 to give us a value between 0 and 9.
this.memory[this.i + 1] = parseInt((this.v[x] % 100) / 10);
// Get the value of the ones (last) digit and place it in I+2.
this.memory[this.i + 2] = parseInt(this.v[x] % 10);
break;
Fx55 - LD [I], Vx: تخزين السجلات في الذاكرة
في هذه التعليمة، نمر عبر السجلات من حتى V0 ونخزن قيمتها في الذاكرة بدءًا من Vx.I
case 0x55:
for (let registerIndex = 0; registerIndex <= x; registerIndex++) {
this.memory[this.i + registerIndex] = this.v[registerIndex];
}
break;
Fx65 - LD Vx, [I]: قراءة الذاكرة في السجلات
الآن ننتقل إلى التعليمة الأخيرة. هذه التعليمة تفعل عكس . تقرأ القيم من الذاكرة بدءًا من Fx55 وتخزنها في السجلات من I حتى V0.Vx
case 0x65:
for (let registerIndex = 0; registerIndex <= x; registerIndex++) {
this.v[registerIndex] = this.memory[this.i + registerIndex];
}
break;
اللمسات الأخيرة: دمج CPU في الملف الرئيسي (chip8.js)
مع إنشاء فئة وحدة المعالجة المركزية (CPU)، دعنا ننهي ملف الخاص بنا عن طريق تحميل chip8.jsROM ودورة وحدة المعالجة المركزية. سنحتاج إلى استيراد وتهيئة كائن cpu.jsCPU:
import Renderer from './renderer.js';
import Keyboard from './keyboard.js';
import Speaker from './speaker.js';
import CPU from './cpu.js'; // NEW
const renderer = new Renderer(10);
const keyboard = new Keyboard();
const speaker = new Speaker();
const cpu = new CPU(renderer, keyboard, speaker); // NEW
تصبح دالة الخاصة بنا:init
function init() {
fpsInterval = 1000 / fps;
then = Date.now();
startTime = then;
cpu.loadSpritesIntoMemory(); // NEW
cpu.loadRom('BLITZ'); // NEW
loop = requestAnimationFrame(step);
}
عند تهيئة محاكينا، سنقوم بتحميل الرسوميات (sprites) في الذاكرة وتحميل ROM باسم . الآن نحتاج فقط إلى تدوير وحدة المعالجة المركزية:BLITZ
function step() {
now = Date.now();
elapsed = now - then;
if (elapsed > fpsInterval) {
cpu.cycle(); // NEW
}
loop = requestAnimationFrame(step);
}
بانتهاء ذلك، يجب أن يكون لدينا الآن محاكي Chip-8 يعمل بكامل طاقته.
الخلاصة
لقد بدأت هذا المشروع منذ فترة وكنت مفتونًا به. لطالما كان إنشاء المحاكيات شيئًا يثير اهتمامي ولكنه لم يكن منطقيًا بالنسبة لي. كان ذلك حتى تعلمت عن Chip-8 وبساطته مقارنة بالأنظمة الأكثر تقدمًا. في اللحظة التي انتهيت فيها من هذا المحاكي، عرفت أنه يجب علي مشاركته مع الآخرين من خلال تقديم دليل مفصل خطوة بخطوة لإنشائه بنفسك. المعرفة التي اكتسبتها، والتي آمل أن تكون قد اكتسبتها أنت أيضًا، ستثبت بلا شك فائدتها في أماكن أخرى. بشكل عام، آمل أن تكون قد استمتعت بالمقال وتعلمت شيئًا. لقد هدفت إلى شرح كل شيء بالتفصيل وبأبسط طريقة ممكنة. بغض النظر، إذا كان أي شيء لا يزال يربكك أو كان لديك سؤال، فلا تتردد في إخباري على Twitter أو نشر مشكلة في مستودع GitHub حيث يسعدني مساعدتك.
أود أن أترك لك بعض الأفكار حول الميزات التي يمكنك إضافتها إلى محاكي Chip-8 الخاص بك:
- التحكم في الصوت (كتم، تغيير التردد، تغيير نوع الموجة (جيبية، مثلثية)، إلخ)
- القدرة على تغيير مقياس العرض وسرعة المحاكي من واجهة المستخدم
- إيقاف مؤقت واستئناف
- القدرة على حفظ وتحميل تحديد الـ
ROM
الخلاصة التقنية
يُعد بناء محاكي Chip-8 تجربة تعليمية لا تقدر بثمن في مجال هندسة الكمبيوتر والبرمجيات منخفضة المستوى. تكمن قيمة Chip-8 في بساطة بنيته، حيث يوفر مجموعة تعليمات محدودة وذاكرة صغيرة، مما يجعله مثاليًا للمطورين لفهم كيفية تفاعل المكونات الأساسية لوحدة المعالجة المركزية (CPU)، الذاكرة، الرسوميات، والمدخلات/المخرجات. إن عملية تحليل رموز التشغيل (opcodes) وتنفيذها خطوة بخطوة، بالإضافة إلى إدارة المؤقتات والتعامل مع العمليات على مستوى البت، توفر أساسًا متينًا لأي شخص مهتم بتطوير المحاكيات أو فهم أعمق لكيفية عمل الأنظمة الحاسوبية. استخدام JavaScript لهذه المهمة يبرز مرونة اللغة وقدرتها على التعامل مع مفاهيم منخفضة المستوى، مع توفير بيئة تطوير سريعة وفعالة.