بناء محاكي Chip-8 الخاص بك: دليل شامل خطوة بخطوة

دقائق القراءة: 28

مقدمة إلى عالم المحاكاة وبناء محاكي 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.css، ثم ننتقل إلى مكونات العرض (renderer)، لوحة المفاتيح (keyboard)، مكبر الصوت (speaker)، وأخيرًا وحدة المعالجة المركزية الفعلية (CPU). سيبدو هيكل مشروعنا كالتالي:

- roms
- scripts
  chip8.js
  cpu.js
  keyboard.js
  renderer.js
  speaker.js
index.html
style.css

ملفات البدء: Index و Styles

لا يوجد شيء معقد في هذين الملفين؛ إنهما أساسيان للغاية. يقوم ملف index.html ببساطة بتحميل الأنماط (styles)، وإنشاء عنصر 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) لدينا التعامل مع كل ما يتعلق بالرسوميات. سيقوم بتهيئة عنصر الـ canvas الخاص بنا، وتبديل وحدات البكسل (pixels) داخل شاشتنا، وعرض تلك البكسلات على الـ 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)، والحصول على عنصر الـ canvas، والحصول على سياق الرسم (context)، وتعيين عرض وارتفاع الـ 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. سنستخدم scale مرة أخرى عندما نبدأ في عرض وحدات البكسل على الشاشة. العنصر الأخير الذي نحتاج إلى إضافته إلى الدالة البانية هو مصفوفة ستعمل كشاشتنا. نظرًا لأن شاشة Chip-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;

كل ما يفعله هذا السطر هو تبديل القيمة في pixelLoc (من 0 إلى 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 مسؤولة عن عرض وحدات البكسل في مصفوفة display على الشاشة. لهذا المشروع، ستعمل 60 مرة في الثانية.

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 C
  • 4 5 6 D
  • 7 8 9 E
  • A 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)، ومتغير بقيمة null (سنتحدث عنه لاحقًا)، واثنين من مستمعي الأحداث (event listeners) للتعامل مع إدخال لوحة المفاتيح.

الدالة isKeyPressed(keyCode): التحقق من ضغط المفتاح

نحتاج إلى طريقة للتحقق مما إذا كان مفتاح معين مضغوطًا. ستقوم هذه الدالة ببساطة بفحص مصفوفة keysPressed بحثًا عن keyCode المحدد لـ Chip-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 إذا تم تهيئته وتم الضغط على مفتاح صالح. دعنا نتحدث عن عبارة if تلك. إحدى تعليمات Chip-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;

كل ما نفعله هنا هو إنشاء AudioContext وربط كسب (gain) به حتى نتمكن من التحكم في مستوى الصوت. لن أضيف التحكم في مستوى الصوت في هذا البرنامج التعليمي، ولكن إذا كنت ترغب في إضافته بنفسك، فما عليك سوى استخدام ما يلي:

// 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 لتنزيلها ووضعها في مجلد roms الخاص بمشروعك. توفر JavaScript طريقة لإجراء طلب 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 في ملف chip8.js، والتي إذا تذكرت، يتم تنفيذها حوالي 60 مرة في الثانية. سنتناول هذه الدالة جزءًا تلو الآخر. في هذه المرحلة، لم يتم إنشاء الدوال التي يتم استدعاؤها داخل 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 و this.pc + 1 في سطر الكود أعلاه. نحن ببساطة نأخذ نصفي رمز التشغيل. ولكن لا يمكنك مجرد دمج قيمتين بحجم 1 بايت للحصول على قيمة بحجم 2 بايت. للقيام بذلك بشكل صحيح، نحتاج إلى إزاحة الجزء الأول من الذاكرة، this.memory[this.pc]، 8 بتات إلى اليسار لجعله بحجم 2 بايت. بأبسط العبارات، سيضيف هذا صفرين، أو بشكل أكثر دقة القيمة السداسية عشرية 0x00 إلى الجانب الأيمن من قيمتنا ذات البايت الواحد، مما يجعلها 2 بايت. على سبيل المثال، إزاحة القيمة السداسية عشرية 0x11 8 بتات إلى اليسار ستعطينا القيمة السداسية عشرية 0x1100. من هناك، نقوم بعملية OR على مستوى البت (|) مع الجزء الثاني من الذاكرة، this.memory[this.pc + 1].

إليك مثال خطوة بخطوة سيساعدك على فهم ما يعنيه كل هذا بشكل أفضل. لنفترض بعض القيم، كل منها بحجم 1 بايت:

  • this.memory[this.pc] = PC = 0x10
  • this.memory[this.pc + 1] = PC + 1 = 0xF0

إزاحة PC 8 بتات (1 بايت) إلى اليسار لجعله 2 بايت:

  • PC = 0x1000

عملية OR على مستوى البت لـ PC و PC + 1:

  • PC | PC + 1 = 0x10F0 أو 0x1000 | 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 من فئة Speaker التي أنشأناها سابقًا لتشغيل صوت بتردد 440.

playSound() {
    if (this.soundTimer > 0) {
        this.speaker.play(440);
    } else {
        this.speaker.stop();
    }
}

الدالة executeInstruction(opcode): تنفيذ التعليمات

لهذه الدالة بأكملها، سنرجع إلى القسمين 3.0 و 3.1 من المرجع التقني. هذه هي الدالة الأخيرة التي نحتاجها لهذا الملف، وهي طويلة. يجب علينا كتابة المنطق لجميع تعليمات Chip-8 البالغ عددها 36. لحسن الحظ، تتطلب معظم هذه التعليمات بضعة أسطر فقط من الكود. أول معلومة يجب أن تكون على دراية بها هي أن جميع التعليمات يبلغ طولها 2 بايت. لذلك في كل مرة ننفذ فيها تعليمة، أو نشغل هذه الدالة، يجب علينا زيادة عداد البرنامج (this.pc) بمقدار 2 حتى تعرف وحدة المعالجة المركزية مكان التعليمة التالية.

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 و y لأنها تستخدمها جميع التعليمات تقريبًا. المتغيرات الأخرى المذكورة أعلاه لا تستخدم بما يكفي لتبرير حساب قيمها في كل مرة. هاتان القيمتان كل منهما بحجم 4 بت (أي نصف بايت أو nibble). تقع قيمة x في الأربع بتات الدنيا من البايت الأعلى (high byte) وتقع y في الأربع بتات العليا من البايت الأدنى (low byte). على سبيل المثال، إذا كان لدينا تعليمة 0x5460، فسيكون البايت الأعلى هو 0x54 والبايت الأدنى هو 0x60. الأربع بتات الدنيا، أو الـ nibble، من البايت الأعلى ستكون 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، فسنحصل على 0x0400. قم بإزاحة ذلك 8 بتات إلى اليمين وسنحصل على 0x04 أو 0x4. نفس الشيء مع y. نقوم بعملية & للتعليمة مع القيمة السداسية عشرية 0x00F0 ونحصل على 0x0060. قم بإزاحة ذلك 4 بتات إلى اليمين وسنحصل على 0x006 أو 0x6. الآن نأتي إلى الجزء الممتع، كتابة المنطق لجميع التعليمات الـ 36. لكل تعليمة، قبل كتابة الكود، أوصي بشدة بقراءة ما تفعله تلك التعليمة في المرجع التقني حيث ستفهمها بشكل أفضل بكثير. سأقدم لك عبارة 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 يشير إلى سجل، والقيمة التي تليه، في هذه الحالة x، هي رقم السجل. إذا كانت متساوية، نزيد عداد البرنامج بمقدار 2، متجاوزين التعليمة التالية بشكل فعال.

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]، أو VF، إلى 0. نفعل ذلك لتجنب الاضطرار إلى استخدام عبارة if-else في السطر التالي. إذا كان المجموع أكبر من 255، أو القيمة السداسية عشرية 0xFF، نعيّن VF إلى 1. أخيرًا، نعيّن this.v[x]، أو Vx، إلى المجموع. قد تتساءل كيف نضمن "الاحتفاظ بالبتات الثمانية الأقل أهمية فقط من النتيجة، وتخزينها في Vx". بفضل كون this.v مصفوفة من النوع Uint8Array، فإن أي قيمة تزيد عن 8 بتات تأخذ تلقائيًا البتات الثمانية الأقل أهمية (اليمنى) وتخزنها في المصفوفة. لذلك لا نحتاج إلى فعل أي شيء خاص بها. دعني أقدم لك مثالاً لتوضيح هذا بشكل أفضل. لنفترض أننا نحاول وضع القيمة العشرية 257 في مصفوفة this.v. في النظام الثنائي، هذه القيمة هي 100000001، وهي قيمة 9 بتات. عندما نحاول تخزين هذه القيمة ذات الـ 9 بتات في المصفوفة، ستأخذ فقط البتات الثمانية الأقل أهمية. هذا يعني أن القيمة الثنائية 00000001، وهي 1 في النظام العشري، ستُخزن في this.v.

8xy5 - SUB Vx, Vy: طرح Vy من Vx مع التعامل مع الاستعارة

تطرح هذه التعليمة Vy من Vx. تمامًا كما يتم التعامل مع تجاوز السعة (overflow) في التعليمة السابقة، يجب علينا التعامل مع نقص السعة (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، فسيتم تعيين VF إلى 1 لأن البت الأقل أهمية هو 1. إذا كانت Vx هي 1000، فسيتم تعيين VF إلى 0.

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 أكبر من Vx، نحتاج إلى تخزين 1 في VF، وإلا نخزن 0.

8xyE - SHL Vx {, Vy}: إزاحة Vx إلى اليسار

لا تقوم هذه التعليمة بإزاحة Vx إلى اليسار بمقدار 1 فحسب، بل تقوم أيضًا بتعيين VF إلى 0 أو 1 اعتمادًا على ما إذا كان الشرط مستوفيًا.

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 ويخزنه في VF. لشرح هذا، لدينا سجل 8 بتات، Vx، ونريد الحصول على البت الأكثر أهمية، أو الأيسر. للقيام بذلك، نحتاج إلى إجراء عملية AND بين Vx والقيمة الثنائية 10000000، أو 0x80 في النظام السداسي عشري. سيحقق هذا تعيين VF إلى القيمة الصحيحة. بعد ذلك، نقوم ببساطة بضرب Vx في 2 عن طريق إزاحته إلى اليسار بمقدار 1.

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) إلى nnn بالإضافة إلى قيمة السجل 0 (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;

لدينا متغير width تم تعيينه إلى 8 لأن كل رسومية (sprite) عرضها 8 بكسلات، لذا من الآمن ترميز هذه القيمة. بعد ذلك، نعيّن height إلى قيمة الـ nibble الأخير (n) من رمز التشغيل. إذا كان رمز التشغيل لدينا هو 0xD235، فسيتم تعيين height إلى 5. من هناك نعيّن VF إلى 0، والذي إذا لزم الأمر، سيتم تعيينه إلى 1 لاحقًا إذا تم مسح وحدات البكسل. الآن ننتقل إلى حلقات for. تذكر أن الرسومية تبدو شيئًا كهذا:

 11110000
 10010000
 10010000
 10010000
 11110000

الكود الخاص بنا يمر صفًا بصف (الحلقة for الأولى)، ثم يمر بتًا بتًا أو عمودًا بعمود (الحلقة for الثانية) عبر تلك الرسومية. هذا الجزء من الكود، let sprite = this.memory[this.i + row];، يأخذ 8 بتات من الذاكرة، أو صفًا واحدًا من الرسومية، المخزنة في this.i + row. ينص المرجع التقني على أننا نبدأ من العنوان المخزن في I، أو this.i في حالتنا، عندما نقرأ الرسوميات من الذاكرة. ضمن حلقتنا for الثانية، لدينا عبارة if تأخذ البت الأيسر وتتحقق مما إذا كان أكبر من 0. تشير القيمة 0 إلى أن الرسومية لا تحتوي على بكسل في ذلك الموقع، لذلك لا داعي للقلق بشأن رسمه أو مسحه. إذا كانت القيمة 1، ننتقل إلى عبارة if أخرى تتحقق من القيمة المرجعة من setPixel. دعنا ننظر إلى القيم التي تم تمريرها إلى تلك الدالة. يبدو استدعاء setPixel الخاص بنا كالتالي: this.renderer.setPixel(this.v[x] + col, this.v[y] + row). وفقًا للمرجع التقني، تقع مواضع x و y في Vx و Vy على التوالي. أضف رقم الـ col إلى Vx ورقم الـ row إلى Vy، وستحصل على الموضع المطلوب لرسم/مسح بكسل. إذا أعادت setPixel القيمة 1، فإننا نمسح البكسل ونعيّن VF إلى 1. إذا أعادت 0، لا نفعل شيئًا، مع الحفاظ على قيمة VF مساوية لـ 0. أخيرًا، نقوم بإزاحة الرسومية إلى اليسار بمقدار 1 بت. يسمح لنا هذا بالمرور عبر كل بت من الرسومية. على سبيل المثال، إذا كانت sprite مضبوطة حاليًا على 10010000، فستصبح 0010000 بعد إزاحتها إلى اليسار. من هناك، يمكننا المرور عبر تكرار آخر من حلقتنا for الداخلية لتحديد ما إذا كنا سنرسم بكسل أم لا. ونستمر في هذه العملية حتى نصل إلى النهاية أو الرسومية الخاصة بنا.

Ex9E - SKP Vx: تخطي إذا كان المفتاح في Vx مضغوطًا

هذه التعليمة بسيطة إلى حد ما وتتخطى التعليمة التالية إذا كان المفتاح المخزن في Vx مضغوطًا، عن طريق زيادة عداد البرنامج بمقدار 2.

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;
}

من هناك، نعيّن Vx إلى رمز المفتاح المضغوط (keycode) وأخيرًا نعيد تشغيل المحاكي عن طريق تعيين 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 إلى موقع الرسومية في Vx. يتم ضربها في 5 لأن كل رسومية يبلغ طولها 5 بايتات.

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.js الخاص بنا عن طريق تحميل ROM ودورة وحدة المعالجة المركزية. سنحتاج إلى استيراد cpu.js وتهيئة كائن CPU:

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 لهذه المهمة يبرز مرونة اللغة وقدرتها على التعامل مع مفاهيم منخفضة المستوى، مع توفير بيئة تطوير سريعة وفعالة.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *