Исправляем битый bmp-скриншот из игр Bethesda

от
Работа с графикой   bmp, file formats, decoder, skyrim, bethesda, python

На днях друг скинул скриншот в формате bmp ScreenShot10.bmp.gz из игры Skyrim. Файл не хотел открываться в доброй половине просмотрщиков, а там, где открывался, получалось искаженное изображение:
cover.jpg
Как выяснилось, в некоторых играх Bethesda был баг с кодировщиком в bmp. В статье я расскажу в чём проблема, как исправить файл, а также познакомлю с форматом bmp.

Большую часть операций я буду проводить в Hex-редакторе. Можно взять wxHexEditor, HxD, либо открыть файл прямо в браузере https://hexed.it/


Представление данных, Little endian и Big endian
Кратко напомню о типах и представлении данных.

Минимальной единицей измерения информации является бит. Бит принимает значение 0/выключен/ложь и 1/включен/правда, то есть имеет два состояния.
Два бита имеют 22 = 4 состояния: 00, 01, 10 и 11
Восемь бит имеют 28 = 256 состояний, это один байт (или byte, int8)
Два байта состоят из 16 бит, содержат 65536 состояний и именуются словом (или word, short, int16).
Четыре байта состоят из 32 бит, содержат 232 состояний и зовутся двойным словом (или dword, int32).

Существует два порядка представления последовательности байт:
   Little endian — первым идёт младший байт, а последним старший.
   Big endian — наоборот, первым байтом идём старший байт, а последним младший.

В последовательности байтов, скажем, для int32 0D 0C 0B 0A, в Little Endian 0D будет младшим байтом, соответственно, значение следует воспринимать как:
0Dh*21 + 0Ch*28 + 0Bh*216 + 0Ah*224
= 13*1 + 12*256 + 11*65536 + 10*16777216
= 168496141


Для Big Endian 0D будет старшим байтом, соответственно:
0Dh*224 + 0Ch*216 + 0Bh*28 + 0Ah*21
= 13*16777216 + 12*65536 + 11*256 + 10*1
= 218893066



Формат 24-битного BMP
Разновидностей BMP несколько, например, существует 1-битный монохромный BMP, 8-битный BMP, который может содержать лишь 256 цветов. Но мы остановимся на 24-битном, содержащем до 16 миллионов цветов, потому что в Skyrim используется именно он. Разберём на примере этого изображения 8x8 sample-24bit.bmp

Первые 14 байт это заголовок файла BMP. Для файла выше эти 14 байт выглядят так:
42 4D F6 00 00 00 00 00 00 00 36 00 00 00

   Первые два байта (00-01) — маркер BMP изображений. Для Windows BMP он всегда имеет значения 4D42 (ASCII-коды символов BM)
   Дальше идут 4 байта int32 (02-05) — размер файла в байтах. Формат BMP использует Little Endian, значит, F6000000 = 246
   Следующие два слова int16 (06-07 и 08-09) — зарезервированы и не используются. В нашем файле они имеют значение 0.
   Последние 4 байта int32 (0A-0D) — смещение непосредственно к данным о пикселях. Для 24-битных изображений не нужно сохранять палитру, как это есть в 8-битном формате, поэтому значение здесь обычно 3600000 = 54 (54 байта: 14 заголовок файла + 40 информация об изображении)


Следующие 40 байт содержат важную информацию об изображении:
28 00 00 00 08 00 00 00 08 00 00 00 01 00 18 00 00 00 00 00 C0 00 00 00 C4 0E 00 00 C4 0E 00 00 00 00 00 00 00 00 00 00

   int32 (0E-11) — размер этого заголовка 2800000 = 40 байт
   int32 (12-15) — ширина изображения 0800000 = 8
   int32 (16-19) — высота изображения 0800000 = 8, итого 8x8
   int16 (1A-1B) — для BMP всегда имеет значение 1
   int16 (1C-1D) — разрядность (количество бит на пиксель) изображения 1800 = 24-бит.
   int32 (1E-21) — метод компресии. 0-RGB (самый распространённый), 1-RLE8, 2-RLE4 и т.д. 0000000 = 0: RGB.
   int32 (22-25) — размер пиксельных данных в байтах. C000000 = 192: 246 (размер файла) - 14 (длина заголовка) - 40 (длина информации о файле) = 192 байта, всё сходится.
   int32 (26-29) — количество пикселей на метр по-горизонтали. C40E000 = 3780, что соответствует 96 пикселям/дюйм
   int32 (2A-2D) — количество пикселей на метр по-вертикали. C40E000 = 3780.
   int32 (2E-31) — количество цветов в палитре, для 24-бит не используется
   int32 (32-35) — количество используемых цветов в палитре, тоже не используется.


Наконец, от смещения 36h до самого конца идут пиксели в формате RGB по три байта (int24 Little endian). Данные идут построчно, но снизу вверх, с выравниванием до 4 байт в строке. Например, изображение 2x2


в байтах выглядело бы так:
FF 00 00 00 00 00 00 00
00 00 FF 00 BB 00 00 00
Поскольку ширина равна двум пикселям, а записав строку по три байта на компонент цвета, получилось бы 6 байт, то требуется ещё выравнивание до кратности 4 байтам, то есть добавляются два байта с любыми значениями, но обычно 0.


Но вернёмся к примеру 8х8. Для удобства, сгруппировал эти данные по три байта и на одной строке разместил 3*8=24 байта, что соответствует изображению 8x8. 24 кратно 4, поэтому выравнивание здесь не используется.

5CE975 5CE975 5CE975 5CE975 5CE975 5CE975 5CE975 5CE975
5CE975 5CE975 5CE975 5CE975 5CE975 5CE975 577AB9 5CE975
E2CC72 E2CC72 E2CC72 E2CC72 E2CC72 E2CC72 577AB9 E2CC72
E2CC72 E2CC72 E2CC72 E2CC72 E2CC72 4CB122 577AB9 4CB122
E2CC72 E2CC72 E2CC72 E2CC72 E2CC72 4CB122 4CB122 4CB122
E2CC72 9DFAFF E2CC72 E2CC72 E2CC72 4CB122 4CB122 4CB122
9DFAFF 00F2FF F8F8F8 F8F8F8 E2CC72 E2CC72 4CB122 E2CC72
E2CC72 9DFAFF E2CC72 F8F8F8 F8F8F8 E2CC72 E2CC72 F8F8F8


Если прочитать эти триплеты в Little endian и представить в виде цветов, то получим:








Отразив по-вертикали, получаем итоговое изображение:
sample-preview.png


Как кодировщик рассчитывает размер файла при записи
До этого мы играли роль декодировщика и по байтам пытались прочитать изображение, но как ведёт себя кодировщик, когда ему нужно закодировать массив пикселей в валидный bmp файл? Первое же препятствие — записать размер файла ещё до того, как файл сформирован.

На самом деле всё просто. Кодировщику приходит массив пикселей с уже известным размером, также приходят опции, к примеру, сохранить файл в 24-битном bmp. Заголовок с информацией о файле занимает 54 байта, остаётся только посчитать длину одной строки пикселей с учётом выравнивания и умножить на высоту.

На примере файла 8x8
Ширина: 8 пикс.
Для кодирования одного пикселя в RGB требуется 3 байта
Для кодирования одной строки пикселей: 3*8 = 24 байта
24 делится на 4, значит, никаких дополнительных байт выравнивания учитывать не требуется.
Высота: 8 пикс.
Для кодирования 8 строк пикселей: 8*24 = 192 байта
Заголовок файла: 14 байт
Информация о файле: 40 байт
Итого размер файла: (14+40) + 8 (высота) * 3*8 = 246 байт

Дальше нужно просто записать всё в файл.


Баг кодировщика bmp у Bethesda
Так что же не так с bmp файлом от Bethesda? Давайте посмотрим на заголовок файла и информацию об изображении ScreenShot10.bmp.gz (не забудьте разархивировать файл):

42 4D 36 06 30 00 00 00 00 00 36 00 00 00 28 00
00 00 56 05 00 00 00 03 00 00 01 00 18 00 00 00
00 00 00 06 30 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00


Размер файла: 36063000 = 3147318 байт
Ширина: 56050000 = 1366 пикс.
Высота: 00030000 = 768 пикс.
Размер пиксельных данных: 00063000 = 3147264 байт

Ширина и высота правильные; размер файла в заголовке соответствует реальному размеру файла, при этом он больше размера пиксельных данных на 54 байта, значит, всё правильно, при условии, что размер пиксельных данных посчитан верно.
Проверим же это.

Представим, что нам пришел массив пикселей 1366x768 и нужно закодировать в bmp 24-бит:
Ширина: 1366 пикс.
Для кодирования одного пикселя в RGB требуется 3 байта
Для кодирования одной строки пикселей: 3*1366 = 4098 байта
4098 mod 4 = 2, то есть строку нужно дополнить двумя байтами, чтобы делилось на 4
Длина одной строки с учётом смещения: 4098+2 = 4100 байт
Высота: 768 пикс.
Для кодирования 768 строк пикселей: 768*4100 = 3148800 байта

Выходит, размер пиксельных данных должен быть 3148800, но в файле он 3147264.
Посмотрим, что насчитали в Bethesda: 3147264 / 768 (высота) = 4098

Бинго! Не учтено выравнивание до 4 байт!


Исправляем
Чтобы исправить файл, нужно записать в заголовок правильный размер пиксельных данных и правильный размер файла, а также 768 раз дополнить данные изображения двумя произвольными байтами — байтами выравнивания.

Разумеется, вручную это делать не стоит, но раз уж взялись за Hex редактор, можно немного и поизменять значения. Поскольку пикселей в изображении 1366x768 = 1049088, для ширины 1366 требуется выравнивание по 2 байта, а его нет, почему бы не изменить ширину так, чтобы выравнивание не требовалось, а количество пикселей при этом не изменялось? Добиться этого легко, если в несколько раз увеличить ширину, уменьшив при этом высоту.

Если взять вместо 1366x768 изображение 2732x384, количество пикселей так и останется 1049088, но при этом 2732*3 = 8196 делится на 4 без остатка, а значит выравнивание не требуется! Записываем новые ширину и высоту: 2732 dec = 0AAC hex, 384 dec = 0180 hex, в int32 Little endian будет AC 0A 00 00 80 01 00 00. Записываем эти значения по оффсету 12h:
shot-20200515T182207.png

Цитата Совет:
Можно поставить курсор на первое значение, например, высоты (оффсет 16h) и в поле напротив 32-bit integer изменить значение на 384.
shot-20200515T181620.png
shot-20200515T181624.png

Сохраняем, открываем bmp в просмотрщике и видим
cover2.jpg

При таком представлении, получается, что первая строка пикселей оригинального изображения занимает первую строку левой части картинки, вторая строка пикселей оригинального изображения — первую строку правой части, третья строка — вторую строку левой, четвёртая — вторую строку правой и так далее.

Если теперь обрезать одну из сторон и растянуть по высоте до 768, то получим примерное представление о содержимом.
cover3.jpg


Полноценно исправляем
Но вернёмся к правильному восстановлению. Скрипт исправления будет на языке Python. Открываем битый файл и проверяем сигнатуру BMP:
  1. from struct import pack, unpack
  2. # ...
  3. with open(path, 'rb') as f:
  4.     if f.read(2) != b'BM':
  5.         sys.exit("Invalid BMP file signature")

Дальше читаем размер файла, ширину, высоту и высчитываем, нужны ли исправления:
  1. file_size, = unpack('<I', f.read(4))
  2. buf1 = f.read(12)
  3. width, height = unpack('<II', f.read(8))
  4. padding_length = width * 3 % 4
  5. line_length = width * 3 + padding_length
  6. valid_file_size = 54 + line_length * height
  7. if (padding_length == 0) or (file_size == valid_file_size):
  8.     sys.exit("This BMP file does not require a fix")
Для преобразования байт в Little endian используется struct.unpack с форматом <I (unsigned int32 Little endian).

Создаём новый файл и записываем заголовок и информацию о файле уже с правильными значениями:
  1. fixed_path = path.with_name(path.stem + '_fixed.bmp')
  2. with open(fixed_path, 'wb') as fout:
  3.     fout.write(b'BM')
  4.     fout.write(pack('<I', valid_file_size))
  5.     fout.write(buf1)
  6.     fout.write(pack('<II', width, height))
  7.     fout.write(f.read(8))
  8.     # Write valid pixel data length
  9.     f.read(4)
  10.     fout.write(pack('<I', valid_file_size - 54))
  11.     # Copy remaining header
  12.     fout.write(f.read(16))

Наконец, построчно копируем width*3 байт из неправильного bmp, но теперь уже добавляем padding_length байт выравнивания:
  1. for y in range(height):
  2.     fout.write(f.read(width * 3))
  3.     fout.write(b'\0' * padding_length)
  4. fout.flush()

Готовый скрипт здесь: https://annimon.com/code/5232

После окончания работы скрипта, появится исправленный файл:
ScreenShot10_fixed.jpg
+10   10   0
1152