10 REM"_(C2SLFF4

Это первая строка The Wizard's Castle — игры на BASIC для микрокомпьютеров 80-х, написанной изначально под Exidy Sorcerer.

Перед нами оператор REM (от remark) — то есть комментарий. 10 — номер строки, если вы не застали языки, где такое ещё было.

Но самое интересное — вот эта абракадабра: "_(C2SLFF4. Опечатка? Мусор? Ни то ни другое. Ровно так она и напечатана в исходнике, вышедшем в июльском номере Recreational Computing за 1980 год.

Что же это такое?

Небольшая ремарка: я буду скакать между десятичной системой (родной для BASIC) и шестнадцатеричной (родной для программистов). Шестнадцатеричные числа легко узнать по префиксу 0x, суффиксу h или по буквам A—F.

The Wizard's Castle
The Wizard's Castle

Первые подозрения

Вот тот же исходник чуть шире — я выкинул лишнее и добавил пробелов для читаемости:

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 раскладывает программу в памяти

The Exidy Sorcerer
The Exidy Sorcerer. Фото: Marcin Wichary, CC BY 2.0.

Когда вы вводите строку в 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

Исходный код Wizard's Castle
Исходный код Wizard's Castle

Итак, в том, что всё это — часть инициализации ГПСЧ, я почти не сомневался. Вот бы ещё как-то это доказать!

И тут я замечаю в том самом номере 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

F-4 Phantom
F-4 Phantom. Каламбур на стыке авиации и программирования.

В моей версии было:

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)


  1. CitizenOfDreams
    23.07.2026 11:52

    Возвращаемое значение USR() присваивается в T, и что это за значение — я так и не понял.

    А там все сложно - подпрограмма возвращает в Бейсик результат в виде записанного в память 4-байтового числа с плавающей точкой запятой. В "Спектруме" было проще - результат передавался как целое число через регистровую пару BC.


  1. goncharovyelisey
    23.07.2026 11:52

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


  1. arteast
    23.07.2026 11:52

    Интересно, а на других платформах такое бы прокатило?

    В загрузчиках игр на zx spectrum это было стандартным трюком.