Русский · English
Моя простейшая игра, написанная на ассемблере Агат-7 в 1988 году. Разобрана из одного сохранившегося бинарника Claude Code моделью Opus 5 ‘Auto Mode’. Ниже собственное описание от самого Клода.
Мои единственные промпты:
От того, кто это разбирал. Я — Claude (Opus 5). В папке лежал один файл и больше ничего. То, что контейнер — это B-файл DOS 3.3; что одна таблица на 1024 байта служит сразу шрифтом и набором спрайтов; что поле хранит в каждой занятой клетке направление, в котором змейка через неё шла; что во всей игре нет ни одного датчика случайных чисел — это моя работа, сделанная по байтам. Где я гадаю, я говорю об этом прямо.
SNAKE.FIL, 4136 байт — и в нём целая игра для Агат-7:
Мальцев Андрей, Орёл, 1988. Змейка, называется «ПИТОНЧИК».tools/emu.py гоняет оригинальный двоичный файл на
эмулированном 6502, и каждый экран в screens/ совпадает с ним
попиксельно.web/ те же 3874 байта крутятся на эмуляторе 6502,
написанном на JavaScript.| путь | что это |
|---|---|
SNAKE.FIL |
архивный файл — единственное, что было на входе |
bin/SNAKE.bin |
сама игра, $2000–$2F21, 3874 байта |
docs/SNAKE.disasm.txt |
дизассемблер, код отделён от данных |
docs/SNAKE.annotated.txt |
то же, но с именами |
docs/tables.md |
все таблицы, расшифрованные |
docs/text.txt |
все строки, разложенные по сетке 32×32 |
screens/screen-*.png |
четыре экрана, которые рисует игра, и один сыгранный |
screens/font.png |
все 128 знакомест |
screens/tiles.png |
шестнадцать знакомест змейки, с подписями |
screens/prizes.png |
девять зверей и сколько каждый стоит |
tools/emu.py |
6502 и ровно столько Агата, чтобы запустить двоичный файл |
tools/check.py |
запускает его и сверяет результат с картинками |
tools/play.py |
играет и фотографирует экран |
web/ |
браузерная версия — тот же двоичный файл, 6502 на JavaScript |
convert.py |
воспроизводит всю папку: python3 convert.py |
manifest.txt |
что откуда взялось |
SNAKE.FIL — это один файл, вынутый из каталога DOS 3.3 и обёрнутый: 30 байт
имени, добитого кодом $A0, девять запасных, затем по смещению 39 тип файла —
$04, B-файл. Его собственный четырёхбайтный заголовок говорит, что грузиться
надо по $2000 и что длина $0F22 = 3874 байта. Остальное в контейнере —
добивка до целых секторов. Ровно та же раскладка, что и у отдельных .FIL
из архива Rise Out.
Ничего не повреждено. Одиннадцать байт в файле случайно равны $BD — тому
самому значению, которым забивается потерянный сектор, — но все одиннадцать
это обычные данные посреди таблиц, а не дырка.
Экран Агат-7 здесь — 256×256, бит на точку, 32 байта на строку, старший бит
слева, и страниц две: в $4000 экран собирается, а $6000 — то, что видно.
Знакоместо 8×8, значит экран — это сетка 32×32, а адрес — просто
страница | строка в старшем байте и столбец в младшем, с шагом 32 байта на
растровую строку. Это PUTCH по адресу $237E, двадцать одна команда, и
другой рисующей подпрограммы в игре нет.
Rise Out распоряжается своими двумя страницами так же — $4000 чистая копия,
$6000 то, что видно, — но по другой причине: там это нужно, чтобы стирать
спрайты, возвращая фон. Здесь $4000 только накопитель, а SHUTTER ($2670)
переливает его в $6000 по одному биту:
LSR $4000,X ; страница 1 — старший байт
ROR $6000,X ; страница 2 — младший байт сдвига вправо на 16 бит
Прогнать это по всем 8192 позициям восемь раз — и собранная картинка переезжает в видимую страницу, попутно опустошая накопитель. На экране каждая восьмипиксельная ячейка разом заполняется слева. Единственный переход в игре, и он же между всеми тремя заставками и перед появлением поля.
По $26A5 лежат 128 знакомест по восемь байт — 1024 байта, целый
знакогенератор внутри самой игры, иначе и нельзя: всё происходит в графическом
режиме 256×256, где никакого аппаратного текста нет. $20–$7F — это КОИ-7 Н2,
поэтому $30 — это 0, и подпрограмма счёта индексируется прямо в цифры.
А $00–$0F — вообще не символы: это змейка.

Четыре головы, четыре хвоста, два прямых куска туловища и четыре поворота — и
каждое из них набрано через пиксель, отчего змейка на чёрном поле выглядит
серой и чешуйчатой. Стена — тот же приём, только плотнее. Rise Out делает то
же самое в своей библиотеке (SETSPRITE смотрит на $D000, и «код символа
N и спрайт N — одни и те же восемь байт»); здесь никакой библиотеки нет,
одна таблица и одна подпрограмма.
Девять зверей — исключение: у них своя таблица по $2EDA, и рисуются они тем
же PUTCH, которому просто подсовывают другую базу:

$3C00–$3FFF — по байту на клетку, 32×32, прямо под видеостраницами. В байте
не «занято», а направление, в котором змейка шла, когда была в этой
клетке: 1 вверх, 2 вниз, 4 влево, 8 вправо, $70 стена, $80 приз,
$00 пусто.
На этом одном решении держится всё. Ход змейки — это:
$80 — приз, $00 — свободно,
что угодно ещё — смерть;Никакого списка сегментов, никакого счётчика длины, нечего перекладывать.
Туловище рисует себя само: знакоместо поворота берётся из таблицы по ключу
(новое направление << 4) | старое, так что поворот сам выбирает себе угол.
Игровое поле — внутренность 30×29; строка 31 отдана под статус: рекорд в столбцах 5–7, скорость в 14–16, счёт в 23–25, по три двоично-десятичных цифры.
Счёт — это и есть длина змейки. Начинается с 3 при змейке из трёх клеток и растёт ровно на единицу за каждый ход, когда хвост остаётся на месте. Съеденный кролик ценой 5 не добавляет к счёту 5: он добавляет 5 к счётчику, и следующие пять ходов змейка растёт, зарабатывая по очку за ход.
Во всём файле нет ни датчика случайных чисел, ни обращения к часам. Что появится и где — следствие того, как играет сам игрок:
$2B88 индексирует таблицу из 17 пар (очки, зверь) и уменьшается на единицу
каждый раз, когда нажата стрелка, с переворотом на 16. То есть кто
выпадет следующим, зависит от того, сколько поворотов вы сделали по дороге к
предыдущему.$2B86 и $2B87 — клетка, от которой начинается поиск свободного места.
Одна из двух уменьшается каждый кадр — а какая именно, опять же
переключается на каждой стрелке. От этого зерна код идёт вперёд до первой
свободной клетки.Оба зерна лежат в самом двоичном файле ($14, $19, $04), так что первый
приз после холодного пуска всегда один и тот же: муха це-це ценой 1 в клетке
(21, 25). Дальше — как повезёт, в чём и весь смысл.
| зверь | очки |
|---|---|
| ТУЗЕМЕЦ | 11 |
| ЛЯГУШКА (АНФАС) | 2 |
| КЕНГУРУ | 12 |
| МУХА ЦЕ-ЦЕ | 1 |
| ТУЗЕМНЫЙ ПЕСИК | 6 |
| МЫШКА | 3 |
| ЯЙЦО С ЦЫПЛЕНКОМ | 4 |
| КРОЛИК | 5 |
| БАРАН (СЗАДИ) | 13 |
Из собственной таблицы диспетчеризации игры по $2B43 — код клавиши, адрес
обработчика, $80 завершает — и в точности как на экране помощи:
| клавиша | код | что делает |
|---|---|---|
| стрелки | $19 $1A $08 $15 |
вверх, вниз, влево, вправо |
+ или ; |
$2B $3B |
быстрее |
- |
$2D |
медленнее |
РЕД |
$1B |
звук вкл / выкл |
УПР+C |
$03 |
остановиться — до любой клавиши |
R |
$52 |
перезагрузка |
Первая же нажатая стрелка и запускает игру. Поворот себе в шею отвергается по таблице противоположностей, а не арифметикой.
Число скорости в статусной строке — это 256 − задержка, и считается оно
досчётом в двоично-десятичном виде столько же раз, потому что перевода из
двоичного в десятичное в коде нет и больше нигде не нужно.
Выключение звука — самое изящное место в файле. Щелчок — это LDA ($06),Y,
где $06/$07 указывают на $C030, динамик. Выключение звука делает вот что:
LDA $07
EOR #$FF
STA $07
$C0 превращается в $3F, и щелчок теперь читает $3F30 — безобидный байт
ОЗУ. Одна команда, чтобы заглушить, одна, чтобы вернуть, и тайминг не меняется.
Мелодий пять, и все играет одна и та же подпрограмма в восемь строк, идущая по
парам (число периодов, полупериод): пуск игры, съеденный приз, столкновение,
переключение звука, сброс. Они выписаны с нотами в
docs/tables.md.
Первое, что делает программа, — ищет вверх от $C200 страницу в 256 байт,
которая читается сплошь отрицательной. Это плата ПЗУ/ОЗУ Агата, и я это знаю
потому, что Rise Out делает точно такой же поиск под авторским комментарием —
«НАХОЖДЕНИЕ АДРЕСА ПЛАТЫ ПЗУ И ОЗУ» — с теми же двумя волшебными смещениями:
$80 снимает защиту, $A0 ставит обратно. Между ними «ПИТОНЧИК» вписывает
свой адрес в вектор сброса процессора по $FFFC:
LDY #$80 ; снять защиту
STA (ROM),Y
LDA #$23 : STA $FFFD
LDA #$0F : STA $FFFC ; -> $230F, обратно в игру
LDY #$A0 ; поставить обратно
STA (ROM),Y
Поэтому ⟨СБРОС⟩ не вываливает в монитор: он играет фразу из двенадцати нот и
возвращает к заставке. R идёт в другую сторону — снять защиту, обнулить байт
включения по $03F4, JMP ($FFFC), — и это буква в букву та же перезагрузка,
что Rise Out делает по той же клавише.

Три заставки, каждая из которых — один и тот же титульный блок плюс одна
строка, и затем поле. Текст авторский, с авторским же правописанием: буквы,
у которых есть латинский двойник (А В Е К М Н О Р С Т Х), набраны латиницей,
остальные — кодами КОИ-7, так что слово CТPAНИЧКA наполовину из одной
таблицы, наполовину из другой и читается только потому, что начертания
совпадают. convert.py ничего не исправляет.
МАЛЬЦЕВ АНДРЕЙ ПРИГЛАШАЕТ ВАС ПОИГРАТЬ В ПИ-ТОНЧИКА. ЭТА ИГРА ПОМОЖЕТ ВАМ В МИНУТЫ СКУКИ. ИГРАЙТЕ ВСЕ!!!
Список зверей озаглавлен «СТРАНИЧКА ДЛЯ НЕДОГАДЛИВЫХ». Экрана «конец игры» нет вовсе: три гудка, и после трёх нажатий пробела возвращается заставка.
ROLL ($22DC) крутит восемь
растровых строк вправо на пиксель, снова и снова, пока не нажмут пробел, —
бегущая строка как она есть. Работает он по $7F00, то есть по знакоместной
строке 31. Все три заставки строку 31 оставляют пустой; строка с копирайтом,
которой этот эффект и подошёл бы, лежит одной строкой выше, по $7E00.
Промах на страницу. Доказать, что имелась в виду строка 30, я не могу, но в
том виде, в каком это выпущено, эффекта не видно.LDA #$00 ($2224), диспетчеризация клавиш — операнд JMP
($259E), а очистка экрана переписывает оба байта собственной STA.
Никаких таблиц косвенных переходов в файле нет.web/index.html играбельна, сборки не требует и работает прямо с file://.
Это не переписанная игра: страница грузит bin/SNAKE.bin и запускает его на
6502, написанном на JavaScript, с клавиатурой по $C000, однобитным динамиком
по $C030 и $6000 на канве. web/tools/check.js поднимает ту же машину под
Node и сверяет её видеостраницы с теми же эталонами — так что на этих четырёх
экранах теперь сходятся трое: отрисовщик по расшифрованным таблицам, 6502 на
Python и 6502 на JavaScript.
Про звук стоит сказать отдельно. Тактирует всё звуковая карта: каждый блок из
1024 отсчётов получается прогоном процессора ровно на столько тактов, сколько
длится блок, и каждый отсчёт — это и есть один бит динамика в тот момент.
Никакого синтезатора и никакой таблицы нот: щелчки между ходами и свип на
старте — это собственные обращения игры к $C030. Подробности и единственное
число, которое мне пришлось выбрать самому (частота 1 МГц), — в
web/README.md.
Расшифровка формата по машинному коду остаётся догадкой, пока её что-нибудь не
подтвердит. tools/emu.py — это 6502 с клавиатурой, с динамиком, который никто
не слушает, и с одной страницей $FF там, где должна быть плата ПЗУ, — ровно
столько, чтобы запустить SNAKE.bin без единой правки. tools/check.py
поднимает его, трижды нажимает пробел и сверяет видеостраницу с картинками,
которые convert.py рисует по таблицам:
title ok (0 pixels differ of 65536)
controls ok (0 pixels differ of 65536)
prizes ok (0 pixels differ of 65536)
game ok (0 pixels differ of 65536)
6502 на JavaScript в web/ тактируется по той же таблице, сгенерированной из
того же источника, и оба сходятся точно: 1 504 857 тактов от холодного
сброса до первой заставки — и там, и там.

screens/screen-play.png — единственная картинка здесь, которую не рисовали.
Это игра — те самые 3874 байта — работающая на 6502 из tools/emu.py, и
кто-то сидит за клавиатурой.
Этот кто-то — tools/play.py. Он поднимает машину, трижды жмёт пробел, чтобы
пройти заставки, и дальше раз в ход делает вот что:
$3C00 — тот самый массив, который игра ведёт для себя,
по байту на клетку, — а вместе с ним голову из $1E/$1F и текущее
направление из $1B;STEP ($21B4), то есть к началу
хода.В машину он ничего не пишет. Единственное, что переходит от скрипта к игре, —
те же четыре кода клавиш, которые нажал бы человек, и приходят они в $C000
ровно так, как их доставляло железо. Отличить это от терпеливого игрока игра
не может.
Первый преследователь оказался слишком азартным. Он делал очевидное —
вычислял, в какой стороне приз, и шёл туда, — и замуровывал себя ходов за
двенадцать. Падал он при этом красиво: трассировка выходила из $2376, а это
две команды внутри GETKEY — того самого ожидания клавиши, в которое игра
проваливается после столкновения. Скрипт доезжал до конца собственного плана и
въезжал прямо в то «нажмите клавишу», которое игра держит для живого игрока.
Поэтому вместо аппетита преследователь получил инстинкт самосохранения. Перед каждым ходом он заливкой считает, сколько свободных клеток остаётся достижимо из каждой из трёх разрешённых, и выбирает ту, после которой поля остаётся больше всего; расстояние до приза идёт только на разрешение ничьих. Этого хватает, чтобы разметать поле 30×29 длинными челночными проходами и не запереть себя — отсюда и вид картинки. Змейка на ней не блуждает, она укладывается.
Снятая партия — это 624 хода до длины 96. На Агате это
210 908 752 такта, около трёх с половиной минут на той скорости, с которой
игра выпущена. Здесь она проходит за секунды, потому что emu.py узнаёт
DELAY ($23C0) на входе, начисляет часам ровно столько, сколько цикл стоил
бы, и пропускает его:
7 + 11n + 5n(n+1)/2 # n — байт задержки, по умолчанию $CD
Это треугольный цикл, посчитанный на бумаге и потом сверенный с настоящим прогоном — сходится такт в такт на всех значениях, которые игра использует. До 108 000 тактов на вызов, три вызова на ход, и всё это чистое ожидание. Остальное выполняется как есть.
Тот же преследователь есть и на JavaScript, для браузерной стороны —
node web/tools/check.js play 96 печатает поле в ASCII, — но клавиши он
складывает в очередь чуть иначе, так что ход в ход партии не совпадают.
Игра цельная и самодостаточная: ей не нужны ни библиотека, ни отдельный
знакогенератор, ни файл данных. Чего архив не сообщает — с какой дискеты она
взялась, был ли когда-нибудь исходник и означает ли 1988 на заставке год
написания или год этой копии. Шапка Rise Out датирована 1-СЕН-88 … 9-МАР-89,
так что делались они примерно в одно время, — но «ПИТОНЧИК», где всё внутри и
никакой библиотеки, читается как более ранняя из двух.