Троллейбус из буханки хлеба везёт тетрамино в стакан
Троллейбус из буханки хлеба везёт тетрамино в стакан

Есть старый мем: а что, если сделать троллейбус из буханки хлеба? Можно, но зачем. В исходнике того самого Тетриса, который Алексей Пажитнов написал для Электроники-60 в ВЦ Академии наук, есть процедура ровно такого устройства. Игра тратит 958 байт кода на то, чтобы при каждом запуске заново вычислить 342 байта данных: семь фигур, их девятнадцать положений и правила поворота. Готовую таблицу можно было положить в программу и не вычислять ничего.

Код игры восстановлен: он компилируется байт в байт в тот же файл, что снят с ленты. Все 590 строк можно открыть и читать.

Фигура в Тетрисе это тетрамино: четыре клетки, склеенные сторонами. Всего их семь, а называть принято латинскими буквами, похожими по очертанию: I, O, T, S, Z, J, L. Давайте посмотрим, как игра строит эти семь фигур и их производные положения.

Фигура: четыре смещения

Сегодня тетрамино описали бы битовой картой четыре на четыре. В этой игре фигура устроена иначе:

TYPE
  VEC4 = ARRAY[1..4] OF INTEGER;
  SHP  = RECORD
           DX, DY : VEC4
         END;

Запись SHP хранит четыре пары смещений от точки отсчёта. У буквы T это DX = (-1, 0, 1, 0) и DY = (0, 0, 0, 1): три клетки в ряд и одна под центральной. Пустых клеток в такой записи нет вообще, хранятся только четыре занятые.

Фигура и её таблица смещений: клетке k соответствует пара DX[k], DY[k]
Фигура и её таблица смещений: клетке k соответствует пара DX[k], DY[k]

Длина массива не просто так константа. Любое тетрамино состоит ровно из четырёх клеток, поэтому VEC4 объявлен как ARRAY[1…4], и каждая фигура занимает восемь слов, шестнадцать байт. Лежат они в общем массиве SHAPE : ARRAY[1…19] OF SHP. Почему записей девятнадцать, а не семь, выяснится через два раздела.

Точка в стакане

Смысл смещений раскрывается вместе со стаканом. Поле хранится в двумерном массиве:

WELL : ARRAY[0..11] OF ARRAY[0..21] OF CHAR;

Игровых колонок десять, строк двадцать, а массив на две больше в каждом измерении: крайние колонки и строки заполнены часовыми CHR(1) и изображают стенки и дно.

Падающая фигура в стакане это одна точка. Текущая позиция XPOS, YPOS плюс указатель CUR на одну из записей SHAPE. Клетки достраиваются к точке: движок четыре раза прибавляет к ней очередную пару смещений и получает координату клетки. Так фигура рисуется на экране, и так же она вписывается в стакан при посадке:

WITH CUR^ DO
  FOR I := 1 TO 4 DO
    WELL[XPOS+DX[I], YPOS+DY[I]] := CHR(1);

Сдвинуть фигуру влево значит уменьшить XPOS на единицу. Четыре клетки при этом не трогаются, они пересчитаются из новой точки при следующей отрисовке.

Перед каждым сдвигом та же арифметика отвечает, поместится ли фигура на новом месте:

FUNCTION CANFIT(X, Y : INTEGER) : BOOLEAN;
VAR K : INTEGER;
BEGIN
  CANFIT := TRUE;
  WITH CUR^ DO
    FOR K := 1 TO 4 DO
      IF WELL[X+DX[K], Y+DY[K]] # CHR(0) THEN BEGIN
        CANFIT := FALSE;
        EXIT
      END
END;

Знак # в этом диалекте означает «не равно». Четыре проверки, и здесь окупаются часовые: выход за край упирается в CHR(1) рамки точно так же, как в осевшую клетку, отдельной проверки границ в игре не существует. EXIT обрывает цикл на первой занятой клетке, оставшиеся даже не читаются.

CANFIT: три шага проходят, на четвёртом третья клетка попадает в стенку
CANFIT: три шага проходят, на четвёртом третья клетка попадает в стенку

Одна заготовка на четыре фигуры

Теперь к рождению фигур. Таблицы в исходнике нет, вместо неё процедура SETUP, которая отрабатывает при каждом запуске, ещё до заставки. Четыре фигуры она собирает из одной заготовки:

FOR J := 1 TO 4 DO
  WITH SHAPE[J+3] DO BEGIN
    FOR I := 1 TO 3 DO BEGIN
      DY[I] := 0;
      DX[I] := I-2          { тройка в ряд }
    END;
    DY[4] := 1;
    DX[4] := J-3            { четвёртая клетка едет вдоль ряда }
  END;
SHAPE[4].DY[4] := 0;

Три клетки в ряд неподвижны, четвёртая проходит под ними четыре позиции. Последние три дают L, T и J. А первая даёт фигуру, которой в игре нет: при J=1 четвёртая клетка встаёт по диагонали от тройки и касается её только углом. Это не тетрамино. Следующая строка, SHAPE[4].DY[4] := 0, поднимает клетку в общий ряд, и не-фигура превращается в палку.

Заготовка: клетка проходит четыре позиции, снимки ложатся в SHAPE[4..7], заплата чинит палку
Заготовка: клетка проходит четыре позиции, снимки ложатся в SHAPE[4..7], заплата чинит палку

Вот так одна заготовка, четыре позиции клетки и одна заплата дают I, L, T и J, четыре фигуры из семи. Остались S и Z, и с ними автор поступил ещё короче.

S и Z: две правки T

Оставшиеся две фигуры не строятся вовсе. Они копируются из готовой T, по одной правке на каждую:

SHAPE[2] := SHAPE[6];
SHAPE[2].DY[1] := 1;      { опустить левое плечо: S }
SHAPE[3] := SHAPE[6];
SHAPE[3].DY[3] := 1;      { опустить правое плечо: Z }
T раздваивается: у левой копии падает левое плечо, у правой правое
T раздваивается: у левой копии падает левое плечо, у правой правое

Шесть фигур готовы. Седьмая, квадрат, единственная не выводится не из заготовки, и не из соседей.

Квадрат из арифметики

Квадрат вычисляется прямо из счётчика цикла:

WITH SHAPE[1] DO
  FOR I := 1 TO 4 DO BEGIN
    DY[I] := I DIV 3;
    DX[I] := -(I MOD 2)
  END;

I MOD 2 чередует колонку, I DIV 3 переключает ряд после второй клетки, и четыре шага счётчика обходят блок два на два в порядке чтения. Четыре деления ради четырёх констант: даже здесь автор предпочёл правило перечислению.

Квадрат из счётчика: MOD выбирает колонку, DIV ряд
Квадрат из счётчика: MOD выбирает колонку, DIV ряд

Петля в конце анимации не шутка. NXT[1] := 1 это первое правило поворота в программе: квадрат поворачивается сам в себя. Что такое NXT, сейчас увидим.

В сумме получается личная грамматика семи фигур: квадрат из формулы, четвёрка из заготовки с путешествующей клеткой, S и Z как мутации T. К классификациям тетрамино из учебников она отношения не имеет. Это способ автора держать все семь фигур в голове, не записывая ни одной из них координатами.

Поворот, который ничего не вычисляет

Осталось объяснить девятнадцать. SETUP не останавливается на семи фигурах: каждую он прокручивает через процедуру ROTATE и складывает результаты в тот же массив как самостоятельные записи.

PROCEDURE ROTATE(S : SHP);
VAR M : INTEGER;
BEGIN
  WITH S DO BEGIN
    T.DX := DY;
    FOR M := 1 TO 4 DO
      T.DY[M] := -DX[M]
  END
END;

Поворот на девяносто градусов, (x, y) → (y, -x), вокруг той самой точки отсчёта. L, T и J поворачиваются трижды, S, Z и палка по одному разу, квадрат ни разу. Итого 1 + 3×2 + 3×4 = 19 записей: семь публичных фигур и двенадцать скрытых положений. Генератор случайных фигур выбирает только номера 1…7, это буквально RANDOM(7)+1 в коде; положения 8…19 наружу не выдаются никогда и существуют только как ступени колец поворота.

L поворачивается трижды вокруг точки отсчёта, результаты уезжают в SHAPE[11..13]
L поворачивается трижды вокруг точки отсчёта, результаты уезжают в SHAPE[11..13]

Кольца строит параллельный массив NXT: для каждого положения в нём лежит номер следующего.

O:  1 -> 1
S:  2 <-> 8      Z:  3 <-> 9      I:  4 <-> 10
L:  5 -> 11 -> 12 -> 13 -> 5
T:  6 -> 14 -> 15 -> 16 -> 6
J:  7 -> 17 -> 18 -> 19 -> 7

В рантайме от поворота остаётся переход по кольцу:

OLD := CUR;
CUR := @SHAPE[NXT[CURPC]];
IF CANFIT(XPOS, YPOS) THEN CURPC := NXT[CURPC]
ELSE CUR := OLD;

Кнопка 8 не вращает ничего. Она берёт соседнюю запись массива и проверяет её всё тем же CANFIT; если новое положение не влезло, указатель возвращается на старую запись, и фигура остаётся как была. Двенадцать поворотов при запуске, ноль поворотов во время игры.

Две детали для любителей археологии. Кольца из двух элементов у S, Z и палки не прихоть: второй поворот даёт ту же фигуру, но сдвинутую на клетку, и кольцо из четырёх записей заставляло бы фигуру уезжать вбок при каждом полном обороте. А замыкаются длинные кольца перезаписью: внутренний цикл сначала честно пишет NXT[19] := 20, ссылку на положение, которого не существует, и только следующий оператор загибает её обратно, NXT[19] := 7. Проверки диапазонов выключены директивой ($T-), и до конца SETUP висящая ссылка никому не мешает.

Почему параметр ROTATE объявлен по значению

В цикле поворотов ROTATE вызывается как ROTATE(T): переменная T здесь одновременно источник и приёмник. Внутри процедуры сначала выполняется T.DX := DY, что затирает половину T, и только потом цикл читает DX. Работает это лишь потому, что S копия аргумента. Объяви автор параметр как VAR, чтение пошло бы по уже затёртой памяти, и все повороты после первого дали бы мусор.

Цена вопроса

Оба варианта я собрал тем же компилятором, которым собран оригинал. Генератор занимает 958 байт кода. Готовая таблица, те же данные плюс цикл копирования, укладывается в 384 байта: 342 байта на 171 слово (19 фигур по 8 слов и 19 переходов NXT) и 42 байта кода. Разница 574 байта, соотношение два с половиной к одному. Для масштаба: весь код игры это 7338 байт, на рождение фигур уходит 13% программы.

Дороже всего обходится кодогенерация. Компилятор разворачивает каждое SHAPE[I*3+7+J].DY[M] в цепочку сложений и сдвигов, WITH заново пересчитывает базу записи, а ROTATE вызывается двенадцать раз и всякий раз копирует восьмисловный аргумент. Чтобы получить 171 слово данных, SETUP выполняет 297 записей в память, не считая копий параметра.

По машинным меркам решение проигрывает вчистую, и это не оптимизация под платформу. Это выбор.

Троллейбус, который поехал

Таблицу можно было и привезти. Структурных констант в этом Паскале нет, зато есть ассемблерные вставки, и автор ими пользовался: в этой же программе их пять, от чтения клавиатуры до цикла задержки. Таблица из .WORD с коротким циклом копирования стоила бы одну вставку.

Дальше эту таблицу нужно было бы ввести. Сначала вычислить на бумаге в клеточку: разложить все девятнадцать положений и выписать 152 смещения со знаками плюс 19 переходов между положениями. Потом набрать числа на клавиатуре терминала и вычитывать глазами. Опечатка в одном знаке молчала бы до тех пор, пока в игре не выпадет кривая фигура, а искать её пришлось бы в этих же колонках цифр, на машине без отладчика.

Пажитнов пошёл от описания. Код не перечисляет фигуры, он шаг за шагом рассказывает, как они устроены: тройка с путешествующей клеткой, заплата, которая превращает брак в палку, T с опущенным плечом вместо отдельных S и Z, одна матрица поворота вместо двенадцати наборов координат. Каждый шаг строится на предыдущем, а данные из этого рассказа машина выводит сама, при каждом запуске. Ошибиться в правиле труднее, чем в числе, и читается правило так, как фигуры держат в голове.

Сорок лет спустя код всё чаще пишет машина, а человеку остаётся описание. Этот исходник устроен так с самого начала. Троллейбус из буханки, который поехал.

Гифка из прошлого поста, для вайба
Гифка из прошлого поста, для вайба

Восстановленный исходник, модули рантайма и цепочка сборки настоящим ПАСКАЛЬ/РАФОС: n0isy/original-tetris-recomp.

Комментарии (21)


  1. CitizenOfDreams
    17.08.2026 04:04

    Сегодня тетромино описали бы битовой картой четыре на четыре.

    Не, сегодня его описали бы джейсоном на четыре килобайта.


    1. Skykharkov
      17.08.2026 04:04

      Та не. Сегодня было бы мегабайт сорок. Node.js, докер обязательно, каждая фигура в своем контейнере, коннекторы к чат-жпт, вебхуки на смену положения\вращение. Три-четыре фреймворка, на всякий случсай... Социальные сети для каждой фигурки... Сорок мег - оценка по нижней планке.


      1. CitizenOfDreams
        17.08.2026 04:04

        И время реакции на нажатие клавиши - от 200 миллисекунд (после того как JVM прогреется) до "Что-то пошло не так".


        1. n0isy Автор
          17.08.2026 04:04

          О. Придётся вспомнить Electron. Чтобы выйти за 1 Гб оперативки /s


          1. Feedman
            17.08.2026 04:04

            можно объявить конкурс на самый большой тетрис. Без искусственного увеличения исполняемого файла, только реализация логики.


            1. NemoVors
              17.08.2026 04:04

              заодно можно генерировать normal map, height map, raytraysing наложить... а сверху еще длсс5 :) . Насчет объема исполняемого файла не уверен, но потребление ресурсов будет достойно ААА индустрии 6)


            1. Moog_Prodigy
              17.08.2026 04:04

              Может сразу обьявим конкурс на самую большую ОС?

              Без искусственного увеличения исполняемого файла, только реализация логики.


    1. AlexeyK77
      17.08.2026 04:04

      я бы предложил другой вариант. Лоакальная LLM тренированная на тетрис. На входе текущая позиция и команда с клавиатуры, LLM в ответ на тетрис-промпт генерирует следующую позицию. Все на агентах естественно.
      Достойно, солидно, вайб-интерпрайз.


      1. Jubilus
        17.08.2026 04:04

        И при каждом сбросе линии отправлять метрики в облако для дообучения модели)


      1. Moog_Prodigy
        17.08.2026 04:04

        Есть решение покруче. Вот есть тетрис, есть его состояния. Генерируем скриптом все возможные пространства состояний. а потом стрелочками туда-сюда двигаем детальку. Зато логика будет упрощена до предела. И рендеринг никакой не нужен, и физика - просто показываем нужную картинку из массива. И эксабайты в ДЦ при деле, а то что простаивают.


    1. Jubilus
      17.08.2026 04:04

      Четыре килобайта это только импорты библиотек для парсинга джейсона. Сами фигуры потянули бы на мегабайт, чтобы с запасом на будущее масштабирование)


  1. ZurgInq
    17.08.2026 04:04

    Дальше эту таблицу нужно было бы ввести. Сначала вычислить на бумаге в клеточку: разложить все девятнадцать положений и выписать 152 смещения со знаками плюс 19 переходов между положениями. Потом набрать числа на клавиатуре терминала и вычитывать глазами. Опечатка в одном знаке молчала бы до тех пор, пока в игре не выпадет кривая фигура, а искать её пришлось бы в этих же колонках цифр, на машине без отладчика.

    Художественное преувеличение. Если бы нужна была такая оптимизация, то 152 числа кодом сразу бы и записывались в исходник, а не вбивались вручную с кучей опечаток.


    1. Jubilus
      17.08.2026 04:04

      Место на диске или ленте тоже было ограничено, исходники старались делать компактными. Написать математическую формулу тупо быстрее и короче, чем набивать простыню данных


    1. n0isy Автор
      17.08.2026 04:04

      Но ведь красивая буханка хлеба получилась ))

      Напомню, что в вбить 152 числа пришлось бы через скучные строки вида:

      SHAPE[2].DY[1] := 1;
      SHAPE[2].DX[1] := 1;


  1. Nick0las
    17.08.2026 04:04

    Програмная генерация тетрамино выглядит как какой-то оверинжениринг. Скорее всего это следы более ранних версий где были пентамино или какие-то еще фигуры.

    Я когда-то писал тетрис сам, причем не один раз и на разных языках. Насколько помню в последней версии фигуры задавались как массив из четырех deltaX и deltaY в константах. Один раз задал 742=56 чисел (на самом деле можно и меньше, потому что точка с координатами 0, 0 всега входит, и больше констант не надо. А вращение было настоящим: deltaY менялась на deltaX и deltaX на - deltaY. Такая схема выглядит самой простой, и если хочется, можно добавить фигур пентамино почти не меняя код. Но в таком варианте все равно была некрасивая часть: программный лок на вращение квадрата. Потому что если не залочить вращение, то оно будет приводить к смещению квадрата на одну клетку вверх/вниз или влево/вправо.


    1. CitizenOfDreams
      17.08.2026 04:04

      Я в детстве Тетрис писал только один раз, на ассемблере Z80. Уже даже не помню, как задавал фигуры и их повороты. Но не битовой картой точно, скорее всего примерно так же, массивом координат смещения для каждой из четырех клеток фигуры.


    1. n0isy Автор
      17.08.2026 04:04

      Другие фигуры, кроме квадрата тоже съезжают, их надо кольцевать раньше. Вот к примеру палка прыгает вправо(2) и вверх(3):

      I: НАИВНЫЙ поворот, четыре нажатия
         .......    .......    .......    ...#...    .......
         .......    ...#...    .......    ...#...    .......
         .####..    ...#...    ..####.    ...#...    .####..
         .......    ...#...    .......    ...#...    .......
         .......    ...#...    .......    .......    .......
          старт        1          2          3          4   
      
      I: ТАБЛИЦА игры, кольцо NXT
         .......    .......    .......    .......    .......
         .......    ...#...    .......    ...#...    .......
         .####..    ...#...    .####..    ...#...    .####..
         .......    ...#...    .......    ...#...    .......
         .......    ...#...    .......    ...#...    .......
          старт        1          2          3          4   
      



  1. MasterMentor
    17.08.2026 04:04

    Процедурная генерация Мира тетриса. В этом есть что-то философское и не дурно.


  1. KivApple
    17.08.2026 04:04

    Да никто не стал бы руками делать сотни комбинаций и перепечатывать. Просто пишешь отдельную программу генератор, которая генерирует комбинации и затем пишет их в виде исходника, который уже встраивается в основную программу.

    Но скорее всего сначала захотелось иметь больше гибкости, а затем всё осталось как есть, потому что ресурсов и так хватает.


  1. Jubilus
    17.08.2026 04:04

    Забавно как ограничения железа рождали такие изящные алгоритмы) Сейчас бы просто закинули все положения в огромный json и не парились, память же резиновая))


    1. Moog_Prodigy
      17.08.2026 04:04

      Сейчас это уже на уровне графики закидывают, да чего сейчас, уже 20 лет так делают местами.