
10 REM"_(C2SLFF4
Это первая строка The Wizard’s Castle — игры на BASIC для микрокомпьютеров 80-х, написанной изначально под Exidy Sorcerer.
Перед нами оператор REM (от remark) — то есть комментарий. 10 — номер строки, если вы не застали языки, где такое ещё было.
Но самое интересное — вот эта абракадабра: "_(C2SLFF4. Опечатка? Мусор? Ни то ни другое. Ровно так она и напечатана в исходнике, вышедшем в июльском номере Recreational Computing за 1980 год.
Что же это такое?
Небольшая ремарка: я буду скакать между десятичной системой (родной для BASIC) и шестнадцатеричной (родной для программистов). Шестнадцатеричные числа легко узнать по префиксу 0x, суффиксу h или по буквам A—F.
Первые подозрения
Вот тот же исходник чуть шире — я выкинул лишнее и добавил пробелов для читаемости:
10 REM"_(C2SLFF440 POKE 260,218: POKE 261,1: T = USR(0): T = PEEK(-2049)80 Q = RND(-(2*T+1))
В BASIC двоеточие разделяет команды. POKE пишет байт по указанному адресу памяти, PEEK читает оттуда. BASIC на Sorcerer, судя по всему, работал со знаковыми 16-битными числами, так что адрес -2049 — это 65536−2049, то есть 0xF7FF. К нему мы ещё вернёмся.
Если вызвать генератор псевдослучайных чисел (ГПСЧ) RND() с отрицательным аргументом, он задаст новое зерно (seed). Старые ГПСЧ любили нечётный seed — отсюда и 2*T+1, которое принудительно делает число нечётным.
А функция USR() передаёт управление подпрограмме на машинном коде.
В строке 80 переменная T используется впервые после того, как в неё попал результат PEEK().
Всё это лежит так кучно, что напрашивается вывод: работают эти строки сообща и занимаются инициализацией ГПСЧ. Команды RANDOMIZE в BASIC для Sorcerer не было, так что вариантов оставалось три:
-
попросить пользователя ввести seed вручную;
-
крутить счётчик, пока пользователь не нажмёт клавишу (или что-нибудь в этом духе), и взять получившееся число;
-
выцепить что-нибудь более-менее случайное из уже работающего софта или железа.
Первых двух вариантов в Wizard’s Castle нет. Значит, третий.
А что, если в REM спрятан машинный код, который по случайности кодируется печатными ASCII-символами? Sorcerer как раз использовал ASCII.
Хотя звучит-то дико: рабочий машинный код Z80 из одних только ASCII-символов? Серьёзно? И всё же гипотеза была достаточно безумной, чтобы взять её в работу и посмотреть, куда она нас выведет.
Разбираемся с USR()
С USR() всё интереснее: функция вызывает машинный код, но подробности зависят от конкретной системы. К счастью, в интернете хватает технических руководств в самых разных местах, так что я разобрался.
По адресу 259 лежит инструкция JP — безусловный переход на 16-битный абсолютный адрес в little-endian.
А POKE пишет как раз в 260 и 261. Это и есть трамплин для USR(): записываете туда адрес начала своего машинного кода, а потом вызываете USR().
40 POKE 260,218: POKE 261,1: T = USR(0): T = PEEK(-2049)
Переставляем байты обратно с учётом little-endian и получаем адрес (1 << 8) | 218, то есть 474. При вызове USR(0) управление уходит на машинный код по адресу 474, который должен заканчиваться инструкцией RET.
Аргумент
USR()(тот самый0) лежит где-то в оперативке в виде 4-байтового числа с плавающей точкой — на случай, если машинный код захочет его прочитать. Здесь он ни на что не влияет. Возвращаемое значениеUSR()присваивается вT, и что это за значение — я так и не понял. Впрочем, неважно: следующим же присваиваниемTзатирается.
Итак… что там по адресу 474?
Как BASIC раскладывает программу в памяти
Когда вы вводите строку в BASIC на Sorcerer, интерпретатор разбивает её на токены и заменяет команды однобайтовыми кодами:
-
PRINTпревращается в 0x97; -
REMпревращается в 0xC3; -
и так далее.
Дальше всё это хранится в памяти как связный список, в котором каждая строка — узел. Формат узла:
-
2 байта — указатель на следующий узел;
-
2 байта — номер строки;
-
байты токенизированной строки кода;
-
1 нулевой байт-терминатор (0x00).
Начинается этот список на Sorcerer с адреса 469.
Ещё раз посмотрим на загадочную строку:
10 REM"_(C2SLFF4
Значит, её узел раскладывается по адресам так:
469 Младший байт указателя на следующий узел470 Старший байт указателя на следующий узел471 Младший байт номера строки472 Старший байт номера строки473 Токен `REM` (0xc3)474 Первый байт текста REM — символ `"`!!
А 474 — это ровно то место, куда нас забрасывает USR()! Программа буквально выполняет текст комментария как машинный код Z80!
Дизассемблируем: попытка первая
Неужели правда? Сейчас проверим!
Вот hex-коды наших ASCII-символов:
" 22_ 5F( 28C 432 32S 53L 4CF 46F 464 34
В конце ещё нулевой терминатор, но в Z80 это просто NOP — забудем про него.
Дизассемблируем:
22 5F 28 LD (285Fh),HL ; " _ (43 LD B,E ; C 32 53 4C LD (4C53h),A ; 2 S L46 LD B,(HL) ; F46 LD B,(HL) ; F34 INC (HL) ; 4
Не буду углубляться в тонкости ассемблера Z80 — поверьте на слово, смысла в этом коде нет никакого. Адреса указывают в никуда, что лежит в HL на входе — бог знает, B никто не читает, продублированный LD B бесполезен, а RET, который вернул бы нас в BASIC, отсутствует как класс.
Мусор. В эмуляторе он ожидаемо творит непонятное — вплоть до программной перезагрузки. На этом я временно уткнулся в тупик.
Что за PEEK(-2049)
Ладно, зайдём с другого конца — со стороны PEEK:
40 POKE 260,218: POKE 261,1: T = USR(0): T = PEEK(-2049)
Что по адресу −2049? Если трактовать число как беззнаковое, получаем 0xF7FF. По документации это последний байт текстовой видеопамяти — то есть символ в правом нижнем углу экрана.
И тут же этим значением инициализируется ГПСЧ:
40 [ ... ] T = PEEK(-2049)80 Q = RND(-(2*T+1))
А сейчас я применю свои магические способности, загляну в одно из ваших открытых окон терминала и прочитаю символ в правом нижнем углу. Расплывается, но если сосредоточиться… это… да, это пробел, верно?
Угадал? С вас 200 долларов.
Так вот, пробел — это 32. Если каждую партию засеивать ГПСЧ значением -(2*32+1), то реиграбельность у случайно генерируемого подземелья будет нулевая. Значит, USR() обязан к этому руку приложить: похоже, он что-то кладёт на экран по адресу 0xF7FF, а PEEK() это оттуда забирает. Экран вскоре очищается, так что игрок вряд ли заметит мелькнувший в углу символ.
RTFM
Итак, в том, что всё это — часть инициализации ГПСЧ, я почти не сомневался. Вот бы ещё как-то это доказать!
И тут я замечаю в том самом номере Recreational Computing, где программу и опубликовали, такую строчку: «Первый комментарий — это подпрограмма на машинном языке, эмулирующая функцию RANDOM».
Мда. Вот что бывает, когда не читаешь то, что написано.
В BASIC за случайные числа отвечает RND(), а не RANDOM: с отрицательным аргументом она задаёт seed, с положительным выдаёт следующее число (исторически — дробное от 0 до 1).
Так что автор, скорее всего, имел в виду RANDOMIZE — популярную в Microsoft BASIC команду, которая либо ставила конкретное зерно, либо просила пользователя ввести его.
Короче, работать всё должно было ровно так, как я и думал. Вот только машинный код ничего подобного не делал.
Прорыв
Я гонял эмуляторы Sorcerer в MAME, а у моего товарища Криса, который помогал мне в расследовании, работал другой эмулятор, на Java.
В MAME мы ввели тот самый REM, залезли в память — и увидели ровно те бессмысленные байты и тот бессмысленный код, что выше. И работать он отказывался.
А потом Крис нашёл образ кассеты с игрой и загрузил его у себя. Первые две строки исходника выглядели так:
10 REM"_(C2SLFF4F4F415 REM ED 5F 28 FC 32 FF F7 C9 (in O1DA)
Ого! Кто-то снабдил исходник hex-дампом машинного кода! Мало того что он заканчивается на 0xC9 — а это RET в Z80, — так там ещё и прямым текстом стоит адрес правого нижнего угла экрана 0xF7FF!
Но как, чёрт возьми, текст REM превращается вот в это? 0xED — очевидно не ASCII. Хотя погодите: часть символов вполне себе ASCII, и позиции у них совпадают с тем, что мы видим в REM!
"5F _28 ( C32 2 S L F
А какие там байты на самом деле? Крис загрузил программу и посмотрел:
FOR I=474 TO 489: PRINT PEEK(I): NEXT I 237 95 40 252 50 255 247 201 70 52 70 52 32 0 18 2READY
Внизу виден нулевой терминатор. И скрытый пробел в конце строки. А 237 — это 0xED, 95 — это 0x5F… всё сходится с комментарием из строки 15!
Ну что, дизассембли-и-и-и-ируем! Перевожу числа в hex — и вперёд.
ed 5f LD A,R " _28 fc JR Z,-4 ( C32 ff f7 LD (F7FF),A 2 S Lc9 RET F46 LD B,(HL) F34 INC (HL) 446 LD B,(HL) F34 INC (HL) 420 00 JR NZ,0 пробел null
Позже выяснится, что кусок после RET дизассемблирован неверно, да и сопоставление символов REM с hex-значениями у меня тут кривое, — но какая сейчас разница! Всё интересное всё равно заканчивается на RET.
А код — ровно тот, который мы искали! Смотрим на первые инструкции:
LD A,R ; копируем регистр R в аккумуляторJR Z,-4 ; если получился ноль — прыгаем на предыдущую инструкциюLD (F7FF),A ; кладём аккумулятор по адресу F7FFhRET ; возврат
Что он делает? Регистр R у Z80 примечателен тем, что растёт на единицу при каждой выборке инструкции. Вроде бы. Разные источники говорят по-разному. Так или иначе, по человеческим меркам он меняется очень и очень часто. А Sorcerer ждёт ввода в цикле активного ожидания, так что к моменту запуска игры в R лежит фактически случайное число.
Но засеивать простенький ГПСЧ нулём — плохая идея: дальше он обычно выдаёт одни нули. Поэтому код повторяет попытку, если в R попался 0. А если значение ненулевое — кладёт его по адресу 0xF7FF, в правый нижний угол экрана! Оттуда его и подхватывает PEEK(), чтобы засеять ГПСЧ!
Правда, R инкрементирует только младшие 7 бит, то есть принимает всего 128 значений. Ноль мы отбрасываем — выходит, на Sorcerer можно было сыграть ровно в 127 разных подземелий. Обидно!
Значит, символы, которые мы видим, просто не ASCII. Первая попытка дизассемблирования была обречена: я исходил из того, что весь текст листинга — ASCII, а REM это правило нарушает.
Для проверки я набрал новую программу: просто REM, а за ним куча пробелов, чтобы было где развернуться до нулевого терминатора. Потом записал туда через POKE нужные значения и вывел листинг.
10 REM POKE 474,237READYPOKE 475,95READYPOKE 476,40READYLIST10 REM"_(READY
Сработало! Эти значения дали мне ровно те глифы "_(, что были в оригинальном листинге!
А можно ли было это вообще набрать?
Смысл таких журналов, как Recreational Computing, в 80-е был в том, что вы получали номер по почте или покупали в киоске, а потом часами мучительно вбивали листинг и вылавливали ошибки.
Занятие непростое. Вот ещё кусок из The Wizard’s Castle:
1070 IFFL=0THENPRINT:PRINT"** HEY BRIGHT ONE, YOU'RE OUT OF FLARES":GOTO6201080 PRINT:PRINT:FL=FL-1:A=X:B=Y:FORQ1=A-1TOA+1:X=FNB(Q1):FORQ2=B-1TOB+1:Y=FNB(Q2)1090 Q=FNE(PEEK(FND(Z))):POKEFND(Z),Q:PRINTI$(Q);" ";:NEXTQ2:PRINT:PRINT:NEXTQ1:X=A:Y=B1100 GOSUB 3400:GOTO6201110 IFLF=0THENPRINT:PRINT"** YOU DON'T HAVE A LAMP, ";R$(RC):GOTO6201120 PRINT:PRINT"WHERE DO YOU SHINE THE LAMP (N,S,E, OR W) ";:GOSUB32901130 A=X:B=Y:X=FNB(X+(O$="N")-(O$="S")):Y=FNB(Y+(O$="W")-(O$="E"))1140 IFA-X+B-Y=0THENPRINT:PRINT"** TURKEY! THAT'S NOT A DIRECTION":GOTO620
Сплошное кодовое месиво. Но те, кто всё это вбивал руками, наловчились делать это без ошибок. Так что, увидев вот такое:
10 REM"_(C2SLFF4
мы бы набрали его символ в символ, будьте уверены.
Только вот теперь мы знаем: если считать эти глифы обычным ASCII, ничего бы не заработало. Возможно, у программистов Sorcerer было какое-то известное в узких кругах знание — те самые магические заклинания, которыми это набиралось.
А может, автор просто написал вот такое, чтобы застолбить себе место:
10 REMF4F4F4F4F4
а потом вручную вбил туда машинный код через POKE, как я выше, и получил:
10 REM"_(C2SLFF4
И ни словом не обмолвился, как это повторить: забыл в суматохе публикации, и текст ушёл в печать как есть.
Но что за история с этими F4? Она не даёт мне покоя.
Загадка F4
В моей версии было:
10 REM"_(C2SLFF4
В версии Криса — лишние F4:
10 REM"_(C2SLFF4F4F4
Мы сняли дамп памяти его версии и получили:
23795402525025524720170527052320182
Заметили странность насчёт F4? Вот и я сначала не заметил. В REM их три, а в дампе памяти — только два (пары 70, 52)!
И это ещё не всё: куда подевалась L? Что-то не сходится. Первые три символа я уже проверял вручную — давайте теперь возьмёмся за остальные.
Воссоздам весь машинный код целиком и посмотрю, что получится.
10 REMXXXXXXXXXXXXXX20 FOR I=474 TO 481: READ X: POKE I,X: NEXT I30 DATA 237, 95, 40, 252, 50, 255, 247, 201
Запускаю, вывожу листинг. Получаю:
10 REM"_(C2SLFF4XXXXXX20 FOR I=474 TO 481: READ X: POKE I,X: NEXT I30 DATA 237, 95, 40, 252, 50, 255, 247, 201
Стоп! Мои X съехали вправо на два символа! Это как? Символы не берутся из ниоткуда. Как будто в выводе появились два лишних: я записал восемь значений, а до X печатается десять символов!
Снимем дамп памяти и посмотрим, что там. Вывод я снабжу пояснениями, но — спойлер — пояснения неверные:
237 "95 _40 (252 C50 2255 S247 L201 F88 X ← никаких дополнительных F и 4!88 X88 X88 X
Ни F, ни 4. Сразу за последним 201 (из DATA) идут одни X, то есть 88. Так почему же в листинге они есть?
Разберёмся предметно. Подставлю вручную 255, 247 и 201 в REM и посмотрю, что выйдет.
10 REMXPOKE 474,255LIST10 REMS
Ага, S — как и ожидалось.
POKE 474,247LIST10 REMLF
Опа — что? LF? Два символа? Подозрительно похоже на linefeed, но кто его знает. Зато с REM совпадает!
POKE 474,201LIST10 REMF4
А вот и F4. Значит, два последних байта печатаются как LFF4. Отсюда и берутся два лишних символа.
Исправляем пояснения к дампу:
237 "95 _40 (252 C50 2255 S247 LF201 F488 X88 X88 X88 X
Вот теперь всё сходится с листингом:
10 REM"_(C2SLFF4XXXXXX
И это ещё не всё. Байты со значением 128 и чуть выше на самом деле соответствуют ключевым словам BASIC! Смотрите:
POKE 474,137LIST10 REMGOTO
Мы предполагаем, что при выводе строки BASIC смотрит на старший бит: если он установлен — ищет в таблице имя ключевого слова и печатает его. А странные символы для значений повыше (те, что выглядят случайными) — тоже наша догадка — получаются при выходе за конец этой таблицы.
Но погодите-ка. В дампе памяти Криса были самые настоящие ASCII-символы F и 4:
237 "95 _40 (252 C50 2255 S247 LF201 F4 ← RET70 F ← а это ещё что такое?52 470 F52 4320182
Зачем их дописали, если на машинный код они не влияют никак? Похоже, эту загадку придётся оставить: ответ канул в Лету. Или можете вглядываться в магический шар, пока он не проявится. Только не увлекайтесь.
Подводим итоги
Единственный смысл всей затеи — а я ведь с самого начала понимал, что речь про инициализацию ГПСЧ, — был в том, чтобы утолить хакерское любопытство. Роскошь, что и говорить!
Что мы выяснили?
-
В
REMна Exidy Sorcerer можно запихнуть машинный код, но на вменяемую распечатку рассчитывать не стоит. -
Набранный из журнала код, скорее всего, не работал.
-
Автор, вероятно, вписал машинный код напрямую через
POKE. Или же на Sorcerer была какая-то графическая shift-клавиша, позволявшая набрать эти символы. -
Теперь мы знаем — с куда большей уверенностью, чем это вообще требовалось, — ответ на извечный вопрос: «Какого лешего этот комментарий делает в начале The Wizard’s Castle?»
Сколько мы на этом заработали? Очевидно, ноль!
Интересно, а на других платформах такое бы прокатило? Или они бы просто ушли в разнос? Пусть кто-нибудь попробует на Commodore 64 или чём-то подобном.
А если хотите узнать больше про The Wizard’s Castle и даже поиграть в этот кусочек истории — у меня на GitHub лежит подборка документов и материалов.
ссылка на оригинал статьи https://habr.com/ru/articles/1062236/