
Давайте исследуем замок волшебника (точнее, его исходный код)!
10 REM"_(C2SLFF4
Это первая строка написанной на BASIC игры 80-х годов The Wizard's Castle, которую изначально создавали для платформы Exidy Sorcerer.
Она представляет собой REMарку, комментарий языка. 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 выполняет его чтение. Очевидно, в Sorcerer BASIC использовались 16-битные числа со знаком, поэтому адрес -2049 — это 65536-2049 или 0xF7FF в шестнадцатеричном виде. Мы вернёмся к этому адресу позже.
Вызов генератора псевдослучайных чисел (PRNG) RND() с отрицательным числом задаёт для него порождающее значение. Старые PRNG предпочитали нечётные порождающие значения, поэтому выражение 2*T+1 обеспечивает нечётность.
Функция USR() вызывает подпрограмму в машинном коде.
В строке 80 впервые используется T после её инициализации с помощью PEEK().
Из-за близости этих операций кажется, что все они связаны с заданием порождающего значения PRNG. В Sorcerer BASIC нет команды RANDOMIZE, поэтому можно было использовать только следующие варианты:
Попросить пользователя ввести случайное порождающее значение.
Выполнять цикл с инкрементом, пока пользователь не нажмёт на клавишу (или не выполнит какое-то ещё действие), и использовать это значение в качестве порождающего.
Получать порождающее значение от какого-нибудь достаточно случайного элемента уже имеющегося ПО и/или оборудования.
В Wizard's Castle первые два варианта не применяются. Значит, это должен быть третий.
Может ли быть так, что в конструкции REM содержится машинный код, кодируемый в ASCII-текст? В Sorcerer использовалась кодировка ASCII.
Но кажется безумием, что можно получить работающий машинный код Z80 из одних лишь символов ASCII.
Как бы то ни было, это безумное допущение кажется достаточно разумным для его проверки.
Функция USR()
Здесь любопытен вызов USR() — он вызывает функцию машинного кода, но подробности её реализации зависят от системы. К счастью, в разных уголках Интернета полно технических руководств, и нам удалось в этом разобраться.
По адресу 259 содержится команда JP (безусловный переход) к 16-битному абсолютному адресу в формате little-endian.
Функции POKE выполняются для адресов 260 и 261. Это трамплин для функции USR(). Мы записываем при помощи POKE адрес начала машинного кода в адреса 260 и 261, а затем выполняем 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-байтное float в другом месте ОЗУ, поэтому машинный код может при необходимости обращаться к нему. Оказалось, что в данном случае это не важно. Кроме того, мы присваиваем возвращаемоеUSR()значение переменнойT, но мне было непонятно, чему равно это возвращаемое значение. Как бы то ни было, это не важно, потому что следующим присвоением мы сразу же переписываемT.
Итак... Что это за адрес 474?
ОЗУ BASIC

При вводе строки на Sorcerer BASIC интерпретатор токенизирует её и заменяет команды наподобие PRINT однобайтными значениями:
PRINTтокенизируется в 0x97REMтокенизируется в 0xC3и так далее
А затем всё это сохраняется в память в виде связанного списка, где каждая строка становится «узлом».
Узел имеет следующий формат:
2 байта указателя следующего токена
2 байта для номера строки
Байты, обозначающие токенизированную строку кода
1 завершающий нулевой байт (0x00)
И начало этого связанного списка в ОЗУ находится по адресу 469 Sorcerer.
Давайте ещё раз взглянем на загадочную строку:
10 REM"_(C2SLFF4
Она означает, что узел этой строки состоит из следующих адресов и байтов:
469 Младший байт указателя следующего токена 470 Старший байт указателя следующего токена 471 Нижний байт номера строки 472 Младший байт номера строки 473 Токен `REM` (0xc3) 474 Первый байт текста REM, то есть символ `"`!
И 474 — это адрес, по которому нас отправляет функция USR()! Она буквально вызывает текст из оператора REM в качестве машинного кода Z80!
Первая попытка дизассемблирования
Может ли это быть правдой? Давайте проверим!
Вот шестнадцатеричные значения соответствующих символов ASCII:
" 22 _ 5F ( 28 C 43 2 32 S 53 L 4C F 46 F 46 4 34
В конце есть нулевой байт 0x00, но в 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. Согласно документации, это последний байт отображаемого в память экранного текста, показывающего текущий символ в правой нижней части экрана.
Затем это значение сразу же используется в качестве порождающего значения PRNG:
40 [ ... ] T = PEEK(-2049) 80 Q = RND(-(2*T+1))
Сейчас я воспользуюсь своими магическими силами, чтобы заглянуть в хрустальный шар одного из окон терминала и прочитать символ в правой нижней части экрана. Темна вода во облацех, но если сконцентрироваться, то я вижу... да, это символ пробела, не так ли?
Я прав? Тогда с вас пятнадцать тыщ.
Пробел — это 32, поэтому если использовать в качестве порождающего значения PRNG -(2*32+1) в каждой игре, то это случайно генерируемое подземелье будет каждый раз одинаковым. Значит, функция USR() должна как-то решать эту проблему. Давайте сохраним что-нибудь с экрана в адрес 0xF7FF, а затем считаем значение при помощи PEEK(). Вскоре после этого экран очищается, так что игрок может и не заметить символ, временно выведенный в правом нижнем углу.
RTFM

Итак, у нас были сильные подозрения в том, что всё это процесс получения порождающего значения PRNG. Вот бы как-то удалось это как-то доказать!
И тут Джош заметил в журнале, где опубликовали программу, одну строку: «Первая ремарка — это подпрограмма на машинном языке для симуляции функции RANDOM».
Фух. Иногда читать всё-таки вредно.
В BASIC функция RND(), а не RANDOM — это функция случайности; при использовании с отрицательным аргументом он становится порождающим значением PRNG, а положительный аргумент переходит к следующему случайному числу, которое представляет собой случайный результат с плавающей запятой в интервале от 0 до 1.
То есть автор, вероятно, имел в виду RANDOMIZE, которая использовалась в Microsoft BASIC для задания конкретного порождающего значения случайных чисел или для ввода значения пользователем.
Она должна была делать то, что мы думали, но машинный код, похоже, делал совершенно иное.
Открытие
Мы с Джошем запускали эмуляторы Sorcerer в MAME (спасибо Джошу за то, что поделился ROM), а у Криса был ещё один написанный на Java эмулятор, который ему удалось заставить работать.
В MAME мы вводили оператор REM и изучали память, получая показанные выше бессмысленные байты и машинный код. И выполнялся код неправильно.
Однако Крис нашёл образ игры с ленты и загрузил в свой эмулятор. Первые две строки исходного кода выглядели так:
10 REM"_(C2SLFF4F4F4 15 REM ED 5F 28 FC 32 FF F7 C9 (in O1DA)
ОГО! Другой (?) автор аннотировал исходники машинным шестнадцатеричным кодом! Он не только заканчивается на 0xC9, то есть RET Z80, но и содержит адрес 0xF7FF правого нижнего угла экрана!
Как вообще текст REM отображается в этот код? Очевидно, что символы наподобие 0xED не относятся к 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
В конце мы видим завершающий байт 0x00. Скрытый пробел в конце строки. 237 — это 0xED, а 95 — это 0x5F... что соответствует комментарию в строке 15!
Поехали дизассемблировать! Для начала преобразуем числа в шестнадцатеричный формат.
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 space null
Позже я узнал, что часть после RET дизассемблировалась некорректно и что моя аннотация того, каким шестнадцатеричным значениям соответствуют символы REM, была ошибочной, но кого это волнует! Весь код всё равно находится перед RET.
Именно этот код мы и искали! Давайте рассмотрим первые ассемблерные команды:
LD A,R ; копируем регистр R в накопитель JR Z,-4 ; если результат равен 0, возвращаемся к предыдущей команде LD (F7FF),A ; копируем накопитель в адрес F7FFh RET ; возврат
Что делает этот код? Регистр R Z80 интересен тем, что при каждом получении команды выполняется его инкремент. Но это не точно, мнения разных источников отличаются. Как бы то ни было, по человеческим меркам его инкремент выполняется крайне часто. А Sorcerer ожидает в холостом цикле ввод, поэтому когда пользователь наконец запустит игру, в регистре, по сути, будет находиться достаточно случайное значение.
Однако использование нуля в качестве порождающего значения слабых PRNG было бы плохой идеей (обычно они при этом просто генерируют 0), поэтому если в R оказывается 0, код выполняет повторную попытку. Если же там ненулевое значение, он сохраняет его в адрес 0xF7FF, то есть в правый нижний угол экрана! А затем его берёт PEEK() и использует в качестве порождающего значения PRNG!
Однако регистр R выполняет инкремент только для семи младших бит, то есть может применяться только 128 уникальных значений. А если отбросить 0, то на Sorcerer останется всего 127 уникальных случайно генерируемых подземелий. Плохо!
То есть видимые нами глифы просто не являются ASCII. Наш первый дизассемблированный код был обречён, потому что REM нарушает допущение о том, что весь тест листинга хранится в ASCII.
Для проверки этого я написал новую программу, состоящую просто из REM с кучей пробелов (чтобы осталось место до нулевого завершающего байта). Затем я сохранил при помощи POKE нужные значения и посмотрел листинг.
10 REM POKE 474,237 READY POKE 475,95 READY POKE 476,40 READY LIST 10 REM"_( READY
Сработало! Эти значения дали мне глифы "_(, присутствующие в оригинальном листинге исходников!
Можно ли ввести их с клавиатуры?
Весь смысл журналов 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? Что-то тут не так. Выше я пробовал выполнить проверку, вручную запуская POKE в первых трёх символах, но давайте теперь проверим остальные.
Я воссоздам весь машинный код, чтобы посмотреть, что получится.
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 сместились вправо на два символа! Что происходит? Нельзя просто вот так вставлять символы. Как будто в выводе появилось два лишних символа. Я запросил функцией POKE восемь значений, но перед символами 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). Так почему же мы видим их в листинге?
Давайте добавим конкретики. Я вручную подставлю в REM 255, 247 и 201, а потом посмотрим, что из этого получится.
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
Почему они добавились, хотя не влияют на машинный код? Наверно, нам придётся смириться с этим, как с нерешённой загадкой прошлого. Или попробовать вглядеться в хрустальный шар.
Подведём итог
Смысл всего этого заключался лишь в том, чтобы потешить любопытство хакера, ведь мы, по сути, изначально знали, что этот код нужен для получения порождающего значения PRNG. Можем себе позволить!
Чему мы научились?
На компьютере Exidy Sorcerer можно запихнуть машинный код в
REM, но не стоит ожидать логичного листинга.Скорее всего, введённый из журнала код не работал.
Вероятно, автор напрямую выполнял
POKEмашинного кода или у Sorcerer имелась какая-нибудь клавиша Shift, позволявшая вводить эти символы.Теперь мы знаем ответ на древний вопрос: «Что это за комментарий в начале кода The Wizard's Castle?»
Сколько мы на этом заработали?
Ноль рублей!
Любопытно, будет ли это работать на других системах, или они сойдут с ума? Если можете, попробуйте повторить это на Commodore 64 или другой машине.
Дополнение: судя по комментариям разных людей на Hacker News, да, так совершенно точно делали на различных платформах.
Если вы хотите узнать больше о The Wizard's Castle или даже поиграть в этот артефакт, то на GitHub у меня есть сборник документов и информации.
Бонус: дополнительная информация
Пользователь nneonneo на Hacker News поделился полезной информацией, которую я с его разрешения процитирую.
Хм, судя по заметкам (рукописным!) о Table F-2 в http://bitsavers.informatik.uni-stuttgart.de/pdf/exidy/DP5002_A_Short_Tour_Of_BASIC_Jul78.pdf, похоже, нажатие Graphic+клавиша позволяло вводить токены BASIC с 0x80 по 0xBF, а нажатие Graphic+Shift+клавиша позволяло получить доступ к токенам с 0xC0 по 0xC6. Можно предположить, что Graphic+Shift+клавиша должны обеспечить доступ ко всему пространству с 0xC0 по 0xFF, но большинство этих клавиш не задокументировано.
Исходя из этого, я подумал, что стоит попробовать следующее:
10 REM [Graphic+Shift+=] [_] [(] [Graphic+Shift+NumpadPlus] [2] [Graphic+Shift+NumpadEquals] [Graphic+Shift+Numpad6] [Graphic+Shift+0]
Стоит отметить, что для ввода вам, вероятно, понадобится эмулятор с точной эмуляцией клавиатуры. Однако при помощи эмулятора http://www.liaquay.co.uk/sorcerer мне удалось убедиться, что Graphic+Shift+0 позволяет ввести 201 (рендерится как F4), а Graphic+Shift+= позволяет ввести 255 (рендерится как S), поэтому я считаю такой подход рабочим.
Бонус: вот, как рендерятся все токены от 0x80 и выше, включая повреждённые:
128 0x80 b'END' 129 0x81 b'FOR' 130 0x82 b'NEXT' 131 0x83 b'DATA' 132 0x84 b'BYE' 133 0x85 b'INPUT' 134 0x86 b'DIM' 135 0x87 b'READ' 136 0x88 b'LET' 137 0x89 b'GOTO' 138 0x8a b'RUN' 139 0x8b b'IF' 140 0x8c b'RESTORE' 141 0x8d b'GOSUB' 142 0x8e b'RETURN' 143 0x8f b'REM' 144 0x90 b'STOP' 145 0x91 b'OUT' 146 0x92 b'ON' 147 0x93 b'NULL' 148 0x94 b'WAIT' 149 0x95 b'DEF' 150 0x96 b'POKE' 151 0x97 b'PRINT' 152 0x98 b'CONT' 153 0x99 b'LIST' 154 0x9a b'CLEAR' 155 0x9b b'CLOAD' 156 0x9c b'CSAVE' 157 0x9d b'NEW' 158 0x9e b'TAB(' 159 0x9f b'TO' 160 0xa0 b'FN' 161 0xa1 b'SPC(' 162 0xa2 b'THEN' 163 0xa3 b'NOT' 164 0xa4 b'STEP' 165 0xa5 b'+' 166 0xa6 b'-' 167 0xa7 b'*' 168 0xa8 b'/' 169 0xa9 b'^' 170 0xaa b'AND' 171 0xab b'OR' 172 0xac b'>' 173 0xad b'=' 174 0xae b'<' 175 0xaf b'SGN' 176 0xb0 b'INT' 177 0xb1 b'ABS' 178 0xb2 b'USR' 179 0xb3 b'FRE' 180 0xb4 b'INP' 181 0xb5 b'POS' 182 0xb6 b'SQR' 183 0xb7 b'RND' 184 0xb8 b'LOG' 185 0xb9 b'EXP' 186 0xba b'COS' 187 0xbb b'SIN' 188 0xbc b'TAN' 189 0xbd b'ATN' 190 0xbe b'PEEK' 191 0xbf b'LEN' 192 0xc0 b'STR$' 193 0xc1 b'VAL' 194 0xc2 b'ASC' 195 0xc3 b'CHR$' 196 0xc4 b'LEFT$' 197 0xc5 b'RIGHT$' 198 0xc6 b'MID$' 199 0xc7 b'\x00\t' 200 0xc8 b'G.' 201 0xc9 b'F4' 202 0xca b'K' 203 0xcb b'5' 204 0xcc b'H\x03' 205 0xcd b'`C' 206 0xce b'JO' 207 0xcf b'Mr' 208 0xd0 b'J' 209 0xd1 b'L' 210 0xd2 b'Hr' 211 0xd3 b'HU' 212 0xd4 b'HD' 213 0xd5 b'I' 214 0xd6 b']' 215 0xd7 b'Fa' 216 0xd8 b'H' 217 0xd9 b'\x10' 218 0xda b'H' 219 0xdb b'7' 220 0xdc b'H\x07' 221 0xdd b'GV' 222 0xde b'R&' 223 0xdf b'IH' 224 0xe0 b'G\\' 225 0xe1 b'R\x1f' 226 0xe2 b'O\x05' 227 0xe3 b'Wh' 228 0xe4 b'I5' 229 0xe5 b'G' 230 0xe6 b'F' 231 0xe7 b'E\x0f' 232 0xe8 b'HA' 233 0xe9 b'S' 234 0xea b'I' 235 0xeb b'R\x1a' 236 0xec b'Dy' 237 0xed b'"' 238 0xee b'Wy' 239 0xef b'*' 240 0xf0 b'S|' 241 0xf1 b'j' 242 0xf2 b'T|K' 243 0xf3 b'U\x7f' 244 0xf4 b'C' 245 0xf5 b'XP' 246 0xf6 b'(' 247 0xf7 b'LF' 248 0xf8 b"'" 249 0xf9 b'LNFSNRGODFCOVOMULBSDD/0IDTMOSLSSTCNUFMO' 250 0xfa b'Ck' 251 0xfb b'@' 252 0xfc b'C' 253 0xfd b'e' 254 0xfe b'G' 255 0xff b'S\x00'
Этот список получен простым декодированием таблицы токенов, начиная с 0xf6 в BASIC ROM; он соответствует с полученным выводом для 201, 247, 252 и 255, поэтому я думаю, что в целом он верен. Введя 10 REMX; POKE 474, 249; LIST в эмуляторе, я действительно получил 10 REMLNFSNRGODFCOVOMULBSDD/0IDTMOSLSSTCNUFMO, что также подтверждает правильность декодирования.
Вот скрипт декодирования, при помощи которого получена показанная выше таблица:
rom = open('exsb1.dat', 'rb').read() ptr = 0xf6 for i in range(128, 256): out = bytearray([rom[ptr] - 0x80]) while rom[ptr+1] < 0x80: out.append(rom[ptr+1]) ptr += 1 ptr += 1 print(i, hex(i), bytes(out))
Формат упакованных в BASIC ROM токенов довольно прост: для первого байта в каждом токене выполняется OR с 0x80 и токены конкатенируются без каких-либо других разделителей. Токены завершаются, когда следующий байт имеет значение 0x80 (обозначающее начало следующего токена).