Моя реализация демки BadApple на TIC-80

Это случилось. Дима спустя год нашел тему, о которой хотел бы написать в своем блоге. Сегодняшней темой будет клип группы Touhou — Bad Apple. Почему он? Дело в том, что он обладает некоторыми особенностями — в клипе контрастными черными белыми цветами творчески изображены анимированные силуэты. С одной стороны — это упрощает перенос анимации на простые и очень старые железки, с другой она все равно выглядит впечатляюще и сложно сравнительно с тем, что мы могли видеть ранее на 8-битных и 16-битных приставках и подобный перенос но примитивное железо может быть своего рода челенджем. На YouTube вы можете видеть демки с этим клипом на программируемых калькуляторах, NES(она же Денди), осцилографах, на которые подается сигнал через ПЛИС, либо VGA, Atari, механических дисплеях, в Майнкрафте и Террарии и подобные вещи. Конечно, перенос столь сложной анимации, на такие гиковские устройства не может не впечатлять — и такие вещи хочется реализовать самому, чтобы можно было сказать — «смотрите, это я сделал». В связи с определенными обстоятельствами у меня под рукой не было ретро железок. Зато, был лэптоп, и кто сказал, что ретро железки не могут быть вымышленными, коим является, например TIC-80? Я попробовал, и вот что у меня получилось.

Идея

Поскольку демосцены нужны, в первую очередь, для того чтобы впечатлить людей, используя железо с ограниченными возможностями, я опишу задачу субъективными категориями. Требуется реализовать классную анимацию используя ограниченные ресурсы, доступные в TIC-80. До меня ещё была демка Bad Apple, реализованная самим разработчиком TIC-80 с растровой графикой в один бит на пиксель, которая судя по комментам затем была пожата при помощи алгоритма LZ77, собственно вот она (по ссылке). У получившейся растровой картинки было низкое разрешение даже по меркам TIC-80, где мы имеем дисплей аж на 240×136, соответственно в демке было добавлено четыре стиля отображения этих точек. Но кто сказал, что демку нельзя сделать лучше? Я собственно уверен был, что можно сделать лучше и более того, что даже я смогу сделать лучше, что смотивировало меня сделать подобную демку, несмотря на наличие похожей.

Одним из ограничений в реализации подобной анимации было ограниченное количество памяти, которое может вместиться в катридж для хранения этой анимации — много графики на N-ое количество кадров в секунду для клипа на 2 минуты 30 секунд. Мы приблизительно видим пределы возможности растровой графики(хотя, я уверен, что и растровую графику на TIC-80 можно было сделать немного лучше). И оно понятно — если пытаться сделать в лоб, то мы увидим, что на каждый кадр при полном разрешении надо 32640 бита или 4080 байт на один кадр. И почему словарными алгоритмами сжатия данная картинка хорошо жмется без потерь — тоже интуитивно доступно должно быть каждому. Если мы посмотрим на битмап — мы увидим длинную последовательность нулей(для черного цвета) или единиц(для белого цвета).

Какие идеи ещё приходят кроме алгоритмов сжатия, как бы нам по компактнее выложить картинку? Конечно, если мы выложим картинку в одномерный массив, вы увидим длинные последовательности белых пикселей и черных пикселей. Учитывая, что у нас сам экран шириной в 240 пикселей, спан из одноцветной последовательности цветов шириной в один экран мы можем уложить в один байт. Оптимистично будет считать, что весь спан можно будет уложить в один байт — просто храня его ширину. Тогда, если понадобится спан шириной больше 255 пикселей, мы можем просто чередовать со спанами размером в ноль пикселей. Выглядеть это будет примерно так — 0xFF, 0x00, 0xFF, 0x00, 0x35 (это будет одноцветный спан шириной 563 пикселей или 2,35 строки экрана TIC-80). Грубо на глаз — если мы на строку расчитываем таким образом потратить 3 — 4 байта, то у нас выйдет 476 байт на фрейм, что согласитесь тоже много. Даже для 10FPS, с которыми я сделал эту демку на 2:30 ролика у нас выйдет 714000 байт или 697 килобайт. У нас попросту нет столько места.

Какая же идея мне тогда пришла? Смотрите, если вы сравните любые две соседние строки в любом кадре, то скорее всего в каждой следущей строке граница перехода с черного цвета на белый или наоборот будет рядом. Иными словами мы видим эту границу, ориентированной вдоль некой линии — эта линия и есть наиболее содержательный для нашего глаза кусочек информации, а не какая-то отдельная точка, которая без разницы где может находиться — левее, там правее. Человеку для распознавания силуета важно понимать где примерно проходит эта линия. Иными словами, нам допустимо потерять часть информации, если в конечном итоге разрешение станет больше, а картинка красивее. Таким образом мне пришла идея реализовать полигональную графику. Причем нам не обязательно отрисовывать замкнутые фигуры. Мы можем хранить список ломаных линий, где каждая следущая точка ниже предыдущей по координате Y и тогда мы можем для каждой координаты Y и ломаной линии только одну либо ни одну координату X. Эта точка и станет тем местом, где рендер должен сменить черный цвет на белый, либо наоборот с белого на черный. Позвольте мне продемонстрировать принцип работы такого рендера на следущей гифке:

Насколько это компактный формат записи графики для подобного клипа? Это зависит от количества деталей в кадре. На практике у меня получилось добиться записи в 330643 байт на 2190 кадров — в среднем примерно 151 байт на кадр. Это при условии, что заголовок кадра занимает 1 байт (на количество линий в кадре), заголовок линии 1 байт(7 бит на количество точек + 1 флаг для формата компактной записи), каждая точка на линии по два байта на координаты X и Y. Также есть линии с компактной формой записи. Если удается для всех точек после первой удается уложить дельту с предыдущей в 4 бита, то каждая точка после первой будет занимать 1 байт. Такая форма записи линии позволила пожать запись на 15%. Надеюсь идея рендера понятна, можем идти дальше.

Железо

Как я упомянул ранее, у TIC-80 есть дисплей размером в 240×136. В экранном буфере приходится 4 бита на пиксель, что позволяет для каждого пикселя выбрать цвет из палитры на 16 цветов. Технически можно отрисовать больше 16 цветов на кадр. Если задать хук на отрисовку линии, то можно после каждой линии менять палитру цветов. У этого вымышленного компьютера есть модель памяти RAM на 96 килобайт, на которую отображаются различные структуры хранящие состояние машины. Есть API для чтения и записи RAM. Очень жирный кусок из соседствующих областий TILES, SPRITES, MAP на адресах с 0x04000 по 0x0ff7f и занимающий 48 килобайт отображается прямо с катриджа и может использоваться нами для хранения векторной анимации, если мы откажемся от спрайтов. Области под музыку не трогаем, она нам нужна будет. Зарезервированная область бесполезна, поскольку все равно содержимое катриджа она не отображает. Кроме того, TIC-80 поддерживает банкинг. На катридже может быть записано до 8ми банок памяти для тайлов, спрайтов и карт. По мере чтения наша программа будет банки переключать и в VRAM будут отображаться разные области памяти. Тогда мы в катридж можем засунуть аж 8*48 = 384 килобайта.

Таким образом для побайтового чтения содержимого из катриджа для переключения банок нам потребуется такая утилитарная функция:

vram_read_begin_address = 0x04000 vram_read_end_address = 0x0ff7f current_bank = 0 current_peek_index = vram_read_begin_address function peek_next() local readed_byte = peek(current_peek_index) -- peek считывает указыанный адрес из RAM current_peek_index = current_peek_index + 1 if current_peek_index > vram_read_end_address then current_peek_index = vram_read_begin_address current_bank = current_bank + 1 if current_bank > 7 then current_bank = 0 end sync(7, current_bank) -- sync переносит содержимое выбранной банки памяти в RAM -- здесь 7 - это побитовая маска для того, чтобы выбрать 3 области памяти end return readed_byte end
Code language: Lua (lua)

Тогда после последнего кадра мы можем сбросить все счетчики переключиться на первую банку задать адрес первого кадра и воспроизведение начнется сначала.

function peek_next_frame() local frame = read_next_frame() current_frame_index = current_frame_index + 1 if current_frame_index >= frames_count then current_frame_index = 0 current_bank = 0 current_peek_index = first_frame_address tick = 1 - update_tick_interval sync(7, current_bank) end return frame end
Code language: Lua (lua)

Но и это ещё не все! Кто сказал, что TIC-80 в своем RAM менеджит рантайм языков, которые он моддерживает, будто Lua, JS, WebAssembly(у последнего есть ограничения по оперативной памяти в 256 кб, но там есть специфика — виртуальная машина WASM довольно низкоуровневая и её можно ограничить по RAM и заммапить её модель памяти в байтовый массив). У интерпретаторов языков есть свои структуры данных вне RAM TIC-80. Кроме того, судя по докам у нас есть возможность добавить до 8 банок кода по 64Кб, что дает нам 512 килобайт ASCII текста. Можно добавить в программу декодер base64, тогда в виде ASCII мы можем закодировать ~390Кб (за вычитом бесполезного кода естественно). В принципе можно пойти по этому пути, но я ограничился чанками катриджа под спрайты, тайлы и мапы.

Рендер

У нас к рендеру требования такие — нужен простой и максимально эффективный рендер, который позволит уложить графику максимально компактно, при этом, рисовать производительно, в связи с чем, я наложил некоторые ограничения на формат графики:

  • Один кадр должен представлять список ломанных линий.
  • Каждая линия — это список точек.
  • Рендер для каждой координаты Y должен найти одну либо ни одной координаты X, дабы минимизировать сложность алгоритма.
  • Как следствие, каждая следущая точка линии имеет большую координату Y (визуально ниже для системы координат с началом в левом верхнем углу), чем предыдущая точка.
  • Поскольку пересечение линии меняет цвет на противоположный, сложная фигура типа квадрата или шара может быть сэмулирована двумя линиями в кадре.
  • В целях ускорения закрашивания(ускорение предполагается за счет уменьшения числа вызовов API кода и минимизации выполнения интерпретируемого кода) рендер не проходит по каждой координате X, а закрашивает белые полоски от нечетных пересечений, до четных(у кода на WASM, возможно, был бы прямой доступ к буферу экрана из памяти виртуальной машины, что позволило-бы рендерить картинку без обращения к API TIC-80).
  • Желательно на этапе рендеринга не должно быть сортировок за счет того, что линии должны быть грамотно отсортированы на этапе векторизации, дабы не создавать промежуточных таблиц, для хранения пересечений и последущих сортировок(почти получилось, однако на некоторых кадрах оставались графические артефакты в связи с неупорядоченностью линий, так что сортировщик пришлось добавить и в рендер).

Для начала, функция, которая инстанцирует таблицу с ломанной. Как видите, в связи с требованиями к линиям, я отошел от некоторых гарантий корректности вычислений(например, каждая следущая точка просто обновляет max_y, а min_y — это всегда первая точка), в сторону эффективности. Так в большинстве случаев в find_intersect не будет выполняться никаких циклов и его выполнение будет ограничено проверкой 5 условий и вычислением формулы пересечения отрезка с прямой, которую выведет любой школьник, не прогуливающий математику.

function new_polyline() return { count = 0, max_y = nil, min_y = nil, index = 0, cur_y_begin = nil, cur_y_end = nil, cur_x_begin = nil, cur_x_end = nil, add_point = function(self, x, y) self[self.count] = new_point(x, y) if self.count == 0 then self.min_y = y self.max_y = y self.cur_y_begin = y self.cur_y_end = y self.cur_x_begin = x self.cur_x_end = x elseif self.count == 1 then self.cur_y_end = y self.cur_x_end = x self.max_y = y else self.max_y = y end self.count = self.count + 1 end, is_hovered = function(self, ypos) return self.count > 0 and ypos >= self.min_y and ypos <= self.max_y end, _set_index = function(self, index) local begin_point = self[index] local end_point = self[index + 1] self.index = index self.cur_y_begin = begin_point.y self.cur_x_begin = begin_point.x self.cur_y_end = end_point.y self.cur_x_end = end_point.x end, find_intersect = function(self, ypos) if not self:is_hovered(ypos) then return nil end while ypos < self.cur_y_begin do self:_set_index(self.index - 1) end while ypos > self.cur_y_end do self:_set_index(self.index + 1) end if ypos == self.cur_y_begin then return self.cur_x_begin elseif ypos == self.cur_y_end then return self.cur_x_end else local xpos = self.cur_x_begin + (ypos - self.cur_y_begin) * (self.cur_x_end - self.cur_x_begin) // (self.cur_y_end - self.cur_y_begin) return xpos end end } end
Code language: Lua (lua)

Непосредственно, функция рендера выглядит так:

function update_frame() local frame = peek_next_frame() local begin_x_pos cls() for ypos = 0,135 do begin_x_pos = nil intersects = {} for line_key, polyline in ipairs(frame) do if polyline:is_hovered(ypos) then x_pos = polyline:find_intersect(ypos) table.insert(intersects, x_pos) end end table.sort(intersects) for intersect_key, intersect in ipairs(intersects) do if begin_x_pos == nil then begin_x_pos = intersect else line(x_offset + begin_x_pos, ypos, x_offset + intersect, ypos, white_color) begin_x_pos = nil end end if begin_x_pos ~= nil then line(x_offset + begin_x_pos, ypos, x_offset + 180, ypos, white_color) end end end
Code language: Lua (lua)

Для тех, кто считает, что можно сделать лучше — домашнее задание. На самом деле это код из первой версии катриджа. Коль скоро сортировкой нам и так приходиться пользоваться — есть один путь для оптимизации. В сложных сценах на некоторых кадрах у нас может быть свыше 200 линий. Так вот, в таком случае в процессе рендеринга на каждую из 136 строк в процессе отрисовки надо перебирать таблицу из около 200 линий, что не очень эффективно. В Firefox на мобильном устройстве демка заметно пролагивала на сложных сценах. Не заглядывая в код нового катриджа, попробуйте придумать, как сделать так, чтобы была временная таблица, в которой были бы исключительно необходимые для отрисовки конкретной строки линии.

Векторизация

Скажу, у меня нет красиво оформленных утилит для векторизации, распаковки, запаковки катриджа. Все манипуляции над графикой я проделовал интерактивно в Jupyter Notebook. Так что выкладываю свои наброски в виде архива с блокнотиками — вот он.

Так вот ролик считывался при помощи OpenCV. На питоне процессить графику из OpenCV также приятно, как любые другие многомерные массивы numpy. Видео ролик считывается кадр за кадром, скипается два кадра из 3-х, т.е. на 30FPS исходника мы имеем 10FPS векторной графики. По уровню трешхолда это конвертируется в булевый массив, разрешение понижается. Давайте пропустим этот скучный неинтересный пайплайн, перейдем к тому моменту, когда у нас есть булевый квадратный массив с низким разрешением и нам надо его векторизовать.

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

def vectorize_image(image): all_lines = list() current_lines = list() height, width = image.shape for y_pos in range(height): next_current_lines = list() current_state = False intersects = list() for x_pos in range(width): pixel_state = image[y_pos, x_pos] if pixel_state != current_state: intersects.append(x_pos) current_state = pixel_state intersects_idx = list(enumerate(intersects)) current_lines_idx = list(enumerate(current_lines)) intersects_product = list_product(intersects_idx, current_lines_idx) intersects_product.sort(key = lambda x:abs(x[0][1] - x[1][1][-1][1])) intersects_product = list(filter(lambda x:abs(x[0][1] - x[1][1][-1][1]) <= 5, intersects_product)) intersects_product = list(map(lambda x:(x[0][0], x[0][1], x[1][0]), intersects_product)) registered_intersects = set() registered_lines = set() for x_idx, x_pos, line_idx in intersects_product: if (not x_idx in registered_intersects) and (not line_idx in registered_lines): registered_intersects.add(x_idx) registered_lines.add(line_idx) line = current_lines[line_idx] next_current_lines.append(line) line.append((y_pos, x_pos)) for x_idx, x_pos in intersects_idx: if not x_idx in registered_intersects: line = list() line.append((y_pos, x_pos)) all_lines.append(line) next_current_lines.append(line) current_lines = next_current_lines all_lines.sort(key = CompareLineContainer) return all_lines
Code language: Python (python)

Все бы ничего, но это слишком плотный набор точек, он будет весить однозначно тяжелее, чем вариант со спанами. Для предвадительной обработки нужен марчер, который будет выкидывать совсем рядом стоящие точки. Собственно, алгоритм простой, он гуляет по списку точек. Следущие точки после контрольной проверяются на декартово растояние до контрольной точки — если оно маленькое — точка выкидывается, если нет, то становится следущей контрольной точкой. Код:

def line_marcher(lines, distance): out_lines = list() sqr_distance = distance * distance for line in lines: out_line = list() out_lines.append(out_line) prev_point = None very_prev_point = None for point in line: last_appended = False if prev_point is None: out_line.append(point) prev_point = point last_appended = True else: cy, cx = point vpx = very_prev_point[1] if vpx <= 0 and cx > 0: # эта бранча хак - хак, для того, # чтобы бороться с артефактами с левого края картинки out_line.append(very_prev_point) prev_point = very_prev_point py, px = prev_point dx, dy = cx - px, cy - py if dx*dx + dy*dy > sqr_distance: out_line.append(point) prev_point = point last_appended = True very_prev_point = point if not last_appended: out_line.append(point) return out_lines
Code language: Python (python)

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

def get_points_angle(first_point, middle_point, last_point): dist = lambda a,b: sqrt((a[0]-b[0])**2 + (a[1]-b[1])**2) a = dist(first_point, middle_point) b = dist(middle_point, last_point) c = dist(first_point, last_point) cos_alpha = (a*a + b*b - c*c) / (2*a*b) # Не удивляйтесь. Вещевственная арифметика бывает с погрешностями. # Тем более вы видели вверху деление, а компьютер оперирует # конечными двоичными дробями, которые выражают не все рациональные числа. # Давайте просто убедимся, что косинус угла попадает в диапазон от -1 до 1. # Без этой проверки все падает. Я знаю. if cos_alpha < -1.0: cos_alpha = -1.0 elif cos_alpha > 1.0: cos_alpha = 1.0 alpha = acos(cos_alpha) return alpha def line_angle_reducer(lines, angle_threshhold): out_lines = list() for line in lines: out_line = list() out_lines.append(out_line) first_point = None middle_point = None prev_point = None for point in line: if prev_point is None: prev_point = point if first_point is None: first_point = point out_line.append(point) else: if middle_point is None: middle_point = point else: try: angle = get_points_angle(first_point, middle_point, point) except ZeroDivisionError: # дерьмо случается # мы не знаем, что это такое, ну лучше точку оставить, чем выкинуть # пусть угол будет заведомо меньше трешхолда. angle = 0.0 if prev_point[1] <= 0 and point[1] > 0: # опять же, эта бранча хак - хак, для того, # чтобы бороться с артефактами с левого края картинки out_line.append(prev_point) first_point = middle_point middle_point = prev_point elif angle < angle_threshhold: out_line.append(middle_point) first_point = middle_point middle_point = point prev_point = point if not middle_point is None: #i.e. line have at least 2 points out_line.append(point) return out_lines
Code language: Python (python)

Итоговый пайплайн векторизации получается такой:

all_lines = vectorize_image(frame) all_lines = line_marcher(all_lines, 2) all_lines = line_angle_reducer(all_lines, 3.0) all_lines = line_marcher(all_lines, 4)
Code language: Python (python)

Векторизовали. Что дальше? Правильно, дальше надо записать в байты, которые мы хотим засунуть в катридж. Выше, я уже упоминал, как выглядит формат конечного векторного ролика. Python код будет соответствующий (да, я использовал списки вместо bytearray, а че вы мне сделаете? Моя память — че хочу, то и делаю).

out_bytes_list = list() all_frames_len = len(all_frames) all_frames_len1 = (all_frames_len & 0xff00) >> 8 all_frames_len2 = all_frames_len & 0xff out_bytes_list.append(all_frames_len1) out_bytes_list.append(all_frames_len2) for frame in all_frames: out_bytes_list.append(len(frame)) for line in frame: line_bytes_list = list() compact_form_failed = False #let's try compact format_first line_bytes_list.append(0x80 | len(line)) prev_point = None for point in line: y, x = point if prev_point is None: line_bytes_list.append(y) line_bytes_list.append(x) else: py, px = prev_point dy = y - py dx = x - px adx = abs(dx) if (dy == dy & 0xf) and (adx == adx & 0x7): coord_byte = dy << 4 if dx < 0: coord_byte |= 0x8 coord_byte |= adx line_bytes_list.append(coord_byte) else: compact_form_failed = True break prev_point = point if not compact_form_failed: out_bytes_list.extend(line_bytes_list) else: out_bytes_list.append(len(line)) for y, x in line: out_bytes_list.append(y) out_bytes_list.append(x) out_bytes = bytes(out_bytes_list) with open('vectorized_video_compact', 'wb') as out_file: out_file.write(out_bytes)
Code language: Python (python)

Распаковка и упаковка катриджей

Итак ролик отвекторизовали, экспортировали. Код написали. Чужой катридж с музыкой достали. Нужно это как-то взять, запихнуть в катридж с демкой. У TIC-80 нет инструментов, чтобы кастомные данные, не относящиеся к спрайтам, тайлам и картам рассовать в чанки. Там только редакторы спрайтов и карт. Также в последних версиях почему-то убрали из аргументов командной строки параметр для импорта кода из внешнего файла, а хотелось бы внешний редактор кода использовать. Благо формат катриджа простой. Документация описывает его как последовательность чанков. У каждого чанка есть заголовок из четырех байт и содержимое. В первом байте три бита указывают номер банки, остальные пять тип чанка. Два байта указывают размер чанка. У каждого чанка в катридже TIC-80 может быть не больше, чем 65 535 байт содержимого. Четвертый байт заголовка резверный — он всегда равен 0. Формат файла простой, пишем функцию для импорта:

def load_cart(cart_filename): with open(cart_filename, 'rb') as cart_file: cart = cart_file.read() idx = 0 chunks = [] while idx < len(cart): chunk_type_and_bank = cart[idx] chunk_bank = (chunk_type_and_bank & 0xE0) >> 5 chunk_type = chunk_type_and_bank & 0x1F chunk_size = cart[idx+1] | (cart[idx + 2] << 8) chunk_reserved = cart[idx+3] payload_begin = idx + 4 payload_end = payload_begin + chunk_size payload = cart[payload_begin:payload_end] chunks.append((chunk_type, chunk_bank, chunk_reserved, payload)) idx += 4 + chunk_size return chunks
Code language: Python (python)

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

chunk_types = { 1: "CHUNK_TILES", 2: "CHUNK_SPRITES", 3: "CHUNK_COVER_DEP", 4: "CHUNK_MAP", 5: "CHUNK_CODE", 9: "CHUNK_SAMPLES", 10: "CHUNK_WAVEFORM", 12: "CHUNK_PALETTE", 13: "CHUNK_PATTERNS_DEP", 14: "CHUNK_MUSIC", 15: "CHUNK_PATTERNS", 17: "CHUNK_DEFAULT", 18: "CHUNK_SCREEN" } def print_cart(cart): for chunk_type, chunk_bank, chunk_reserved, payload in cart: chunk_type_name = chunk_types.get(chunk_type) if chunk_type_name is None: chunk_type_name = str(chunk_type) else: chunk_type_name = f"{chunk_type} ({chunk_type_name})" print(f"type = {chunk_type_name}, bank = {chunk_bank}, reserved = {chunk_reserved}, size = {len(payload)}")
Code language: Python (python)

Экспорт катриджа:

def save_cart(cart, cart_filename): cart_bytes_list = list() for chunk_type, chunk_bank, chunk_reserved, payload in cart: chunk_type_and_bank = (chunk_bank << 5) | chunk_type cart_bytes_list.append(chunk_type_and_bank) chunk_size = len(payload) cart_bytes_list.append(chunk_size & 0xff) cart_bytes_list.append((chunk_size & 0xff00) >> 8) cart_bytes_list.append(chunk_reserved) cart_bytes_list.extend(payload) cart_bytes = bytes(cart_bytes_list) with open(cart_filename, 'wb') as cart_file: cart_file.write(cart_bytes)
Code language: Python (python)

И наконец кодик, который разрезает экспортированный клип и рассовывает по чанкам для катриджа:

# Перевернутый словарик chunk_types, чтобы из читаемого типа # чанка я мог разрешить числовой ID типа. chunk_types_names_to_id = dict(map(lambda x:(x[1], x[0]), chunk_types.items())) # Юзается так: # ch_types_id("CHUNK_TILES", "CHUNK_SPRITES", "CHUNK_MAP") -> [1, 2, 4] def ch_types_id(*type_names): return list(map(lambda x:chunk_types_names_to_id[x], type_names)) # Утилитарный класс - хранит итератор по байтикам, выдает запрошенное число байт. # Когда содержимое файлика с клипом кончается, аттрибут finished == True. class PayloadSplitter: def __init__(self, payload): self.finished = len(payload) == 0 self.payload_iter = iter(payload) def get_piece(self, count): bytes_list_out = list() cur_count = 0 while cur_count < count: try: bytes_list_out.append(next(self.payload_iter)) cur_count += 1 except StopIteration: self.finished = True break return bytes(bytes_list_out) # Здесь непосредственно нарезание байтов на чанки. def prepare_custom_cart_payload(payload_filename): with open(payload_filename, 'rb') as payload_file: payload = payload_file.read() custom_data_chunk_types = ch_types_id("CHUNK_TILES", "CHUNK_SPRITES", "CHUNK_MAP") payload_splitter = PayloadSplitter(payload) payload_chunks = list() for bank, chunk_type in itt.product(range(8), custom_data_chunk_types): if payload_splitter.finished: break payload_chunks.append((chunk_type, bank, 0, payload_splitter.get_piece(chunk_types_max_sizes[chunk_type]))) if not payload_splitter.finished: raise ValueError("Payload too big for TIC-80 custom data!") return payload_chunks
Code language: HTML, XML (xml)

Катриджи загрузили, клип нарезали, ненужное выкинули, нужное засунули — катридж готов.