איך עשו את זה: סנייק ב-60 בייטים

לפני כמה ימים קיבלתי מייל עם קישור לפנינה קטנה, תרתי משמע: משחק "סנייק" שנכתב באסמבלי (x86), ושגודלו – בינארי, לא כטקסט קריא – שישים בייטים בלבד. בואו ננסה להבין איך זה יכול להיות.

הסנייק הכי קטן בעולם - צילום מסך
הסנייק הכי קטן בעולם – צילום מסך

הסנייק המינימליסטי הוא יצירה של מתכנת בשם אלישע הולנדר, donno2048 ב-Github . הנה קוד המקור, וגם דף שמריץ את הקוד בדפדפן כדי שנוכל לשחק בו. אל תצפו ליותר מדי מבחינת חוויית משחק, אבל כמובן שזו ממש לא הפואנטה. ה"נחש" מגיב למקשי החצים, מתארך כשהוא תופס את המטרות שמופיעות במיקומים אקראיים, והמשחק נגמר – כלומר, מתחיל מהתחלה – כשהוא מתנגש בעצמו או בשולי המסך. איך כל זה נכנס בשישים בייטים, שהם פחות ממספר התווים במשפט הראשון בפיסקה זו?

כמה שפחות מהכול

אפילו בנוף של תכנות אקסטרימי, הסנייק הזעיר הוא חריג. חוץ מהחתירה ל"כמה שפחות קוד בשביל לממש X", הוא מציע אינטרפרטציה מינימליסטית למשחק עצמו, וגם הפלטפורמה שעליה הוא רץ מינימליסטית. שישים הבייטים אינם קובץ exe שתוכלו להריץ על Windows 11 או משהו כזה – הם מיועדים לפעול בסביבת DOS "של פעם", ולמעשה, כפי שנראה מיד, הבחירה הזו אחראית לרוב הטריקים שמאפשרים את ההישג המרשים.

דיסקליימר: אני לא מומחה לאסמבלי, בטח שלא ל-x86 תחת DOS, ולמרות ההערות המושקעות לצד קוד המקור אני לא יכול לומר שהבנתי לגמרי מה קורה שם. הניתוח מכאן והלאה מבוסס על ההבנה החלקית שלי וייתכן שהוא לא מדויק. אם למישהו יש תיקונים או תוספות, אשמח לשמוע אותם בתגובות או במייל!

תוספת מאוחרת: כן, היו כמה אי-דיוקים וחוסרים – אני ממליץ בחום לקרוא את תגובתו של אלישע למטה!

התצוגה השימושית

הפרמטרים של תצוגות מחשב – כל תצוגה, מימי הטרמינלים הטקסטואליים ועד למסכי 8K – הם במובנים רבים עניין של מוסכמה שרירותית. האורך והרוחב (בתווים או בפיקסלים) אינם זהים זה לזה, והם אפילו לא מספרים "עגולים" בייצוג בינארי. לכן, בתכנות רגיל, אפילו מטלה בסיסית כמו לבדוק אם הקואורדינטות של ראש הנחש חורגות מהמסך תתפוס הרבה בייטים.

הסנייק של donno2048 מבוסס על שיטת תצוגה שתעלה חיוך נוסטלגי על פני כל מי שעסק בתכנות desktop בשנות השמונים המאוחרות או התשעים המוקדמות: "מצב טקסט", וספציפית מצב 40×25 שהיה קיים בכרטיסי מסך CGA ו-EGA. זוהי תצוגה של תווי ASCII בלבד, כאשר המספרים מציינים את הרוחב והגובה שלה ביחידות של תווים. הפיצ'ר הנהדר באמת הוא שכל זיכרון התצוגה יושב בבלוק אחד רציף, שהכתובת שלו קבועה ושקוד יכול לגשת אליו ישירות, בלי תיווך של מערכת הפעלה. כלומר, אם אני כותב למשל את הערך 65 לבייט שנמצא בכתובת 0xB800, התו "A" יוצג בפינה השמאלית העליונה של המסך. פשוט ככה. קצת פחות פשוט הוא הבייט הבא אחריו, בכתובת 0xB801. הוא לא מייצג את התו הבא על המסך (שנמצא למעשה כתובת אחת הלאה, ב-0xB802), אלא את הצבעים והתכונות של התו הראשון: צבע פונט וצבע רקע (מתוך לוח צבעים מצומצם מאוד), קו תחתי, הבהוב וכאלה.

אם כל תו על המסך, ביחד עם תכונותיו, תופס שני בייטים בזיכרון התצוגה, אז תצוגת 40×25 שלמה תופסת רצף של 2000 בייטים. שורת התווים הראשונה תופסת את 80 הבייטים הראשונים, והבייט ה-81 יהיה התו הראשון בשורה השנייה בתצוגה. מזה נובעים רוב קיצורי הדרך הבאים:

קיצורי דרך

איך מגרילים, במינימום פקודות, מיקום אקראי עבור המטרות שהנחש צריך לאכול? במונחי התצוגה שהראיתי למעלה, השאלה הזו הופכת ל"איך מפיקים מספר אקראי בין 0 ל-2000 שמתחלק ב-2 ללא שארית?" וזה מתבצע בקוד בשני שלבים. הראשון הוא לקרוא ערך (16-ביט) ששוכן בכתובת 0x40. כמו התצוגה, גם פה מדובר בכתובת קבועה של משהו חשוב – במקרה זה, טיימר מערכת שמשתנה ומתעדכן "מעצמו" בתדירות גבוהה. לאחר מכן נעשה לערך שהתקבל AND עם 2000 (שזה 0x7D0 בהקסדצימלי, ובבינארי 11111010000). הפעולה מקטינה מאוד את מרחב האפשרויות, אבל מבטיחה שהתוצאה תהיה גם בגבולות התצוגה, וגם בכתובת של תו, לא של תכונות-תו. כאילו שמישהו ישים לב שהמטרות אף פעם לא מופיעות במיקום ספציפי כזה או אחר!

[עריכה: בקריאה חוזרת, אני רואה כאן בעיה. אם הערך המוגרל הוא בדיוק 2000 זה ייחשב חוקי, אך למעשה מחוץ לתצוגה, כי מתחילים לספור מאפס. אולי יש בכתובת ההיא ערך שנחשב "תפוס", מה שיגרור הגרלה-מחדש של המיקום]

קל לגלות אם ראש הנחש חורג מגבולות התצוגה למעלה או למטה: אם הכתובת שלו גדולה/שווה ל-2000 או קטנה מ-0, הוא בחוץ. את הבדיקה הזו אפשר לבצע בפעולת השוואה אחת ויחידה באסמבלי, אם ניזכר שבמשתנה 16-ביט ללא סימן, מה שיורד "מתחת לאפס" מתגלגל לערך חיובי גבוה. מה לגבי הגבולות מימין ומשמאל?

אפשרות אחת היא לצייר מראש מסגרת בשולי המסך, ובכל פעם שמעדכנים את כתובת הנחש, לבדוק אם הכתובת החדשה כבר תפוסה. אבל אנחנו יודעים שזיכרון התצוגה רציף, אז אפשר לחסוך קוד ולצייר את המסגרת רק בצד אחד, נניח משמאל. אם כתובת הנחש תחרוג מצד ימין, היא הרי תתגלגל להתחלה של שורת התווים הבאה ותיתקל במסגרת שם. תו המסגרת שמצויר בסנייק הזה הוא בלתי נראה (תו ללא תוכן גרפי, חישבו למשל על תו הרווח), ואם תשחקו קצת באמולטור שבקישור למעלה תראו שהנחש אכן יכול להגיע ממש עד שולי התצוגה מימין, אבל מתנגש ומתאפס תו אחד לפני קצה התצוגה משמאל.

נעבור לשליטה של השחקן בתנועת הנחש. כדי לזוז בתצוגה ימינה או שמאלה מוסיפים או מפחיתים 2 מהכתובת, ובשביל תנועה אנכית אפשר להוסיף/להפחית 80. עם זאת, ארבע בדיקות בשביל זיהוי המקשים וקביעת הכיוון זה בזבזני. מה עושים?

הקוד קורא, קודם כל, את הקלט מהמקלדת – שוב, מדובר בבייט שיושב בכתובת קבועה (0x60), והוא מייצג את ה-scan code הגולמי של המקש האחרון שנלחץ. הקודים המעניינים הם של מקשי החצים: 72 (למעלה), 75 (שמאלה), 77 (ימינה) ו-80 (למטה). פעם ידעתי את זה בעל-פה. ועכשיו מגיע הקטע הביזארי: אם מבצעים על הבייט שהתקבל את הפעולות הבאות:

פעולות ומספרי קסם להמרת קודי מקשים לערכי תזוזה
פעולות ומספרי קסם להמרת קודי מקשים לערכי תזוזה (לחצו לתמונה גדולה)

מתקבל מספר שערכו מינוס שמונים, מינוס שתיים, פלוס שתיים או פלוס שמונים – בדיוק המספרים הדרושים כדי לנוע על התצוגה בכיוון של החץ. וזה עובד גם ל-scan codes של עזיבת המקשים, שהם כמו הקודמים פרט לביט העליון, שהוא "1". החיסרון הוא שאם לוחצים בטעות על מקש שאינו אחד מהחצים עלולות להיווצר "תנועות" חסרות כל היגיון – זה אחד המחירים שצריך לשלם כשכותבים משחק ב-60 בייטים.

איך עובד החישוב? בפקודות שבשורות 21-23, הקלט הגולמי מוכפל בעשר, מחולק לספרות (בסגנון ייצוג BCD רק בשני בייטים נפרדים של הרגיסטר ולפי בסיס 20), ומחושב מיד בחזרה מהספרות הנפרדות כאילו היו בבסיס 68. כשמסתכלים על הבייט הנמוך של התוצאה כ-signed, מקבלים את המספרים שהזכרתי. אם אתם לא מאמינים לי, פתחו גיליון אקסל וחשבו את הערכים לפי התיעוד של aam ושל aad, כפי שאני עשיתי. זה עובד. השאלה המעניינת באמת, שלא ראיתי תשובה לה בהערות לקוד או ב-README, היא איך לכל הרוחות הגיעו לחישוב הזה, שלעניות דעתי הוא מוזר ומסתורי לא פחות ממספר הקסם המפורסם מהמשחק Quake III.

הערות לסיום

שישים הבייטים של המשחק טומנים בחובם מגוון אופטימיזציות וסוגי אופטימיזציה, שרובם בעצם ניצול חכם ומקורי של האלמנטים המשתתפים: החל משפת התכנות, דרך הגדרות DOS ועד לחוקי המשחק עצמו. סקרתי כאן את האלמנטים שלדעתי הם המעניינים ביותר, אבל יש עוד, ויש גם כמה דברים שלא הבנתי (כגון: מה מונע מהמשחק לרוץ במהירות על-אנושית – אולי שום דבר פרט להגדרות אמולטור?)

בעולם האמיתי יש מעט מאוד הזדמנויות, ועוד פחות הצדקות, לתכנות קיצוני עד כדי כך. אלא אם אתם עובדים בפיתוח וירוסים עבור מוסד כזה או אחר למודיעין ותפקידים מיוחדים, יש תחומים בתכנות שכדאי הרבה יותר ללמוד ולחקור… אך כמו כל יצירת אומנות טובה, הסנייק הזה לא קיים כדי להיות שימושי!

להרשמה
הודע לי על
4 Comments
מהכי חדשה
מהכי ישנה לפי הצבעות
Inline Feedbacks
הראה את כל התגובות

הי, אני אלישע (donno2048), חייב להגיד שהכתיבה הטכנית שלך מעולה, אני נהנתי בעצמי לקרוא את הבלוג למרות שאני זה שכתב את הקוד. חוץ מזה נראה שהבנת מאוד טוב מה הולך בקוד אפילו שאתה לכאורה "לא מומחה לאסמבלי, בטח שלא ל-x86", יצא לי לדבר עם אנשים שלמרות ההערות הבינו הרבה פחות טוב מה קורה שם למרות שהיה להם ניסיון בx86. מה שכן, קצת הפריע לי שכתבת יותר על המבנה הלוגי של הקוד שהוא לדעתי פחות מעניין, אני מתגאה יותר בכל מיני אופטימיזציות שנעשו כדי לחסוך בייטים דווקא ברמה הטכנית (ולא הלוגית), לדוגמא השימוש בLDS כדי לאתחל את את SI, DS, AX… לקרוא עוד »

עריכה אחרונה 2 חודשים מכתיבת התגובה על ידי donno2048

חחחחחחח, מחמיא לי אבל כרגע אני לא עושה שום דבר חשוב, או אפילו מוצא עבודה שמוכנה להעסיק ילד בן 19 למשרה חלקית או לפי חוזה 😅 הבנתי לגבי האופטימיזציות, תכלס הגיוני שלא יעניין הרבה אנשים הפרטים הטכניים הלא ישימים… למרות שגם השימוש ב"באפר המעגלי" המזויף/FIFO שני פוינטרים יכול להיות שימושי למקרים מסויימים (אם כי ספציפיים), וגם ראיתי שכתבת על באפרים כאלה באחד הפוסטים בקטגורית אופטימיזציה ("ניהול באפר במינימום זיכרון"). אני לא חושב שיש דרך טובה למצוא את "מספרי הקסם" שיעבדו בשביל המרות דומות, ל20 ו68 אין תכונות מיוחדות כלשהן והם פשוט במקרה המספרים שעובדים למקרה הזה, תחליף למשל את 68… לקרוא עוד »

עריכה אחרונה 2 חודשים מכתיבת התגובה על ידי donno2048

מטורף… באמת יצירת אומנות..

תודה על ההסברים!