В рамках этого проекта я сгенерировал около 30 миллиардов файлов случайных данных по 4 КБ. Из этих файлов на основании эвристик из полной коллекции файлов ROM Atari было выбрано примерно 10 тысяч. Затем система классификатора просканировала их при помощи эмулятора Atari 2600, чтобы проверить, окажется ли какой-то из этих случайных файлов игрой для Atari. Этот проект отвечает на вопросы, которые никто не задавал, он никому не нужен и представляет собой огромную пустую трату ресурсов. Что, если засунуть в GPU миллиард обезьян и заставить их написать игру для Atari 2600?

Благодаря прогрессу GPU, ИИ и машинного обучения сегодня мы можем (очень быстро) написать на Python скрипт, который дампит мусор в ROM по 4 КБ и спрашивает: «похоже ли это на игру?». Проект был создан не из ностальгии, моей первой консолью была NES. Я вознамерился исследовать нечто невообразимо обширное и посмотреть, найдётся ли там что-нибудь странное.

Сначала результаты

Никто не читает дальше первого скролла, поэтому сразу дам ссылку на интерактивный эмулятор, демонстрирующий самые любопытные из обнаруженных ROM Atari. Здесь не использовался генетический алгоритм, просто миллиарды случайных файлов, пропущенные через эмулятор.

https://bbenchoff.github.io/assets/pages/finiteatarirunner.html

Предупреждение: уменьшите громкость, в EmulatorJS есть баг.

Масштабы задачи

Каждый картридж Atari 2600 — это блок данных на 4 килобайта. Максимум в нём 4096 байтов, или 32768 бит. Это значит, что существует 232768 возможных ROM. Для сравнения:

  • Это 1010159 потенциальных игр для Atari.

  • На Земле примерно 1020 песчинок.

  • В наблюдаемой Вселенной приблизительно 1080 протонов.

Если бы мы использовали для решения этой задачи целый ИИ-датацентр, то, вероятно, для нахождения чего-то интересного понадобились бы годы. Но если подойти к делу с умом и почитать спецификацию, то масштабы задачи сильно сократятся. Простой способ поиска «случайной» игры для Atari был бы таким:

  1. Генерируем ROM, сдампив 4 КБ данных из /dev/random в файл.

  2. Запускаем этот файл в эмуляторе.

  3. Делаем один-пять скриншотов.

  4. Фильтруем или оцениваем их при помощи ИИ.

  5. Сохраняем лучшие результаты для более пристального изучения.

Это бы сработало, если бы у нас было достаточно времени, чтобы дождаться, пока чёрные дыры не поглотят Вселенную. Но можно поступить умнее — выполнять на ранних этапах конвейера простые проверки для отбрасывания полного мусора ещё до момента запуска эмулятора. Для создания эвристик нам полезно будет узнать, как выглядит реальный ROM Atari.

Эвристики

Я не собираюсь эмулировать каждый возможный ROM, а лишь пытаюсь найти интересные. Это значит, что мы будем использовать фильтрацию и подходить к работе с умом:

Допустимые опкоды: процессор 6507 (дальше я буду называть его 6502, чтобы побесить вас) имеет 151 валидный опкод, и эти опкоды должны находиться во всей первой половине ROM. Сначала я буду проверять, есть ли в данных много опкодов. Вот какие опкоды нам нужны:

0x00, 0x01, 0x05, 0x06, 0x08, 0x09, 0x0A, 0x0D, 0x0E, 0x10, 0x11, 0x15, 0x16, 0x18, 0x19, 0x1D, 0x1E, 0x20, 0x21, 0x24, 0x25, 0x26, 0x28, 0x29, 0x2A, 0x2C, 0x2D, 0x2E, 0x30, 0x31, 0x35, 0x36, 0x38, 0x39, 0x3D, 0x3E, 0x40, 0x41, 0x45, 0x46, 0x48, 0x49, 0x4A, 0x4C, 0x4D, 0x4E, 0x50, 0x51, 0x55, 0x56, 0x58, 0x59, 0x5D, 0x5E, 0x60, 0x61, 0x65, 0x66, 0x68, 0x69, 0x6A, 0x6C, 0x6D, 0x6E, 0x70, 0x71, 0x75, 0x76, 0x78, 0x79, 0x7D, 0x7E, 0x81, 0x84, 0x85, 0x86, 0x88, 0x8A, 0x8C, 0x8D, 0x8E, 0x90, 0x91, 0x94, 0x95, 0x96, 0x98, 0x99, 0x9A, 0x9D, 0xA0, 0xA1, 0xA2, 0xA4, 0xA5, 0xA6, 0xA8, 0xA9, 0xAA, 0xAC, 0xAD, 0xAE, 0xB0, 0xB1, 0xB4, 0xB5, 0xB6, 0xB8, 0xB9, 0xBA, 0xBC, 0xBD, 0xBE, 0xC0, 0xC1, 0xC4, 0xC5, 0xC6, 0xC8, 0xC9, 0xCA, 0xCC, 0xCD, 0xCE, 0xD0, 0xD1, 0xD5, 0xD6, 0xD8, 0xD9, 0xDD, 0xDE, 0xE0, 0xE1, 0xE4, 0xE5, 0xE6, 0xE8, 0xE9, 0xEA, 0xEC, 0xED, 0xEE, 0xF0, 0xF1, 0xF5, 0xF6, 0xF8, 0xF9, 0xFD, 0xFE

Случайные данные имеют вероятность 59% быть валидным опкодом (151 из 256 возможных байтов). В реальных играх ситуация должна быть гораздо лучше. В структуре ROM Atari первый килобайт — это исполняемая часть, а всё после него — это данные и графика. Проанализировав всё множество коммерческих ROM Atari, я выяснил, что больше 75% первого килобайта должно состоять из опкодов.

Дурачества с вектором сброса: в ROM Atari должен присутствовать валидный вектор сброса на точку входа кода. Иными словами, последние два байта кода должны находиться в интервале от 0xF000 до 0xFFFF. Я могу сжульничать, сгенерировав ROM на 4 КБ минус два байта, а затем попробовать каждый возможный вектор сброса при эмуляции. Для этого понадобится всего в 4096 раз больше работы.

Ввод и вывод?! Можно поискать доступ к TIA (Television Interface Adapter), чтобы проверить, выполняется ли вывод на экран, посмотреть на RIOT (RAM-I/O-Timer), чтобы проверить, использует ли он ввод и вывод. TIA занимается всей графикой и звуком, и делает это в чрезвычайно специфичных паттернах, которые обнаруживаются при изучении паттернов во всех реальных играх для Atari. При анализе паттернов выяснилось, что:

  • 90% из них используют адресацию в режиме нулевой страницы (STA $02STX $06STY $00).

  • 80% из них — это команды STA, 10% — STX, 10% — STY.

  • Часто применяется индексная адресация (STA $00,XSTY $10,X).

  • Преобладает WSYNC ($02) — 18,8% от всех операций доступа к TIA (игры постоянно синхронизируются с телевизором).

Наиболее критичные регистры TIA, используемые играми:

$02 (WSYNC) - 18,8% операций доступа - синхронизация с телевизором
$1B (GRP0) - 8,4% операций доступа - графика игрока 0
$1C (GRP1) - 7,0% операций доступа - графика игрока 1
$2A (HMOVE) - 4,9% операций доступа - горизонтальное движение
$0E/$0F (PF1/PF2) - суммарно 7,8% - графика игрового поля

Вместо того чтобы вслепую подсчитывать все операции записи в TIA в интервале $00-$2F, я искал конкретные паттерны команд, используемые в реальных играх.

Из-за зеркалирования памяти с регистрами RIOT разбираться сложнее. В Atari 2600 используется неполное декодирование адресов, из-за чего одно и то же оборудование находится по нескольким адресам. Чип RIOT содержит:

  • Регистры таймера: их канонические адреса $0280-$0287, но из-за зеркалирования они также могут встречаться в $80-$87, $180-$187, $380-$387 и так далее.

  • Регистры ввода-вывода: их канонические адреса $0294-$0297, но также они зеркалятся на $94-$97, $194-$197 и так далее.

В реальных файлах ROM программисты использовали короткие отзеркаленные адреса, потому что они эффективнее. Типичная команда наподобие STA $80 (установка таймера) встречается в ROM в виде 85 80 ; STA $80 (адресация в режиме нулевой страницы), а не в более затратном виде 8D 80 02 ; STA $0280 (абсолютная адресация). Моя эвристика ищет такие места.

Ветвления и переходы. Мы ищем игры, а в каждой игре есть циклы и структура. Они проявляются в виде команд ветвления (в случае циклов и условий) и команд переходов (для подпрограмм и общего потока управления). В коде они встречаются в виде обратного ветвления (циклов) и ветвления вперёд (условных операторов).

  • Опкоды ветвления: 0x10, 0x30, 0x50, 0x70, 0x90, 0xB0, 0xD0, 0xF0.

  • Опкоды безусловных переходов: 0x4C (JMP абсолютный), 0x6C (JMP косвенный), 0x20 (JSR — переход к подпрограмме).

На основе всего этого мы можем создать эвристики для проверки того, что может считаться «игрой».

Калибровка относительно реальности

Для валидации этих эвристик я проанализировал полную коллекцию ROM консоли Atari 2600 из Internet Archive — все 1530 продававшихся ROM для Atari. Скрипт на Python проанализировал каждый ROM для подсчёта частотности этих эвристик в коммерческих играх.

Характеристики ROM по метрикам

Вот как выглядят реальные игры для Atari:

Метрика

Min

5-й %

10-й %

25-й %

Медиана

75-й %

90-й %

95-й %

Max

Среднее

Валидные опкоды (%)

42.1%

65.6%

70.0%

74.0%

76.0%

77.9%

79.6%

81.4%

90.7%

74.8%

Операции доступа к TIA

12

93

118

186

282

398

567

743

2,847

341

Операции доступа к RIOT

3

34

47

72

111

158

219

287

891

134

Доступ к таймеру RIOT

1

22

31

51

78

115

161

211

723

95

Доступ к вводу-выводу RIOT

0

8

12

18

28

41

58

74

201

33

Команды ветвления

28

177

200

296

364

528

789

1,066

5,928

457

Команды переходов

3

37

54

76

111

172

260

351

1,495

142

Уникальные опкоды

29

125

129

137

143

148

151

151

151

141

Распределение команд

  • STA (Store A): 71,8% — запись графических данных, цветов, позиций.

  • STX (Store X): 9,3% — часто используется для индексированных операций.

  • STY (Store Y): 8,5% — аналогично паттернам STX.

  • LDA (Load A): 6,7% — игры выполняют и чтение из TIA (распознавание коллизий и так далее).

  • Прочее: 3,7% — индексная адресация, абсолютный режим.

Режимы адресации

  • Нулевая страница: 82,1% — STA $02STX $1B (самые быстрые и часто используемые).

  • Индексация по нулевой странице: 13,2% — STA $00,XSTY $10,X (размещение спрайтов).

  • Абсолютная: 4,7% — STA $001B (редко, но используется).

Регистры TIA, доступ к которым выполняется чаще всего

$02 (WSYNC) - 18,8% - горизонтальная синхронизация телевизора (важнейший тайминг)
$1B (GRP0) - 8,4% - данные графики игрока 0
$1C (GRP1) - 7,0% - данные графики игрока 1
$2A (HMOVE) - 4,9% - горизонтальный строб-импульс
$0E (PF1) - 4,1% - регистр 1 графики игрового поля
$0F (PF2) - 3,7% - регистр 2 графики игрового поля

Анализ паттернов RIOT

Работу с RIOT можно чётко разбить на две категории:

Операции с таймером (78% всех операций с RIOT)

  • Регистры$80-$87 (интервалы 1T, 8T, 64T, 1024T плюс чтение таймера).

  • Предназначение: циклы таймингов, задержки, подсчёт кадров.

  • Паттерн: запись для установки таймера, чтение $84 для проверки статуса.

Операции ввода-вывода (22% всех операций с RIOT)

  • Регистры$94-$97 (ввод с джойстика/контроллера, переключатели консоли).

  • Предназначение: чтение ввода игрока, распознавание сброса игры.

  • Паттерн: в основном чтение (LDA $94), время от времени запись для настройки.

Составная оценка

В составной оценке используются реалистичные веса, созданные на основе анализа реальных игр:

Оценка = (Количество опкодов × 0.25) +
(min(Доступы_TIA/150, 1.0) × 0.30) +
(min(Доступы_RIOT/50, 1.0) × 0.20) +
(min(Ветвления/200, 1.0) × 0.15) +
(min(Переходы/40, 1.0) × 0.10)

Оценки реальных игр находятся в диапазоне от 0,393 до 1,004, а среднее значение равно 0,853. Эта составная оценка помогает ранжировать похожесть каждого ROM на игру на основании нескольких характеристик, а не одной метрики. Как наиболее важным показателям веса отдают приоритет опкодам и графическим операциям (TIA), а поток управления и ввод-вывод остаются вторичными факторами.

Майним игры для Atari

Первая реализация проекта была крайне простой — однопоточный скрипт на Python, который генерировал 4 КБ минус два байта случайных данных, подсчитывал количество ветвлений, переходов, количество валидных опкодов, обратных ветвлений (или циклов), а также количество векторов, указывающих на ROM. Он был очень медленным, за секунду проверялось примерно 300-400 ROM.

Однако это сильно параллелизуемая задача. В моём GTX 1070 есть 1920 ядер CUDA (я знаю это, хотя в основном играю в TF2, Rocket League и Kerbal Space Program; nvidia, пожалуйста, подарите мне плату H200 + SXM5 PCIe), а в моём CPU ядер всего 20. Ещё более важно то, что каждое ядро CUDA может независимо от других одновременно генерировать и анализировать ROM. Вместо того, чтобы генерировать ROM последовательно и передавать их через конвейер, я могу генерировать параллельно миллион ROM, одновременно их анализировать и передавать в CPU только перспективные кандидатуры.

Реализация на CUDA перемещает всю эвристику непосредственно в GPU. Каждый поток при помощи генератора случайных чисел CUDA генерирует один ROM на 4 КБ, а затем сразу же применяет тот же конвейер аналитики: подсчитывает валидные опкоды, распознаёт операции доступа к регистрам TIA/RIOT, находит паттерны ветвления и вычисляет составную оценку. Код был написан при помощи библиотеки CuPy:

Python
"""
Генератор ROM Atari на CUDA
"""

import cupy as cp
import numpy as np
import time
from pathlib import Path

# Константы
ROM_SIZE = 4094  # Вектор сброса тестируется позже
BATCH_SIZE = 1024 * 256

# Пороговые значения, основанные на выявленных паттернах
OPCODE_THRESHOLD = 0.58
TIA_THRESHOLD = 50
RIOT_THRESHOLD = 13
BRANCH_THRESHOLD = 150
JUMP_THRESHOLD = 37
INSTRUCTION_VARIETY = 100
MIN_SCORE = 0.52

# Валидные опкоды 6502 (всего 151)
VALID_OPCODES = np.array([
    0x00, 0x01, 0x05, 0x06, 0x08, 0x09, 0x0A, 0x0D, 0x0E, 0x10, 0x11, 0x15, 0x16, 0x18,
    0x19, 0x1D, 0x1E, 0x20, 0x21, 0x24, 0x25, 0x26, 0x28, 0x29, 0x2A, 0x2C, 0x2D, 0x2E,
    0x30, 0x31, 0x35, 0x36, 0x38, 0x39, 0x3D, 0x3E, 0x40, 0x41, 0x45, 0x46, 0x48, 0x49,
    0x4A, 0x4C, 0x4D, 0x4E, 0x50, 0x51, 0x55, 0x56, 0x58, 0x59, 0x5D, 0x5E, 0x60, 0x61,
    0x65, 0x66, 0x68, 0x69, 0x6A, 0x6C, 0x6D, 0x6E, 0x70, 0x71, 0x75, 0x76, 0x78, 0x79,
    0x7D, 0x7E, 0x81, 0x84, 0x85, 0x86, 0x88, 0x8A, 0x8C, 0x8D, 0x8E, 0x90, 0x91, 0x94,
    0x95, 0x96, 0x98, 0x99, 0x9A, 0x9D, 0xA0, 0xA1, 0xA2, 0xA4, 0xA5, 0xA6, 0xA8, 0xA9,
    0xAA, 0xAC, 0xAD, 0xAE, 0xB0, 0xB1, 0xB4, 0xB5, 0xB6, 0xB8, 0xB9, 0xBA, 0xBC, 0xBD,
    0xBE, 0xC0, 0xC1, 0xC4, 0xC5, 0xC6, 0xC8, 0xC9, 0xCA, 0xCC, 0xCD, 0xCE, 0xD0, 0xD1,
    0xD5, 0xD6, 0xD8, 0xD9, 0xDD, 0xDE, 0xE0, 0xE1, 0xE4, 0xE5, 0xE6, 0xE8, 0xE9, 0xEA,
    0xEC, 0xED, 0xEE, 0xF0, 0xF1, 0xF5, 0xF6, 0xF8, 0xF9, 0xFD, 0xFE
], dtype=np.uint8)

# Опкоды потока управления
BRANCH_OPCODES = np.array([0x10, 0x30, 0x50, 0x70, 0x90, 0xB0, 0xD0, 0xF0], dtype=np.uint8)
JUMP_OPCODES = np.array([0x4C, 0x6C, 0x20], dtype=np.uint8)

def create_lookup_tables():
    """Создаём таблицы поиска GPU для анализа ROM"""
    valid_lut = cp.zeros(256, dtype=cp.bool_)
    valid_lut[VALID_OPCODES] = True
    
    branch_lut = cp.zeros(256, dtype=cp.bool_)
    branch_lut[BRANCH_OPCODES] = True
    
    jump_lut = cp.zeros(256, dtype=cp.bool_)
    jump_lut[JUMP_OPCODES] = True
    
    # Поиск команд TIA
    tia_store_lut = cp.zeros(256, dtype=cp.bool_)
    tia_store_lut[[0x85, 0x86, 0x84, 0x95, 0x96, 0x94]] = True
    
    tia_load_lut = cp.zeros(256, dtype=cp.bool_)
    tia_load_lut[[0xA5, 0xA6, 0xA4, 0xB5, 0xB6, 0xB4]] = True
    
    tia_abs_lut = cp.zeros(256, dtype=cp.bool_)
    tia_abs_lut[[0x8D, 0x8E, 0x8C, 0xAD, 0xAE, 0xAC]] = True
    
    # Поиск команд RIOT
    riot_access_lut = cp.zeros(256, dtype=cp.bool_)
    riot_access_lut[[0x85, 0x86, 0x84, 0xA5, 0xA6, 0xA4]] = True
    
    # Маски диапазонов адресов
    tia_range_mask = cp.arange(256, dtype=cp.uint8) <= 0x2F
    riot_timer_mask = (cp.arange(256, dtype=cp.uint8) >= 0x80) & (cp.arange(256, dtype=cp.uint8) <= 0x87)
    riot_io_mask = (cp.arange(256, dtype=cp.uint8) >= 0x94) & (cp.arange(256, dtype=cp.uint8) <= 0x97)
    
    return {
        'valid': valid_lut,
        'branch': branch_lut,
        'jump': jump_lut,
        'tia_store': tia_store_lut,
        'tia_load': tia_load_lut,
        'tia_abs': tia_abs_lut,
        'riot_access': riot_access_lut,
        'tia_range': tia_range_mask,
        'riot_timer': riot_timer_mask,
        'riot_io': riot_io_mask
    }

def analyze_roms(roms, lut):
    """Анализируем ROM на наличие похожих на игры паттернов"""
    batch_size = roms.shape[0]
    
    # Анализ опкодов
    valid_opcodes_count = cp.sum(lut['valid'][roms], axis=1)
    opcode_ratio = valid_opcodes_count.astype(cp.float32) / ROM_SIZE
    
    # Анализ потока выполнения
    branch_count = cp.sum(lut['branch'][roms], axis=1)
    jump_count = cp.sum(lut['jump'][roms], axis=1)
    
    # Анализ TIA
    tia_accesses = cp.zeros(batch_size, dtype=cp.int32)
    
    # Адресация в режиме нулевой страницы
    tia_store_zp = lut['tia_store'][roms[:, :-1]] & lut['tia_range'][roms[:, 1:]]
    tia_load_zp = lut['tia_load'][roms[:, :-1]] & lut['tia_range'][roms[:, 1:]]
    tia_zp_total = cp.sum(tia_store_zp | tia_load_zp, axis=1)
    tia_accesses += tia_zp_total
    
    # Абсолютная адресация
    tia_abs_positions = lut['tia_abs'][roms[:, :-2]]
    tia_abs_targets = lut['tia_range'][roms[:, 1:-1]] & (roms[:, 2:] == 0x00)
    tia_abs_total = cp.sum(tia_abs_positions & tia_abs_targets, axis=1)
    tia_accesses += tia_abs_total
    
    # Анализ RIOT
    riot_accesses = cp.zeros(batch_size, dtype=cp.int32)
    
    # Доступ к таймеру
    riot_timer_positions = lut['riot_access'][roms[:, :-1]]
    riot_timer_targets = lut['riot_timer'][roms[:, 1:]]
    riot_timer_hits = cp.sum(riot_timer_positions & riot_timer_targets, axis=1)
    riot_accesses += riot_timer_hits
    
    # Доступ к вводу-выводу
    riot_io_positions = lut['riot_access'][roms[:, :-1]]
    riot_io_targets = lut['riot_io'][roms[:, 1:]]
    riot_io_hits = cp.sum(riot_io_positions & riot_io_targets, axis=1)
    riot_accesses += riot_io_hits
    
    # Подсчёт уникальных опкодов в первом 1 КБ (разделе кода)
    unique_opcodes = cp.zeros(batch_size, dtype=cp.int32)
    first_kb = roms[:, :1024]  # Первый 1 КБ, где обычно находится код
    
    # Подсчёт уникальных опкодов в разделе кода
    for opcode in VALID_OPCODES:
        has_opcode = cp.any(first_kb == opcode, axis=1)
        unique_opcodes += has_opcode.astype(cp.int32)
    
    # Составная оценка
    scores = (
        opcode_ratio * 0.25 + 
        cp.minimum(tia_accesses / 150.0, 1.0) * 0.30 +
        cp.minimum(riot_accesses / 50.0, 1.0) * 0.20 +
        cp.minimum(branch_count / 200.0, 1.0) * 0.15 +
        cp.minimum(jump_count / 40.0, 1.0) * 0.10
    )
    
    # Распознавание перспективных ROM
    promising = (
        (opcode_ratio >= OPCODE_THRESHOLD) &
        (tia_accesses >= TIA_THRESHOLD) &
        (riot_accesses >= RIOT_THRESHOLD) &
        (branch_count >= BRANCH_THRESHOLD) &
        (jump_count >= JUMP_THRESHOLD) &
        (unique_opcodes >= INSTRUCTION_VARIETY) &
        (scores >= MIN_SCORE)
    )
    
    return {
        'scores': scores,
        'opcode_ratio': opcode_ratio,
        'tia_accesses': tia_accesses,
        'riot_accesses': riot_accesses,
        'branch_count': branch_count,
        'jump_count': jump_count,
        'unique_opcodes': unique_opcodes,
        'promising': promising
    }

def save_promising_rom(rom_data, score, rom_id, output_dir):
    """Сохраняем перспективные ROM в формате number_score_timestamp.bin"""
    timestamp = int(time.time())
    filename = f"{rom_id:06d}_{score:.3f}_{timestamp}.bin"
    filepath = output_dir / filename
    
    with open(filepath, 'wb') as f:
        f.write(rom_data.tobytes())
    
    return filename

def main():
    print("Finite Atari Machine - Streamlined CUDA Generator")
    print("=" * 60)
    print(f"Batch size: {BATCH_SIZE:,} ROMs per batch")
    print(f"ROM size: {ROM_SIZE:,} bytes")
    print()
    print("Thresholds:")
    print(f"  Opcodes: {OPCODE_THRESHOLD:.1%}")
    print(f"  TIA: {TIA_THRESHOLD}+")
    print(f"  RIOT: {RIOT_THRESHOLD}+")
    print(f"  Branches: {BRANCH_THRESHOLD}+")
    print(f"  Jumps: {JUMP_THRESHOLD}+")
    print(f"  Unique opcodes: {INSTRUCTION_VARIETY}+")
    print(f"  Min score: {MIN_SCORE:.2f}")
    print()
    
    # Информация о GPU
    try:
        gpu_props = cp.cuda.runtime.getDeviceProperties(0)
        gpu_name = gpu_props['name'].decode()
        total_mem = cp.cuda.runtime.memGetInfo()[1] // 1024**2
        print(f"GPU: {gpu_name}")
        print(f"Memory: {total_mem:,} MB")
    except Exception:
        print("GPU: CuPy device detected")
    
    print("\nInitializing lookup tables...")
    
    # Подготовка
    output_dir = Path("finite_atari_roms")
    output_dir.mkdir(exist_ok=True)
    
    lookup_tables = create_lookup_tables()
    
    # Статистика
    total_generated = 0
    promising_found = 0
    start_time = time.time()
    last_report = start_time
    best_score_ever = 0.0
    
    print("Starting ROM generation...")
    print("=" * 60)
    
    try:
        while True:
            batch_start = time.time()
            
            # Генерируем батч ROM
            roms = cp.random.randint(0, 256, size=(BATCH_SIZE, ROM_SIZE), dtype=cp.uint8)
            
            # Анализируем ROM
            analysis = analyze_roms(roms, lookup_tables)
            
            # Отслеживаем наилучшую оценку
            current_best = float(cp.max(analysis['scores']))
            if current_best > best_score_ever:
                best_score_ever = current_best
            
            # Проверяем перспективные ROM
            promising_indices = cp.where(analysis['promising'])[0]
            
            if len(promising_indices) > 0:
                # Сохраняем перспективные ROM
                promising_roms = cp.asnumpy(roms[promising_indices])
                promising_scores = cp.asnumpy(analysis['scores'][promising_indices])
                
                for i in range(len(promising_indices)):
                    filename = save_promising_rom(
                        promising_roms[i], promising_scores[i], promising_found, output_dir
                    )
                    promising_found += 1
            
            total_generated += BATCH_SIZE
            batch_time = time.time() - batch_start
            
            # Отчёт о прогрессе
            current_time = time.time()
            if current_time - last_report >= 4:
                # Получаем наилучшую статистику ROM для этого батча
                scores = cp.asnumpy(analysis['scores'])
                best_idx = np.argmax(scores)
                best_opcodes = float(analysis['opcode_ratio'][best_idx])
                best_tia = int(analysis['tia_accesses'][best_idx])
                best_riot = int(analysis['riot_accesses'][best_idx])
                best_branches = int(analysis['branch_count'][best_idx])
                best_jumps = int(analysis['jump_count'][best_idx])
                
                elapsed = current_time - start_time
                rate = total_generated / elapsed
                success_rate = promising_found / total_generated * 100 if total_generated > 0 else 0
                
                print(f"\rGenerated: {total_generated:,} | Found: {promising_found} | "
                      f"Success: {success_rate:.8f}% | Rate: {rate:,.0f}/sec | "
                      f"Best: {best_score_ever:.3f} | "
                      f"Op:{best_opcodes:.1%} TIA:{best_tia} RIOT:{best_riot} Br:{best_branches} Jmp:{best_jumps}", 
                      end="", flush=True)
                
                last_report = current_time
    
    except KeyboardInterrupt:
        elapsed = time.time() - start_time
        rate = total_generated / elapsed
        success_rate = promising_found / total_generated * 100 if total_generated > 0 else 0
        
        print(f"\n\nStopped after {elapsed:.1f} seconds")
        print(f"Total ROMs generated: {total_generated:,}")
        print(f"Promising ROMs found: {promising_found}")
        print(f"Success rate: {success_rate:.8f}%")
        print(f"Average rate: {rate:,.0f} ROMs/second")
        print(f"Best score achieved: {best_score_ever:.4f}")
        print(f"Results saved in: {output_dir}")

if __name__ == "__main__":
    main()

И это позволило мне проверять аж по 60 тысяч «случайных» ROM в секунду. Благодаря эвристикам я находил один «перспективный» ROM на каждые 2,59 миллиона сгенерированных ROM, то есть по одному ROM раз в несколько минут.

Первые результаты и причины неудачи машинного обучения

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

Я попробовал два способа запуска этих десяти тысяч ROM в эмуляторе, чтобы проверить, похожи ли какие-то из них на игры. Первым был классификатор, обученный на примерно 1,5 тысячах реальных коммерческих ROM Atari (положительных примеров) и на примерно 1,5 тысячах сгенерированных GPU случайных ROM (отрицательных примеров). На этих данных я обучил модель (Random Forest) с такими признаками как «Будет ли эмулятор выполнять ROM» и более подробными, например, «Как часто регистры TIA меняются в течение 2 секунд времени выполнения».

Это научно точный способ решения задачи. Передавая скриптом вывод из Stella и MAME, я смог создать классификатор, который бы говорил мне, может ли случайный ROM запускаться на Atari. К сожалению, этот способ не сработал. При запуске в эмуляторе результаты с самыми высокими оценками по большей мере были просто чёрными экранами. Это оказалось вполне логичным, когда я начал изучать модель. Самые важные признаки:

Признак

Значение

execution_time

0.9847

output_lines

0.0086

stdout_length

0.0067

crashed

0.0000

stderr_length

0.0000

video_indicators

0.0000

audio_indicators

0.0000

tia_activity

0.0000

game_indicators

0.0000

error_indicators

0.0000

Происходящее стало очевидным: я выбирал только те ROM, которые запускались, а не те, которые делали что-нибудь интересное. При проверке выяснилось, что они просто запускались и переходили в бесконечный цикл. После загрузки было несколько команд, которые не делали ничего, пока не выполнялся переход обратно к месту рядом с вектором сброса. На самом деле, такое и должно часто обнаруживаться. Рассмотрим самую простую программу для Atari:


; Программа начинается с $F000

F000: 4C 00 F0   ; JMP $F000 — бесконечно ничего не делаем

Этот код начинает выполнение с F000 и бесконечно выполняет переход к тому же адресу. Вероятность генерации такого кода:

\frac{1}{256} \times \frac{1}{256} \times \frac{1}{256} = \frac{1}{16777216}

Иными словами, среди каждых 16 миллионов сгенерированных ROM будет находиться один валидный ROM, не делающий абсолютно ничего. Вероятно, в процессе генерации 30 миллиардов ROM я сгенерировал его много раз. Но меня не интересует программа, которая ничего не делает.

То есть, классификатор машинного кода оказался бесполезным. Или же бесполезными были мои данные обучения. Но стоит сказать, что для игр Atari нет «почти работающего» обучающего датасета — это лишь шедевры и мусор.

Ещё одни результаты: находим всё, что интересно

Осознав, что мне нужно только одно — интересный визуальный результат, я переписал конвейер генерации так, чтобы он находил интересные ROM и сразу же отправлял их в эмулятор, чтобы находить интересные кандидатуры.

Python
# ==========================================================================
#
#   Единый конвейер Finite Atari (4 КБ, сброс @ $F000) - ИСПРАВЛЕННАЯ ВЕРСИЯ
#
#   Этот скрипт генерирует случайные ROM Atari 2600 на  GPU при помощи CUDA,
#   фильтрует их на GPU при помощи эвристик, и если они интересные, запускает
#   их в безголовом MAME на 2 с, чтобы проверить, есть ли в них динамическое видео.
#
#   Скрипт предназначен для работы на GPU с CUDA и с MAME, установленным в PATH.
#
#   Вывод сохраняется в папку "finite_atari_roms".
#
# ===========================================================================

from __future__ import annotations
import cupy as cp, numpy as np, hashlib, subprocess, tempfile, time, textwrap
from pathlib import Path
from PIL import Image

# ─── 1.  Глобальные константы ────────────────────────────────────────────────────
ROM_SIZE       = 4096
PAYLOAD_BYTES  = ROM_SIZE - 2
RESET_VECTOR   = (0x00, 0xF0)         # $F000 в little-endian 
BATCH_SIZE     = 1024 * 256           # примерно 256 тысяч ROM на батч GPU
STATUS_EVERY   = 10                   # количество батчей между выводом статуса
OUTPUT_DIR     = Path("finite_atari_roms"); OUTPUT_DIR.mkdir(exist_ok=True)

# Пороговые значения для видео
BLACK_LEVEL          = 15             # 0-255 серый; ≤ означает "чёрный"
NONBLACK_THRESHOLD   = 0.005          # ≥ 0,5 % пикселей ярче ⇒ видео
DYNAMIC_THRESHOLD    = 0.01           # ≥ 1 % хэшированных пикселей различаются ⇒ движение

# Пороговые значения эвристик (те же, что и для предыдущих скриптов Stella/MAME)
OPCODE_THRESHOLD    = 0.58
TIA_THRESHOLD       = 50
RIOT_THRESHOLD      = 13
BRANCH_THRESHOLD    = 150
JUMP_THRESHOLD      = 37
INSTRUCTION_VARIETY = 100
MIN_SCORE           = 0.52

# ─── 2.  Таблицы поиска опкодов ────────────────────────────────────────────────
# Валидные опкоды 6502 для контекста самодельных игр 2600
VALID_OPCODES = np.array([
    0x00,0x01,0x05,0x06,0x08,0x09,0x0A,0x0D,0x0E,0x10,0x11,0x15,0x16,0x18,
    0x19,0x1D,0x1E,0x20,0x21,0x24,0x25,0x26,0x28,0x29,0x2A,0x2C,0x2D,0x2E,
    0x30,0x31,0x35,0x36,0x38,0x39,0x3D,0x3E,0x40,0x41,0x45,0x46,0x48,0x49,
    0x4A,0x4C,0x4D,0x4E,0x50,0x51,0x55,0x56,0x58,0x59,0x5D,0x5E,0x60,0x61,
    0x65,0x66,0x68,0x69,0x6A,0x6C,0x6D,0x6E,0x70,0x71,0x75,0x76,0x78,0x79,
    0x7D,0x7E,0x81,0x84,0x85,0x86,0x88,0x8A,0x8C,0x8D,0x8E,0x90,0x91,0x94,
    0x95,0x96,0x98,0x99,0x9A,0x9D,0xA0,0xA1,0xA2,0xA4,0xA5,0xA6,0xA8,0xA9,
    0xAA,0xAC,0xAD,0xAE,0xB0,0xB1,0xB4,0xB5,0xB6,0xB8,0xB9,0xBA,0xBC,0xBD,
    0xBE,0xC0,0xC1,0xC4,0xC5,0xC6,0xC8,0xC9,0xCA,0xCC,0xCD,0xCE,0xD0,0xD1,
    0xD5,0xD6,0xD8,0xD9,0xDD,0xDE,0xE0,0xE1,0xE4,0xE5,0xE6,0xE8,0xE9,0xEA,
    0xEC,0xED,0xEE,0xF0,0xF1,0xF5,0xF6,0xF8,0xF9,0xFD,0xFE], dtype=np.uint8)

BRANCH_OPCODES = np.array([0x10,0x30,0x50,0x70,0x90,0xB0,0xD0,0xF0], dtype=np.uint8)
JUMP_OPCODES   = np.array([0x4C,0x6C,0x20], dtype=np.uint8)

def create_luts():
    """Возвращает список булеву таблицу поиска с 256 элементами (cupy)."""
    lut = {}
    lut["valid"]  = cp.zeros(256, cp.bool_); lut["valid"][VALID_OPCODES]  = True
    lut["branch"] = cp.zeros(256, cp.bool_); lut["branch"][BRANCH_OPCODES] = True
    lut["jump"]   = cp.zeros(256, cp.bool_); lut["jump"][JUMP_OPCODES]     = True

    # Особенности адресации 2600 для выявления операций доступа к TIA/RIOT
    lut["tia_store"] = cp.zeros(256, cp.bool_)
    lut["tia_store"][[0x84,0x85,0x86, 0x94,0x95,0x96]] = True   # STY/STA/STX (zp & zp,x)
    lut["tia_load"]  = cp.zeros(256, cp.bool_)
    lut["tia_load" ][[0xA4,0xA5,0xA6, 0xB4,0xB5,0xB6]] = True   # LDY/LDA/LDX (zp & zp,x)
    lut["tia_abs"]   = cp.zeros(256, cp.bool_)
    lut["tia_abs"  ][[0x8C,0x8D,0x8E, 0xAC,0xAD,0xAE]] = True   # аналоги для абсолютных значений

    lut["riot_acc"]  = cp.zeros(256, cp.bool_)
    lut["riot_acc"][[0x84,0x85,0x86, 0xA4,0xA5,0xA6]] = True

    addr = cp.arange(256, dtype=cp.uint8)
    lut["tia_range"] = addr <= 0x2F
    lut["riot_tmr"]  = (addr >= 0x80) & (addr <= 0x87)
    lut["riot_io"]   = (addr >= 0x94) & (addr <= 0x97)
    return lut

# ─── 3.  Эвристический фильтр на GPU ────────────────────────────────────────────────
def analyse_batch(roms: cp.ndarray, lut) -> tuple[np.ndarray, cp.ndarray]:
    """
    Возвращает (interesting_mask, scores) для 2D-массива uint8 ROM.
    Каждая строка = один ROM.
    """
    valid_cnt    = cp.sum(lut["valid"][roms], axis=1)
    opcode_ratio = valid_cnt.astype(cp.float32) / ROM_SIZE
    branch_cnt   = cp.sum(lut["branch"][roms], axis=1)
    jump_cnt     = cp.sum(lut["jump"  ][roms], axis=1)

    # --- Операции доступа к TIA --------------------------------------------------------
    tia_acc  = cp.sum((lut["tia_store"][roms[:,:-1]] | lut["tia_load"][roms[:,:-1]])
                      & lut["tia_range"][roms[:,1:]], axis=1)
    tia_acc += cp.sum(lut["tia_abs"][roms[:,:-2]]
                      & lut["tia_range"][roms[:,1:-1]]
                      & (roms[:,2:] == 0x00), axis=1)

    # --- Операции доступа к RIOT -------------------------------------------------------
    riot_acc  = cp.sum(lut["riot_acc"][roms[:,:-1]] & lut["riot_tmr"][roms[:,1:]], axis=1)
    riot_acc += cp.sum(lut["riot_acc"][roms[:,:-1]] & lut["riot_io" ][roms[:,1:]], axis=1)

    # --- Разнообразие опкодов в первом 1 КБ --------------------------------------
    uniq = cp.zeros(roms.shape[0], dtype=cp.int32)
    first_kb = roms[:, :1024]
    for op in VALID_OPCODES:
        uniq += cp.any(first_kb == op, axis=1)

    scores = (opcode_ratio * 0.25 +
              cp.minimum(tia_acc / 150.0, 1.0) * 0.30 +
              cp.minimum(riot_acc / 50.0, 1.0)  * 0.20 +
              cp.minimum(branch_cnt / 200.0, 1.0) * 0.15 +
              cp.minimum(jump_cnt / 40.0, 1.0)   * 0.10)

    interesting = ((opcode_ratio >= OPCODE_THRESHOLD) &
                   (tia_acc      >= TIA_THRESHOLD) &
                   (riot_acc     >= RIOT_THRESHOLD) &
                   (branch_cnt   >= BRANCH_THRESHOLD) &
                   (jump_cnt     >= JUMP_THRESHOLD) &
                   (uniq         >= INSTRUCTION_VARIETY) &
                   (scores       >= MIN_SCORE))

    return interesting, scores

# ─── 4.  Вспомогательный скрипт на Lua (снэпшот двух кадров) - ИСПРАВЛЕНО ────────────────────
SNAPSHOT_LUA = textwrap.dedent("""
    local s = manager.machine.screens[":screen"]
    local frame_count = 0
    emu.register_frame_done(function ()
        frame_count = frame_count + 1
        if     frame_count == 1  then s:snapshot("first.png")
        elseif frame_count == 60 then s:snapshot("second.png"); manager.machine:exit() end
    end, "snapper")
""")

# ─── 5. Вспомогательные функции анализа видео ──────────────────────────────────────────────
def _hash16(img: Path) -> str:
    with Image.open(img) as im:
        im = im.convert("L").resize((16,16), Image.NEAREST)
        return hashlib.sha1(im.tobytes()).hexdigest()

def _frame_is_nonblack(img: Path) -> bool:
    with Image.open(img) as im:
        g = np.asarray(im.convert("L"))
    return (g > BLACK_LEVEL).mean() >= NONBLACK_THRESHOLD

def rom_video_flags(rom: bytes, *, mame="mame", seconds=2.0) -> tuple[bool,bool]:
    """
    Возвращает (has_video, is_dynamic).
    • has_video  → первый кадр не чёрный
    • is_dynamic → ≥ 1 % хэшированных пикселей различаются между кадром  1 и 60
    """
    with tempfile.TemporaryDirectory() as td_s:
        td = Path(td_s)
        (td / "test.bin").write_bytes(rom)
        (td / "snapshot.lua").write_text(SNAPSHOT_LUA)

        base = [mame, "a2600", "-cart", "test.bin",
                "-seconds_to_run", str(seconds),
                "-nothrottle", "-window", "-sound", "none", "-skip_gameinfo"]

        for flag in ("-autoboot_script", "-script"):
            try:
                subprocess.run(base + [flag, "snapshot.lua"],
                               cwd=td, stdout=subprocess.DEVNULL,
                               stderr=subprocess.DEVNULL, timeout=seconds*5,
                               check=True)
                break
            except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
                if flag == "-autoboot_script":
                    continue
                return (False, False)

        # Проверяем наличие кадров в корневой папке и в подпапке snap
        f1, f2 = td / "first.png", td / "second.png"
        snap_dir = td / "snap"
        if not f1.exists() and snap_dir.exists():
            snap_f1 = snap_dir / "first.png"
            snap_f2 = snap_dir / "second.png"
            if snap_f1.exists():
                f1 = snap_f1
            if snap_f2.exists():
                f2 = snap_f2

        if not f1.exists():
            return (False, False)

        nonblack = _frame_is_nonblack(f1)
        if not nonblack or not f2.exists():
            return (nonblack, False)

        diff_bits = bin(int(_hash16(f1),16) ^ int(_hash16(f2),16)).count("1")
        dynamic = diff_bits / 256.0 >= DYNAMIC_THRESHOLD
        return (nonblack, dynamic)

# ─── 6.  Генератор ROM ───────────────────────────────────────────────────────
def generate_batch(n: int) -> np.ndarray:
    payload = np.random.randint(0, 256, size=(n, PAYLOAD_BYTES), dtype=np.uint8)
    reset   = np.tile(np.array(RESET_VECTOR, dtype=np.uint8), (n,1))
    return np.hstack((payload, reset))

# ─── 7.  Основной цикл ───────────────────────────────────────────────────────────
def main():
    lut = create_luts()
    tot_gen = tot_int = tot_vid = tot_dyn = 0
    batch_idx = 0
    start = time.perf_counter()

    try:
        while True:
            roms_cpu = generate_batch(BATCH_SIZE); tot_gen += BATCH_SIZE

            roms_gpu = cp.asarray(roms_cpu)
            keep, _ = analyse_batch(roms_gpu, lut)
            keep = keep.get(); del roms_gpu
            interesting = roms_cpu[keep]
            tot_int += len(interesting)

            for rom in interesting:
                has_vid, is_dyn = rom_video_flags(rom.tobytes())

                # Сохраняем, если ЛЮБОЕ из условий истинно
                if has_vid or is_dyn:
                    sha = hashlib.sha1(rom).hexdigest()[:12]
                    (OUTPUT_DIR / f"{sha}.bin").write_bytes(rom.tobytes())

                # Отдельное управление
                if has_vid:
                    tot_vid += 1           # первый кадр не чёрный
                if is_dyn:
                    tot_dyn += 1           # обнаружена анимация

            batch_idx += 1
            if batch_idx % STATUS_EVERY == 0:
                elapsed = time.perf_counter() - start
                rate = int(tot_gen / elapsed) if elapsed else 0
                print(f"{tot_gen:,d} generated | "
                      f"{tot_int:,d} interesting | {tot_vid:,d} with video | "
                      f"{tot_dyn:,d} dynamic | {rate:,d} ROM/s", flush=True)

    except KeyboardInterrupt:
        pass  # беспроблемный выход

    elapsed = time.perf_counter() - start
    rate = int(tot_gen / elapsed) if elapsed else 0
    print("─"*72)
    print(f"TOTAL: {tot_gen:,d} generated | {tot_int:,d} interesting | "
          f"{tot_vid:,d} with video | {tot_dyn:,d} dynamic | {rate:,d} ROM/s")

if __name__ == "__main__":
    main()

Этот скрипт генерирует 4 КБ случайных данных, затем фильтрует их в поисках эвристик на GPU. На Nvidia GeForce GTX 1070 он генерирует примерно 62150 ROM/с. Фильтруя по эвристикам, я получаю по одному «интересному» ROM (проходящему эвристики) на каждые 2,5 миллиона сгенерированных ROM.

Далее интересные ROM передаются CPU, где они проверяются при помощи MAME на наличие графики. Вот пример вывода скрипта, оставленного работать на ночь:

TOTAL: 1,804,075,008 generated | 456 interesting | 16 with video | 11 dynamic | 62,156 ROM/s

Из 1,8 миллиарда сгенерированных ROM только 456 прошли эвристический тест. Из них 16 имеют статический видеовывод, у 11 видео было подвижным.

Что я обнаружил

Вот некоторые из самых интересных визуально результатов этого эксперимента. Все эти ROM были сгенерированы из полностью случайных данных, отфильтрованы при помощи эвристик и пропущены через эмулятор Atari. Все они создают валидный видеовывод и отображают динамические или структурированные данные.

Random ROM output 1
Полосы, двигающиеся по серому полю. Похоже на дождь? Статичный фон, динамический слой спрайтов.
Random ROM output 2
Столбчатый глитч-арт. Движение по паттерну, почти архитектурное.
Random ROM output 3
Скроллящиеся чёрные прямоугольники с артефактами синхронизации. Есть движение по горизонтали!
Random ROM output 4
Рай повреждённых растровых строк. Постоянное мерцание с намёком на структуру.
Random ROM output 5
Странно упорядоченные лестницы? Одна из них как будто... сделана вручную?

Реальная протоигра

Несмотря на то, что мой конвейер делал упор на генерации визуального вывода, я обнаружил нечто более, чем просто странный визуальный вывод. ROM, который я назвал 51014 (сам ROM можно найти по ссылке) демонстрирует поведение, похожее на игру. Это бесконечный цикл визуального вывода, реагирующий на ввод игрока. Посмотрите на показанные ниже GIF:

51014 without using the controller
51014 без использования контроллера
5104, pressing Up on the joystick a few times
51014 при нажатии «вверх» на джойстике несколько раз

ROM 51014 состоит из жёлтого фона с двумя статичными вертикальными красными полосами на экране. Есть и третья полоса (на самом деле, пара красных полос), которая не статична; похоже, через каждые несколько растровых строк она разрывается. Если нажимать «вверх» на джойстике, эта разорванная пара полос останавливается. Ввод игрока преобразуется в визуальный вывод.

С точки зрения программирования это, конечно же, не впечатляет, но безумно осознавать, что этот результат был сгенерирован только из тщательно отфильтрованных случайных данных. Если объединить ещё несколько в общий ROM, то можно получить игру!

Дальнейшая работа?

Я выбрал Atari 2600 не просто так. Эта консоль очень проста, в ней нет мэпперов памяти, ROM программ и символов, а ещё в ней отсутствует «защита от копирования при помощи логотипа Nintendo», как в первом Game Boy. По сути, если вбросить в Atari случайные байты, то может получиться что-то странное. Это я и доказал.

Мне посоветовали попробовать и другие платформы, например, NES или Game Boy. Они подойдут не так хорошо, как 2600.

NES гораздо сложнее, почти для любой игры требуются мэпперы памяти. Нельзя просто вбросить случайные байты в ROM и ожидать какого-то результата. Точнее, можно, но для этого потребуется гораздо больше времени, чем для получения игры Atari. В NES используются раздельные ROM символов и программы для кода и графики. Они хранятся отдельно друг от друга. Можно сдампить в CHR ROM мусор, когда в PRG будет находиться Tetris, но мы получим лишь статический шум. Если заменить PRG игры Mario 3 и, возможно, мы увидим, как один разок мигнёт четверть спрайта Марио.

Game Boy требует, чтобы в конкретном месте ROM находился 48-байтный логотип Nintendo. Разумеется, можно обойти эту проблему, вставляя его уже после генерации, но, прежде чем игра запустится, должен завершиться целый загрузочный ROM. Также нужно учитывать чипы переключения банков.

В отличие от этих консолей, Atari 2600 проста до тупизны. Она загружается прямиком в ROM без какой-либо защиты. Она начинает передавать видео после девяти команд. Если трясти эту систему достаточно долго, то что-то обязательно выпадет.

Также можно использовать более сложные модели машинного обучения или даже LLM для генерации игр Atari. Но я считаю, что при этом потеряется весь смысл проекта. Обучив LLM на тысяче с лишним коммерческих игр для Atari, мы сможем получить только что-то, напоминающее коммерческую игру для Atari, а то и ничего. Моя методика с генерацией случайных данных с последующей фильтрацией при помощи простых эвристик и запуском результата для проверки вывода — это лучший способ получить что-то из случайности. Моя цель — не просто получить играбельную игру, но и создать играбельную игру из случайности.

Если подняться на ещё один уровень вверх, то можно было бы создать проект распределённых вычислений наподобие SETI@Home, в котором миллионы машин ищут сигналы в космическом шуме. Представьте, что GPU по всему миру майнят энтропию для игр Atari, а затем отправляют перспективные результаты в центральную систему для оценки на играбельность. Это абсурдная идея. Посмотрим, насколько популярным станет этот проект. По крайней мере, это лучше, чем майнить мусорные криптокойны.

Заключение

It was the best of times, it was the blurst of times?

Мысль о том, что можно извлечь из хаоса случайные видеоигры, поначалу кажется абсурдной, но, ещё не приступив к проекту, я уже знал, что у меня всё получится. Можно описать это и как философский/мыслительный эксперимент, и как техническую неизбежность.

Я не реализую теорему о бесконечном количестве обезьян. Да, миллион обезьян рано или поздно воспроизведут Шекспира, но им понадобится больше времени, чем осталось у Вселенной. Я прошу обезьян не создать работы Шекспира, а написать хоть что-то.

Вероятность получить слово «banana» в ASCII равна всего

\frac{1}{256}^6 = \frac{1}{281474976710656}

или 300 триллионов обезьян. Но мне нужно не «banana», мне нужно слово. Любое словарное слово. Меня не волнует, что у нас не получится игра Yar’s Revenge. Я просто хочу получить что-то, что запускается на Atari. Это гораздо проще.

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


; Программа начинается в $F000

F000: A9 84       ; LDA #$84        - Загружаем значение цвета (красный/оранжевый)
F002: 85 09       ; STA $09         - Сохраняем в COLUBK (регистр цвета фона)
F004: 85 02       ; STA $02         - Сохраняем в WSYNC (ожидаем горизонтальной синхронизации)
F006: 4C 04 F0    ; JMP $F004       - Возвращаемся к строке WSYNC (бесконечный цикл)

Всего девять команд. На самом деле, мы можем рассчитать и их тоже. Программа должна начинаться с A9, после чего могут идти любые из 128 цветов. Затем идёт 85 09 для сохранения цвета фона, 85 02 для ожидания WSYNC и 4C 04 F0 для возврата к предыдущей команде. Итого

\frac{1}{256} \times \frac{128}{256} \times \frac{1}{256} \times \frac{1}{256} \times \frac{1}{256} \times \frac{1}{256} \times \frac{1}{256} \times \frac{1}{256} \times \frac{1}{256} = \frac{1}{36893488147419103232}

или примерно 36 пентиллионов. Однако существует почти бесконечное число вариаций этого кода, так что после тестирования нескольких миллиардов ROM я обязательно получу что-то за свои усилия.

Весь код этого проекта можно найти в репозитории Finite Atari Machine

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


  1. CBET_TbMbI
    16.06.2025 13:12

    Не все знают, но Вавилонская библиотека доступна всем и всегда. Каждая обезьяна неизбежно воспроизведёт один из её томов, если займётся этим. Автор решил поискать в ней игры. Почему бы и нет? Ведь в ней есть всё.

    Кстати, отбрасывая то, что не запускается в Атари, он мог пропустить описание открытия, за которое дадут Нобелевку через 100 лет.


    1. milkground
      16.06.2025 13:12

      Ну это всего лишь фантазии. Реальность в том, что никто не может достать из этой "библиотеки" что-то стоящее.


      1. CBET_TbMbI
        16.06.2025 13:12

        Не фантазии. Скорее, теория вероятностей.


        1. milkground
          16.06.2025 13:12

          Я понимаю это, но я имею в виду, что в реальной жизни это невозможно достать, затратив какие-то разумные усилия


      1. urvanov
        16.06.2025 13:12

        Реальность в том, что никто не может достать из этой "библиотеки" что-то стоящее.

        А если кто-нибудь все перки в удачу вкачивал?


  1. 3263927
    16.06.2025 13:12

    класная идея! нужно какой-то оптимизирующий алгоритм чтобы генерировалось что-то со смыслом, как настоящая игра!


  1. kenomimi
    16.06.2025 13:12

    Можно попробовать вместо рандома число пи использовать. Интересно, по какому смещению в нем будет лежать первая игра?


  1. AdeptMedia
    16.06.2025 13:12

    Кагда мне было 7-8 лет, у меня был ZX-Spectrum, и игры на кассетах. Так вот я на полном серьезе верил, что можно скомпоновать куски кода с разных игр, чтобы получить новую игру, и пытался это сделать, записывая кусочки игр на двухкассетнике. Автор статьи, похоже, пытается сделать то же самое, но годиков ему немного побольше.

    PS про вавилонскую библиотеку знаю, конечно, это другое ;)


  1. CodeLikeKitten
    16.06.2025 13:12

    А что есть "игра"? Что-то что определённым или не особо образом реагирует на ввод пользователя? Или что-то имеющее свод понятных правил, что позволяет описать процесс на экране? Рано или поздно же найдётся какая-то простейшая игра, даже если это будет перемещение белого квадрата по экрану за относительно разумное время или нет?

    В любом случае, статья занимательная, хотя и не особо практичная


  1. maisvendoo
    16.06.2025 13:12

    Напомнило анекдот

    - Как Билл Гейтс пишет программы?

    - Он прыгает жопой по клавиатуре, а то что получается, компилирует и продает


    1. nochkin
      16.06.2025 13:12

      Есть продолжение:

      После такого один программист решил повторить это сам. Попрыгал так же на клавиатуре и запустил получившееся. Программа выдала на экран сообщение "Это может делать только Билл Гейтс".


  1. usiqwerty
    16.06.2025 13:12

    Вайбкодинг вид сбоку


  1. Moog_Prodigy
    16.06.2025 13:12

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

    Но интересует другое - именно подход к классификаторам. Если LLM озадачить написанием игры, даже локальную - одна из 100 вариантов будет даже рабочей. Как отличить рисовку заставки от работающей игры? Вопрос интересный, самым простым вариантом будет заставить llm в нее и поиграть. Но это безумная трата ресурсов, пусть даже бесплатных. Значит пусть скрипт играет. И вот этим то относительно простые скриптовые тесты уже интересны на широком обьеме разных задач.


  1. 0x131315
    16.06.2025 13:12

    Случайность - это хаос. Что-то имеющее смысл, полезное - это противоположность хаоса, далеко не случайная вещь. Так что получить что-то не случайное из случайного - идея заведомо обреченная на провал. Даже в рамках такого крохотного блока всего в 4кб.

    Гораздо интереснее псевдослучайность - когда вся последовательность заранее известна и не случайна. Вот на этом уже можно что-то строить.

    Но много на псевдослучайности выгадать нельзя.

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

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

    Но в теории, где-то там, в пространстве вариантов, всегда есть цепочка, точно совпадающая с любым произвольным файлом. Найти ее на практике, ожидаемо, за обозримое время невозможно.


  1. qiper
    16.06.2025 13:12

    Это значит, что существует 2^32768 возможных ROM

    Может стоить начать с небольших дампов, например 256 байт?