
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"_(C2SLFF4 40 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 ( 28 C 43 2 32 S 53 L 4C F 46 F 46 4 34
В конце ещё нулевой терминатор, но в Z80 это просто NOP — забудем про него.
Дизассемблируем:
22 5F 28 LD (285Fh),HL ; " _ ( 43 LD B,E ; C 32 53 4C LD (4C53h),A ; 2 S L 46 LD B,(HL) ; F 46 LD B,(HL) ; F 34 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"_(C2SLFF4F4F4 15 REM ED 5F 28 FC 32 FF F7 C9 (in O1DA)
Ого! Кто-то снабдил исходник hex-дампом машинного кода! Мало того что он заканчивается на 0xC9 — а это RET в Z80, — так там ещё и прямым текстом стоит адрес правого нижнего угла экрана 0xF7FF!
Но как, чёрт возьми, текст REM превращается вот в это? 0xED — очевидно не ASCII. Хотя погодите: часть символов вполне себе ASCII, и позиции у них совпадают с тем, что мы видим в REM!
" 5F _ 28 ( C 32 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 2 READY
Внизу виден нулевой терминатор. И скрытый пробел в конце строки. А 237 — это 0xED, 95 — это 0x5F… всё сходится с комментарием из строки 15!
Ну что, дизассембли-и-и-и-ируем! Перевожу числа в hex — и вперёд.
ed 5f LD A,R " _ 28 fc JR Z,-4 ( C 32 ff f7 LD (F7FF),A 2 S L c9 RET F 46 LD B,(HL) F 34 INC (HL) 4 46 LD B,(HL) F 34 INC (HL) 4 20 00 JR NZ,0 пробел null
Позже выяснится, что кусок после RET дизассемблирован неверно, да и сопоставление символов REM с hex-значениями у меня тут кривое, — но какая сейчас разница! Всё интересное всё равно заканчивается на RET.
А код — ровно тот, который мы искали! Смотрим на первые инструкции:
LD A,R ; копируем регистр R в аккумулятор JR Z,-4 ; если получился ноль — прыгаем на предыдущую инструкцию LD (F7FF),A ; кладём аккумулятор по адресу F7FFh RET ; возврат
Что он делает? Регистр R у Z80 примечателен тем, что растёт на единицу при каждой выборке инструкции. Вроде бы. Разные источники говорят по-разному. Так или иначе, по человеческим меркам он меняется очень и очень часто. А Sorcerer ждёт ввода в цикле активного ожидания, так что к моменту запуска игры в R лежит фактически случайное число.
Но засеивать простенький ГПСЧ нулём — плохая идея: дальше он обычно выдаёт одни нули. Поэтому код повторяет попытку, если в R попался 0. А если значение ненулевое — кладёт его по адресу 0xF7FF, в правый нижний угол экрана! Оттуда его и подхватывает PEEK(), чтобы засеять ГПСЧ!
Правда, R инкрементирует только младшие 7 бит, то есть принимает всего 128 значений. Ноль мы отбрасываем — выходит, на Sorcerer можно было сыграть ровно в 127 разных подземелий. Обидно!
Значит, символы, которые мы видим, просто не ASCII. Первая попытка дизассемблирования была обречена: я исходил из того, что весь текст листинга — ASCII, а REM это правило нарушает.
Для проверки я набрал новую программу: просто REM, а за ним куча пробелов, чтобы было где развернуться до нулевого терминатора. Потом записал туда через POKE нужные значения и вывел листинг.
10 REM POKE 474,237 READY POKE 475,95 READY POKE 476,40 READY LIST 10 REM"_( READY
Сработало! Эти значения дали мне ровно те глифы "_(, что были в оригинальном листинге!
А можно ли было это вообще набрать?
Смысл таких журналов, как Recreational Computing, в 80-е был в том, что вы получали номер по почте или покупали в киоске, а потом часами мучительно вбивали листинг и вылавливали ошибки.
Занятие непростое. Вот ещё кусок из The Wizard's Castle:
1070 IFFL=0THENPRINT:PRINT"** HEY BRIGHT ONE, YOU'RE OUT OF FLARES":GOTO620 1080 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=B 1100 GOSUB 3400:GOTO620 1110 IFLF=0THENPRINT:PRINT"** YOU DON'T HAVE A LAMP, ";R$(RC):GOTO620 1120 PRINT:PRINT"WHERE DO YOU SHINE THE LAMP (N,S,E, OR W) ";:GOSUB3290 1130 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
Мы сняли дамп памяти его версии и получили:
237 95 40 252 50 255 247 201 70 52 70 52 32 0 18 2
Заметили странность насчёт F4? Вот и я сначала не заметил. В REM их три, а в дампе памяти — только два (пары 70, 52)!
И это ещё не всё: куда подевалась L? Что-то не сходится. Первые три символа я уже проверял вручную — давайте теперь возьмёмся за остальные.
Воссоздам весь машинный код целиком и посмотрю, что получится.
10 REMXXXXXXXXXXXXXX 20 FOR I=474 TO 481: READ X: POKE I,X: NEXT I 30 DATA 237, 95, 40, 252, 50, 255, 247, 201
Запускаю, вывожу листинг. Получаю:
10 REM"_(C2SLFF4XXXXXX 20 FOR I=474 TO 481: READ X: POKE I,X: NEXT I 30 DATA 237, 95, 40, 252, 50, 255, 247, 201
Стоп! Мои X съехали вправо на два символа! Это как? Символы не берутся из ниоткуда. Как будто в выводе появились два лишних: я записал восемь значений, а до X печатается десять символов!
Снимем дамп памяти и посмотрим, что там. Вывод я снабжу пояснениями, но — спойлер — пояснения неверные:
237 " 95 _ 40 ( 252 C 50 2 255 S 247 L 201 F 88 X ← никаких дополнительных F и 4! 88 X 88 X 88 X
Ни F, ни 4. Сразу за последним 201 (из DATA) идут одни X, то есть 88. Так почему же в листинге они есть?
Разберёмся предметно. Подставлю вручную 255, 247 и 201 в REM и посмотрю, что выйдет.
10 REMX POKE 474,255 LIST 10 REMS
Ага, S — как и ожидалось.
POKE 474,247 LIST 10 REMLF
Опа — что? LF? Два символа? Подозрительно похоже на linefeed, но кто его знает. Зато с REM совпадает!
POKE 474,201 LIST 10 REMF4
А вот и F4. Значит, два последних байта печатаются как LFF4. Отсюда и берутся два лишних символа.
Исправляем пояснения к дампу:
237 " 95 _ 40 ( 252 C 50 2 255 S 247 LF 201 F4 88 X 88 X 88 X 88 X
Вот теперь всё сходится с листингом:
10 REM"_(C2SLFF4XXXXXX
И это ещё не всё. Байты со значением 128 и чуть выше на самом деле соответствуют ключевым словам BASIC! Смотрите:
POKE 474,137 LIST 10 REMGOTO
Мы предполагаем, что при выводе строки BASIC смотрит на старший бит: если он установлен — ищет в таблице имя ключевого слова и печатает его. А странные символы для значений повыше (те, что выглядят случайными) — тоже наша догадка — получаются при выходе за конец этой таблицы.
Но погодите-ка. В дампе памяти Криса были самые настоящие ASCII-символы F и 4:
237 " 95 _ 40 ( 252 C 50 2 255 S 247 LF 201 F4 ← RET 70 F ← а это ещё что такое? 52 4 70 F 52 4 32 0 18 2
Зачем их дописали, если на машинный код они не влияют никак? Похоже, эту загадку придётся оставить: ответ канул в Лету. Или можете вглядываться в магический шар, пока он не проявится. Только не увлекайтесь.
Подводим итоги
Единственный смысл всей затеи — а я ведь с самого начала понимал, что речь про инициализацию ГПСЧ, — был в том, чтобы утолить хакерское любопытство. Роскошь, что и говорить!
Что мы выяснили?
В
REMна Exidy Sorcerer можно запихнуть машинный код, но на вменяемую распечатку рассчитывать не стоит.Набранный из журнала код, скорее всего, не работал.
Автор, вероятно, вписал машинный код напрямую через
POKE. Или же на Sorcerer была какая-то графическая shift-клавиша, позволявшая набрать эти символы.Теперь мы знаем — с куда большей уверенностью, чем это вообще требовалось, — ответ на извечный вопрос: «Какого лешего этот комментарий делает в начале The Wizard's Castle?»
Сколько мы на этом заработали? Очевидно, ноль!
Интересно, а на других платформах такое бы прокатило? Или они бы просто ушли в разнос? Пусть кто-нибудь попробует на Commodore 64 или чём-то подобном.
А если хотите узнать больше про The Wizard's Castle и даже поиграть в этот кусочек истории — у меня на GitHub лежит подборка документов и материалов.
Комментарии (3)

goncharovyelisey
23.07.2026 11:52Не уверен, что всё именно так. Складывается ощущение, что часть выводов построена на догадках, а не на фактах.

arteast
23.07.2026 11:52Интересно, а на других платформах такое бы прокатило?
В загрузчиках игр на zx spectrum это было стандартным трюком.
CitizenOfDreams
А там все сложно - подпрограмма возвращает в Бейсик результат в виде записанного в память 4-байтового числа с плавающей
точкойзапятой. В "Спектруме" было проще - результат передавался как целое число через регистровую пару BC.