
В рамках этого проекта я сгенерировал около 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 был бы таким:
Генерируем ROM, сдампив 4 КБ данных из /dev/random в файл.
Запускаем этот файл в эмуляторе.
Делаем один-пять скриншотов.
Фильтруем или оцениваем их при помощи ИИ.
Сохраняем лучшие результаты для более пристального изучения.
Это бы сработало, если бы у нас было достаточно времени, чтобы дождаться, пока чёрные дыры не поглотят Вселенную. Но можно поступить умнее — выполнять на ранних этапах конвейера простые проверки для отбрасывания полного мусора ещё до момента запуска эмулятора. Для создания эвристик нам полезно будет узнать, как выглядит реальный 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 $02
,STX $06
,STY $00
).80% из них — это команды STA, 10% — STX, 10% — STY.
Часто применяется индексная адресация (
STA $00,X
,STY $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 $02
,STX $1B
(самые быстрые и часто используемые).Индексация по нулевой странице: 13,2% —
STA $00,X
,STY $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 и бесконечно выполняет переход к тому же адресу. Вероятность генерации такого кода:
Иными словами, среди каждых 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. Все они создают валидный видеовывод и отображают динамические или структурированные данные.





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


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, а затем отправляют перспективные результаты в центральную систему для оценки на играбельность. Это абсурдная идея. Посмотрим, насколько популярным станет этот проект. По крайней мере, это лучше, чем майнить мусорные криптокойны.
Заключение

Мысль о том, что можно извлечь из хаоса случайные видеоигры, поначалу кажется абсурдной, но, ещё не приступив к проекту, я уже знал, что у меня всё получится. Можно описать это и как философский/мыслительный эксперимент, и как техническую неизбежность.
Я не реализую теорему о бесконечном количестве обезьян. Да, миллион обезьян рано или поздно воспроизведут Шекспира, но им понадобится больше времени, чем осталось у Вселенной. Я прошу обезьян не создать работы Шекспира, а написать хоть что-то.
Вероятность получить слово «banana» в ASCII равна всего
или 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
для возврата к предыдущей команде. Итого
или примерно 36 пентиллионов. Однако существует почти бесконечное число вариаций этого кода, так что после тестирования нескольких миллиардов ROM я обязательно получу что-то за свои усилия.
Весь код этого проекта можно найти в репозитории Finite Atari Machine
Комментарии (15)
3263927
16.06.2025 13:12класная идея! нужно какой-то оптимизирующий алгоритм чтобы генерировалось что-то со смыслом, как настоящая игра!
kenomimi
16.06.2025 13:12Можно попробовать вместо рандома число пи использовать. Интересно, по какому смещению в нем будет лежать первая игра?
AdeptMedia
16.06.2025 13:12Кагда мне было 7-8 лет, у меня был ZX-Spectrum, и игры на кассетах. Так вот я на полном серьезе верил, что можно скомпоновать куски кода с разных игр, чтобы получить новую игру, и пытался это сделать, записывая кусочки игр на двухкассетнике. Автор статьи, похоже, пытается сделать то же самое, но годиков ему немного побольше.
PS про вавилонскую библиотеку знаю, конечно, это другое ;)
CodeLikeKitten
16.06.2025 13:12А что есть "игра"? Что-то что определённым или не особо образом реагирует на ввод пользователя? Или что-то имеющее свод понятных правил, что позволяет описать процесс на экране? Рано или поздно же найдётся какая-то простейшая игра, даже если это будет перемещение белого квадрата по экрану за относительно разумное время или нет?
В любом случае, статья занимательная, хотя и не особо практичная
maisvendoo
16.06.2025 13:12Напомнило анекдот
- Как Билл Гейтс пишет программы?
- Он прыгает жопой по клавиатуре, а то что получается, компилирует и продает
nochkin
16.06.2025 13:12Есть продолжение:
После такого один программист решил повторить это сам. Попрыгал так же на клавиатуре и запустил получившееся. Программа выдала на экран сообщение "Это может делать только Билл Гейтс".
Moog_Prodigy
16.06.2025 13:12Подход конечно довольно старый, раньше в числе пи искали закодированное слово или пароль. Старожилы говорят, что они встречали в числе Пи даже код Windows 80 для квантового компьютера, а также рецепт бессмертия и технологию сверхсветового перемещения...
Но интересует другое - именно подход к классификаторам. Если LLM озадачить написанием игры, даже локальную - одна из 100 вариантов будет даже рабочей. Как отличить рисовку заставки от работающей игры? Вопрос интересный, самым простым вариантом будет заставить llm в нее и поиграть. Но это безумная трата ресурсов, пусть даже бесплатных. Значит пусть скрипт играет. И вот этим то относительно простые скриптовые тесты уже интересны на широком обьеме разных задач.
0x131315
16.06.2025 13:12Случайность - это хаос. Что-то имеющее смысл, полезное - это противоположность хаоса, далеко не случайная вещь. Так что получить что-то не случайное из случайного - идея заведомо обреченная на провал. Даже в рамках такого крохотного блока всего в 4кб.
Гораздо интереснее псевдослучайность - когда вся последовательность заранее известна и не случайна. Вот на этом уже можно что-то строить.
Но много на псевдослучайности выгадать нельзя.
Как цепочку входных параметров для какого-нибудь генератора виртуального мира это можно использовать. И все будет работать как надо, стабильно и ожидаемо. И каждый раз из крохотной затравки, зерна псевдослучайной последовательности, будет разворачиваться один и тот же виртуальный мир. Все необходимое и осмысленное заранее зашито в генератор, который просто компонует виртуальный мир из готовых блоков, а параметры компоновки получает раскручивая псевдослучайную цепочку чисел.
А вот любые попытки сжать в лоб что-либо за счет цепочки заранее известных блоков также будут обречены на провал: длина смещения в этой цепочке будет больше сэкономленного, к тому же еще и времени на последовательный поиск уйдет много.
Но в теории, где-то там, в пространстве вариантов, всегда есть цепочка, точно совпадающая с любым произвольным файлом. Найти ее на практике, ожидаемо, за обозримое время невозможно.
qiper
16.06.2025 13:12Это значит, что существует 2^32768 возможных ROM
Может стоить начать с небольших дампов, например 256 байт?
CBET_TbMbI
Не все знают, но Вавилонская библиотека доступна всем и всегда. Каждая обезьяна неизбежно воспроизведёт один из её томов, если займётся этим. Автор решил поискать в ней игры. Почему бы и нет? Ведь в ней есть всё.
Кстати, отбрасывая то, что не запускается в Атари, он мог пропустить описание открытия, за которое дадут Нобелевку через 100 лет.
milkground
Ну это всего лишь фантазии. Реальность в том, что никто не может достать из этой "библиотеки" что-то стоящее.
CBET_TbMbI
Не фантазии. Скорее, теория вероятностей.
milkground
Я понимаю это, но я имею в виду, что в реальной жизни это невозможно достать, затратив какие-то разумные усилия
urvanov
А если кто-нибудь все перки в удачу вкачивал?