← كل المقالات

المعمارية

NES في المتصفح: ليست 60 إطاراً، بل 60.0988

عتاد NES يُنتج 60.0988 إطاراً في الثانية؛ والمتصفح يرسم بإيقاع الشاشة نفسها. القرارات التي اتخذناها بين هاتين الساعتين ونحن نبني Kaset: إبقاء النواة قابلة للتبديل، ونقل الرسم خارج الخيط الرئيسي، والتعامل مع الإشارة المركّبة بوصفها الوسيط الذي صُمّمت له الألعاب.

أول رقم تقابلونه وأنتم تبنون مشغّل NES للمتصفح ليس رقماً صحيحاً. فالجهاز لا يُنتج 60 إطاراً في الثانية؛ إذ يعمل NES بنظام NTSC عند 60.0988 هرتز. أما الشاشة فترسم بإيقاعها هي.

وKaset مشغّل NES يعمل داخل المتصفح: تُفلتون ملف ‎.nes على النافذة، فتعمل اللعبة على جهازكم، ولا يذهب الملف إلى أي خادم. وتسجّل هذه الملاحظة القرارات التي اتخذناها في بنائه — إبقاء النواة قابلة للتبديل، ونقل الرسم خارج الخيط الرئيسي، والتعامل مع الفيديو المركّب لا بوصفه خياراً جمالياً بل بوصفه الوسيط الذي صُمّمت له الألعاب. والسلسلة كلها تبدأ من تلك الأرقام الأربعة بعد الفاصلة العشرية.

رقم واحد: 60.0988

والفجوة يسهل تجسيدها. فعند 0.0988 إطاراً في الثانية تبلغ نحو 356 إطاراً في الساعة، بنسبة نحو 0.165%. وبعبارة أخرى، كل عشر ثوانٍ تقريباً يُنتج NES إطاراً واحداً أكثر مما يستطيع عرض بتردد 60 هرتز إظهاره.

وذلك الفائض يجب تدوينه في مكان ما. فإسقاط الإطار يوقف الحركة لحظة؛ وتراكمه على جانب الصوت يُبعد الصورة عن الصوت. وكل قرار أدناه يدور حول السؤال نفسه: أين يُسجَّل الفرق.

من أين تأتي الساعة

لم يُختَر الرقم؛ بل يسقط من قسمة. ففي NTSC يجب أن تكون الساعة الرئيسية ستة أضعاف الحامل الفرعي للّون، وذلك الشرط يُنتج رقمين غريبين: فالساعة الرئيسية بحكم التعريف 236.25 ميغاهرتز مقسومة على أحد عشر، أي 21.477272 ميغاهرتز. وتنفق وحدة PPU أربعاً من تلك النبضات على كل نقطة وينفق المعالج اثنتي عشرة — ولهذا تقع ثلاث نقاط PPU بالضبط داخل دورة معالج واحدة في NTSC.

text
master   21.477272 MHz  (236.25 / 11)
dot      21.477272 / 4  = 5.369318 MHz
CPU      21.477272 / 12 = 1.789773 MHz
frame    341 dots x 262 lines = 89,342 ticks

وهندسة الإطار ليست خياراً تصميمياً هي الأخرى؛ بل تخرج من الإشارة مباشرة. فمن الأسطر الـ 262، 240 مرئية، وواحد سطر ما بعد الرسم، وعشرون إخماد رأسي، وواحد ما قبل الرسم. وفي PAL تمضي السلسلة نفسها من ساعة رئيسية بتردد 26.6017125 ميغاهرتز عبر 312 سطراً، وهناك تقع 3.2 نقطة PPU داخل دورة معالج.

نقطة أقصر في الإطارات الفردية

والتفصيل الذي يثبّت الأرقام الأخيرة يقع هنا. فمع تفعيل الرسم، يكون كل إطار PPU فردي أقصر من المعتاد بنبضة ساعة PPU واحدة؛ ويُنفَّذ القفز بالانتقال مباشرة من النقطة (339,261) إلى (0,0)، فتكون النقطة الضائعة هي (340,261). ومع تعطيل الرسم لا يوجد قفز البتة ويعمل كل إطار بكامل نبضاته الـ 89,342.

والحساب يتبع ذلك. فحين تتناوب الإطارات بين 89,342 و89,341 نبضة يكون المتوسط 89,341.5، وقسمة معدل النقاط عليه تعطي 60.09881 هرتز؛ وبلا القفزة كانت ستبلغ 60.09848. وتنشر NESdev قيمة واحدة في جدولها: 60.0988. وما يفصل تلك الأرقام الأخيرة نبضة ساعة واحدة تُسقَط كل إطار ثانٍ.

وعلى جانب Kaset يُقاس إيقاع الإطارات: إذ تُقيَّم قيمة effectiveFps على كائن frameTiming أمام 60، بعتبتين منفصلتين للانحراف. وانحراف الجهاز نفسه البالغ 0.0988 يقع دون كلتيهما بكثير — فالقياس يحمل انحراف العتاد من دون أن يعدّه ضجيجاً.

أي ساعة تضبط الإيقاع

يقدّم المتصفح مصدرين للوقت، وليس أي منهما ساعة الجهاز. فعلى جانب العرض، يشغّل requestAnimationFrame الاستدعاء قبل إعادة الرسم التالية؛ ومعدل الاستدعاء يطابق عادةً معدل تحديث الشاشة، وفي الألسنة الخلفية توقفه معظم المتصفحات. وعلى جانب الصوت تكون مواصفة Web Audio صريحة: الوقت المنقضي على currentTime يخصّ تيار الصوت وقد لا يكون متزامناً مع الساعات الأخرى في النظام.

وساعة الصوت تتغير كذلك من جهاز إلى آخر. وحيث لا يُعطى خيار، يكون معدل العينات ما يفضّله جهاز الإخراج — وهو عادة بين 8,000 و96,000 هرتز، وأشيعه 44,100. وتتباين baseLatency وoutputLatency بحسب المنصة، وlatencyHint طلب قد يمتنع المتصفح عن تلبيته.

وجانب المعالجة انتقل أيضاً. فـ AudioWorklet يشغّل كود المعالجة على خيط Web Audio منفصل، ويُستدعى process() مرة لكل كتلة صوت؛ والكتل حالياً بطول 128 إطاراً دائماً، وإن كان المقصود قراءة الحجم من جديد في كل مرة. وقد حلّ محل ScriptProcessorNode الذي كان يعمل على الخيط الرئيسي.

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

جعل النواة قابلة للتبديل

يقدّم Kaset عائلتَي نوى: TetaNES (الأصلية) وLibretro. ويسافر الاختيار في مفتاح kaset.core داخل localStorage وفي معامل ‎?core=، وتذكر الواجهة أن التغيير يسري على الخرطوشة التالية.

وملاحظة «الخرطوشة التالية» تلك ليست تنازلاً في سهولة الاستخدام؛ بل هي شكل العقد. فـ libretro واجهة خفيفة قائمة على C تعرض استدعاءات الصوت والصورة والإدخال بصيغة عامة، وإصدار واجهتها ما زال 1. فـ retro_load_game يحمّل المحتوى، وكل استدعاء لـ retro_run يُنتج إطار فيديو واحداً بالضبط، وتتعرّف الواجهة الأمامية على خصائص الصوت والصورة عبر retro_get_system_av_info. وتبديل النواة ليس قلب راية داخل عملية جارية؛ بل إقامة العقد من جديد من البداية.

والمسار الأصلي بلغة Rust. فـ TetaNES محاكي NES متعدد المنصات يعمل أيضاً داخل المتصفح عبر WebAssembly، موزّع على صندوقَي كود، مع tetanes-core مكتبةَ محاكاة مستقلة عن أي واجهة. وذلك الفصل هو ما يتيح لنا وضع طبقة الرسم الخاصة بنا فوقه. وهدف البناء هو wasm32-unknown-unknown، أنحف أهداف WebAssembly — إذ لا يستورد دوالاً من المضيف — ويقع في المستوى الثاني. وحتى 11 أغسطس 2026 يكون أحدث إصدار منشور من tetanes-core هو 0.15.0، بتاريخ 7 أغسطس 2026، مرخّصاً بـ MIT أو Apache-2.0.

ومسار libretro يمرّ عبر Nostalgist.js. والمكتبة لا تشحن محاكياً خاصاً بها؛ بل تقود نوى RetroArch المترجَمة بـ Emscripten، ونقطة دخولها الوحيدة هي launch({ core, rom }). ويأخذ خيار core إما اسماً معروفاً أو كائن { name, js, wasm }، وهنا تبقى القائمة مفتوحة. ومن هنا أيضاً يأتي وسم NESTOPIA CORE في الشريط العلوي: فـ Nestopia محاكي NES وFamicom دقيق على مستوى الدورة، ومنفذ libretro يبني على تفرّع Nestopia JG الأصلي، وهو مرخّص بـ GPLv2.

والمساران يقعان متباعدين في الحزمة، ويُحمَّلان كقطع خاصة بهما: nativeWorkerEngine وnativeEngine وnostalgistEngine وcrtParams وpacing. وقد ظهرت صورة من الفصل نفسه في ملاحظة «طبقة تقديم النماذج اللغوية المحلية: vLLM وSGLang وllama.cpp وOllama» — فهناك أيضاً كان اختيار بيئة تشغيل والحفاظ على قابلية نقل ذلك الاختيار عملين مختلفين.

الانتقال إلى عامل، وما يكلّفه العزل

ونقل الرسم خارج الخيط الرئيسي يصطدم باختبار قدرات عند الباب. ويشترط Kaset الشروط الخمسة كلها؛ فمع توافرها جميعاً يحمّل المحرك القائم على العامل، وإلا حمّل محرك الخيط الرئيسي.

js
Worker
OffscreenCanvas
HTMLCanvasElement.prototype.transferControlToOffscreen
self.crossOriginIsolated === true
new SharedArrayBuffer(4)

وتلك الخمسة سلسلتان في الحقيقة. فعلى سلسلة اللوحة، يسلّم transferControlToOffscreen التحكم في الرسم إلى كائن OffscreenCanvas؛ فيصير العنصر في الصفحة نائباً، ويثبت حجمه الأصلي، ولا يعود قادراً على الحصول على سياق رسم خاص به. والنقل أحادي الاتجاه ولمرة واحدة — واستدعاؤه على لوحة لها سياق بالفعل، أو نُقلت من قبل، يرفع InvalidStateError.

وسلسلة الذاكرة المشتركة تطلب أكثر. فـ SharedArrayBuffer يشترط أن تكون الوثيقة في سياق آمن ومعزولة عبر المصادر، ويُشغَّل العزل بترويستين من الخادم: Cross-Origin-Opener-Policy: same-origin وCross-Origin-Embedder-Policy: require-corp. وتُقرأ النتيجة في الكود بـ self.crossOriginIsolated. وفي المقابل، تستطيع كائنات SharedArrayBuffer السفر عبر postMessage ويقدّم Performance.now دقة أعلى.

والكلفة واضحة بالقدر نفسه. فـ COOP same-origin تعني أن الوثيقة لا تشارك مجموعة سياق التصفح إلا مع وثائق من المصدر نفسه؛ وتحت require-corp، يجب أن تكون الموارد المجلوبة بوضع no-cors إما من المصدر نفسه أو أن تمنح الإذن صراحةً عبر Cross-Origin-Resource-Policy. وإن كانت عادتكم إسقاط نصوص برمجية من أطراف ثالثة على الصفحة، فهذا الاختيار يطلب منكم الترتيب أولاً.

وثمة افتراض يستحق التصحيح: الانتقال إلى عامل لا يعني التخلي عن ساعة العرض. فـ requestAnimationFrame موجود داخل العمال المخصصين أيضاً (ضمن Baseline منذ مارس 2023)، شريطة أن يكون للعامل نافذة مالكة. أما Atomics.wait فلا يمكن استخدامه على الخيط الرئيسي ولا يعمل إلا على المصفوفات المسنودة بـ SharedArrayBuffer — فضبط الإيقاع بإيقاف خيط متاح على جانب العامل وحده.

المركّب ليس مظهراً، بل هو الوسيط

والقرار الحقيقي في طبقة الفيديو مفاهيمي لا تقني. فوحدة PPU في NES لا تُنتج RGB ثم تحوّله إلى مركّب؛ بل تبني فيديو NTSC مباشرة في المجال المركّب. والفيديو المركّب ليس مرشِّحاً يُضاف لاحقاً — بل هو الإشارة نفسها.

واللوحة اللونية تجسّد ذلك. فقيمة من ستة بتّات تُربَط بواحد من 64 مخرجاً، حيث يضبط البتّان الأعلى السطوع وتضبط الأربعة الأدنى الدرجة اللونية في معظمها. والدرجة هنا طور حامل فرعي: فالقيم من ‎$x1 إلى ‎$xC موجة مربعة تتأرجح بين مستويَي جهد. فاللون يسافر بوصفه توقيتاً، لا بوصفه رقماً.

وتترتب على ذلك نتيجة مباشرة: لا توجد لوحة لونية صحيحة واحدة. فعلى العتاد الحقيقي للّوحة أربعة مصادر تباين على الأقل — مطابقة الممانعة، وإعدادات المستخدم في التلفاز، وطريقة فكّه للمركّب إلى RGB، وفضاء الألوان الخاص بالجهاز. وبعبارة NESdev نفسها، لا توجد لوحة مركّبة واحدة تُرضي المظهر المقصود لكل لعبة في آن، ولم تصف Nintendo قط شاشة مرجعية لمطوّريها المرخّصين. ولهذا بالضبط نقدّم أربعة أنماط لا نمطاً واحداً.

وانخفاض دقة اللون دون دقة البكسل يأتي من الموضع نفسه. فدورة اللون تدوم اثنتي عشرة نبضة بينما عرض بكسل NTSC ثماني نبضات، فيُشارَك جزء من معلومة اللون مع البكسل المجاور. ولأن السطر الواحد يحمل ‎227⅓ دورة لون، تنزاح المحاذاة في كل سطر ويتكرر نمط كل ثلاثة أسطر — ويظهر تلألؤاً أثناء التمرير البطيء.

والأنماط الأربعة تستند إلى تلك الوقائع. فطريقة فكّ المركّب تتباين من جهاز إلى آخر، وبعض الأجهزة لا يرشّح البتة — ومن هناك يأتي نمط Living Room TV. وأنبوب Trinitron يستخدم مدفعاً إلكترونياً واحداً، وفوسفوراً مخطّطاً، وشبكة فتحات منتقياً للّون، والشبكة شرائط تتشكل من شقوق رأسية في صفيحة رقيقة. أما قناع الظل فصفيحة مثقّبة تظلّل ثلاثيات الفوسفور بدلاً من ذلك. والاثنان يتركان بنيتين مرئيتين مختلفتين: إحداهما ثلاثية نقاط، والأخرى خط رأسي متصل. ولهذا يحمل إعداد القناع في معاملاتنا حقل kind أصلاً.

ونمط RF يستند إلى عرض النطاق. فقناة التلفاز عرضها 6 ميغاهرتز إجمالاً، بينما تسافر قنوات فرق اللون بين بضع مئات من الكيلوهرتز و1.3 ميغاهرتز — فاللون يركب نطاقاً أضيق بكثير من الإضاءة. وخطوط المسح مرئية لسبب مختلف: فـ NES يُنتج 262 سطراً دائماً، فيرسم التلفاز الحقول بعضها فوق بعض ولا تتشكل صورة متشابكة.

والأرقام خلف الأنماط قيم اخترناها نحن؛ وهي لا تأتي من قياس معايرة. ففي طبقة اللون: تشبّع 1.25، وتباين 1.06، وغاما 1.05، ومضاعِف صبغة دافئ قليلاً؛ وفي طبقة خطوط المسح: عرض شعاع نحو 0.55؛ وفي طبقة القناع: نوع ظل بقوة 0.25 ومقياس 3. وطبقة CRT بتقنية WebGL مفعّلة على النواة الأصلية فقط.

المنطقة، وحالات الحفظ، وأين يبقى الملف

المنطقة ليست وسماً، بل آلة مختلفة. فإطار PAL هو 312 سطراً عند 50.0070 هرتز، بنسبة أبعاد بكسل 1.386:1 مقابل 1.143:1 في NTSC. ونمط أثر اللون يتغير كذلك: فسطر PAL يحمل ‎284⅙ دورة لون، فيتكرر النمط كل ستة أسطر لا كل ثلاثة.

وثمة هجين بينهما. فـ Dendy نسخة مستنسخة من Famicom تستخدم إشارة PAL بينما يعمل معالجها بسرعة معالج NTSC، فتجمع هندسة إطار PAL مع نسبة المعالج إلى PPU في NTSC. وتبلغ دورات المعالج لكل إطار ثلاثة أرقام مختلفة عبر الأنظمة الثلاثة: ‎29,780⅔ في NTSC، و33,247.5 في PAL، و35,464 في Dendy. ويقدّم Kaset في الواجهة خيارات تلقائي وNTSC وPAL، وفي الكود يُربَط PAL وDendy بالجانب نفسه بينما يُربَط ما عداهما بـ NTSC.

وعلى جانب حالات الحفظ، ترتبط الحالة بملف اللعبة: إذ يُخزَّن مخرَج saveState مع romHash، وخانة، واسم الملف، ووقت الإنشاء، وتعمل العملية داخل غلاف مهلة من خمس ثوانٍ. وربط الحفظ بتجزئة المحتوى بدل اسم الملف مسألة هوية — كي لا تحطّ نسخ مختلفة من اللعبة نفسها في خانة بعضها بعضاً.

وما يبقى في النهاية يعود إلى الجملة الأولى. فالملف الذي تُفلتونه في Kaset لا يغادر جهازكم؛ واللعبة تعمل على آلتكم. وكل قرار في هذه الملاحظة صورة أخرى للسؤال نفسه: أن تعرفوا بالضبط أين يجري العمل.

المصادر

  • NESdev Wiki — Clock rate, Cycle reference chart (master clock, CPU and PPU divisors, cycles per frame)
  • NESdev Wiki — PPU frame timing, PPU rendering (the dot skipped on odd frames and where the skip happens)
  • NESdev Wiki — NTSC video, PAL video (colour generator, colour cycle width, chroma cycles per line, 240p)
  • NESdev Wiki — PPU palettes (the six-bit palette value, hue as subcarrier phase, sources of palette variation)
  • NESdev Wiki — Detect TV system, iNES, NES 2.0 (Dendy, CPU cycles per frame, region fields in the header)
  • MDN Web Docs — SharedArrayBuffer, Window and WorkerGlobalScope: crossOriginIsolated
  • MDN Web Docs — Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers
  • MDN Web Docs — OffscreenCanvas, HTMLCanvasElement: transferControlToOffscreen(), Atomics.wait()
  • MDN Web Docs — Window and DedicatedWorkerGlobalScope: requestAnimationFrame()
  • MDN Web Docs — AudioWorklet, AudioWorkletProcessor: process(), AudioContext baseLatency and outputLatency, BaseAudioContext: sampleRate
  • WHATWG HTML Standard — The canvas element (placeholder canvas behaviour)
  • W3C Web Audio API — BaseAudioContext.currentTime (the audio stream's own time)
  • lukexor/tetanes — repository and README; crates.io — tetanes-core release list
  • Nostalgist.js documentation — Under the hood and launch
  • libretro documentation — Developing Cores and the Nestopia UE core; libretro-common — libretro.h
  • Rust compiler book — platform support: wasm32-unknown-unknown
  • Sony US5382871A and US6111349A (aperture grille), Zenith EP0239083A2 (shadow mask)
  • 47 CFR 73.682 — TV transmission standards (channel width and the colour-difference band)

مقالات أخرى

لديكم نظام تبنونه، أو نظام قائم تطوّرونه؟

No deck needed. 20 minutes. The rest is up to you.