Что скрыто в файлах персонажей Doki Doki Literature Club
от aNNiMON
Визуальная новелла Doki Doki Literature Club! привлекла меня тем, что содержит множество загадок, которые при первом прочтении заметить невозможно. Рассказывать обо всех я не стану, лучше сами скачайте — игра бесплатна. А я, постараясь без спойлеров, расскажу о том, что скрыто в файлах персонажей из папки characters/ и как самому сделать нечто подобное. В конце вас ждёт небольшой квест.
Кому лень читать, есть видео:
Декодируем
В папке characters/ лежат четыре файла, по одному на каждую героиню. В них зашифрована некоторая информация.
monika.chr
natsuki.chr
sayori.chr
yuri.chr
Счастливые обладатели Linux получают подсказку сразу при открытии папки:

Юри
Начнём с текстового файла yuri.chr. Выведем первые и последние 50 байт.
Два знака равно в конце файла говорят о том, что перед нами текст в base64. Раскодируем его:
yuri.txt
Сайори
sayori.chr — аудио в формате ogg. В этом можно убедиться, выполнив:
sayori.ogg
Если прослушать аудио в ffplay, будет видна спектрограмма:

Это напомнило мне времена ZX Spectrum, когда программы записывались на магнитную ленту в виде звука.
Очевидно, что перед нами qr-код. Осталось только достать изображение. В этом поможет ffmpeg с фильтром showspectrumpic:
С параметрами фильтра можно ознакомиться в документации. Основная проблема была с выбором нужного размера (параметр s). Если пропорции не совпадали, то qr-код мог обрезаться или быть слишком сжатым. saturation=0 делает картинку чёрно-белой, scale=4thrt добавляет контраста в изображение, а legend=0 убирает легенду из результирующего изображения. После showspectrumpic применяется инверсия цвета — фильтр negate. В итоге получаем:

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


http://projectlibitina.com
Нацуки
Файл natsuki.chr — изображение в формате jpeg.

Можно сразу открывать в редакторе, а можно предварительно сделать инверсию цвета и отражение по-вертикали:

Остаётся только преобразовать изображение в полярные координаты. В GIMP это Filters -> Distorts -> Polar Coordinates:


Моника
Наконец, monika.chr. Это png-изображение размером 800x800.

В центре изображения расположен чёрно-белый рисунок. Вырезаем его:

Теперь каждому чёрному пикселю ставим в соответствие бит 0, а каждому белому — 1. Получаем последовательность из нулей и единиц длиной 140*140 = 19600.
Преобразуем биты в символы. Получится 2450 байт текста:
Скрипт, который делает всю работу:
Символ = в конце полученного текста снова говорит, что перед нами base64. Декодируем:
monika.txt
Кодируем
Теперь поставим обратную задачу — закодировать необходимые данные, чтобы получить такие же .chr файлы.
Юри
Здесь всё просто. Берём текст, кодируем его в base64 без переносов строки (-w 0) и сохраняем в .chr файл:
Сайори
Для начала нужно подготовить картинку с qr-кодом. Можно воспользоваться генератором от Google:
Далее в графическом редакторе можно её немного исказить.

Теперь кодируем изображение в аудиофайл, воспользовавшись https://github.com/alexadam/img-encode
Там есть онлайн версия, но я воспользуюсь скриптом на Python:
en_sayori.ogg
Нацуки
Фильтр Polar Coordinates работает в обе стороны. На сей раз нам потребуется круглое или овальное изображение и такие параметры:

Дальше делаем инверсию цвета, сохраняем картинку в jpg и меняем расширение на .chr.
Моника
Для начала закодируем текст в base64:
Дальше нужно взять полученный текст и каждый ASCII код символа перевести в двоичное число и на месте 0 бита закрашивать картинку чёрным, а на месте 1 — белым цветом. Вот скрипт:
Конвертируем текст в изображение:

Теперь осталось только наложить это изображение в центр другого изображения.
Квест
Напоследок, предлагаю пройти небольшой квест и декодировать файл персонажа, назовём её Лизой:
lisa.chr
JUST MONIKA
Кому лень читать, есть видео:

Декодируем
В папке characters/ лежат четыре файла, по одному на каждую героиню. В них зашифрована некоторая информация.




Счастливые обладатели Linux получают подсказку сразу при открытии папки:

Юри
Начнём с текстового файла yuri.chr. Выведем первые и последние 50 байт.
- $ head -c 50 yuri.chr
- SWYgeW91IGZvdW5kIHRoaXMgbm90ZSBpbiBhIHNtYWxsIHdvb2
- $ tail -c 50 yuri.chr
- FsbHkgcmVhbGx5LCByZWFsbHkgaG9wZSBzby4NCg0KfuKZpQ==
Два знака равно в конце файла говорят о том, что перед нами текст в base64. Раскодируем его:
- $ cat yuri.chr | base64 -d > yuri.txt
- $ head -c 50 yuri.chr
- If you found this note in a small wooden box with
- $ tail -c 50 yuri.txt
- is you. I actually really, really hope so.
- ~♥

Сайори
sayori.chr — аудио в формате ogg. В этом можно убедиться, выполнив:
- $ file sayori.chr
- sayori.chr: Ogg data, Vorbis audio, mono, 44100 Hz, ~110000 bps, created by: Xiph.Org libVorbis I

Если прослушать аудио в ffplay, будет видна спектрограмма:

Это напомнило мне времена ZX Spectrum, когда программы записывались на магнитную ленту в виде звука.
Очевидно, что перед нами qr-код. Осталось только достать изображение. В этом поможет ffmpeg с фильтром showspectrumpic:
- ffmpeg -i sayori.chr -lavfi showspectrumpic='s=300x510:saturation=0:scale=4thrt:legend=0',negate -y sayori_qr.png
С параметрами фильтра можно ознакомиться в документации. Основная проблема была с выбором нужного размера (параметр s). Если пропорции не совпадали, то qr-код мог обрезаться или быть слишком сжатым. saturation=0 делает картинку чёрно-белой, scale=4thrt добавляет контраста в изображение, а legend=0 убирает легенду из результирующего изображения. После showspectrumpic применяется инверсия цвета — фильтр negate. В итоге получаем:

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


http://projectlibitina.com
Нацуки
Файл natsuki.chr — изображение в формате jpeg.

Можно сразу открывать в редакторе, а можно предварительно сделать инверсию цвета и отражение по-вертикали:
- ffmpeg -i natsuki.chr -vf negate,vflip natsuki.jpg

Остаётся только преобразовать изображение в полярные координаты. В GIMP это Filters -> Distorts -> Polar Coordinates:


Моника
Наконец, monika.chr. Это png-изображение размером 800x800.

В центре изображения расположен чёрно-белый рисунок. Вырезаем его:
- ffmpeg -i monika.chr -vf crop=140:140:330:330 monika_code.png

Теперь каждому чёрному пикселю ставим в соответствие бит 0, а каждому белому — 1. Получаем последовательность из нулей и единиц длиной 140*140 = 19600.
- 01010001001100100100011001110101010010010100100001101100011...
- ...
- ...
- ...11001110011110100000000000000000000000000000000000000000000000000000000000000000000000000000000
Преобразуем биты в символы. Получится 2450 байт текста:
Открыть спойлер
Скрипт, который делает всю работу:
- #!/usr/bin/python
- import getopt
- import sys
- from PIL import Image
- def usage():
- print('Usage: monika_decode.py [-v] [FILE]')
- sys.exit(2)
- def main(argv):
- if len(argv) == 0:
- usage()
- verbose = False
- input = ''
- for arg in argv:
- if arg == '-v':
- verbose = True
- else:
- input = arg
- if len(input) == 0:
- usage()
- image = Image.open(input)
- width, height = image.size
- i = 0
- value = 0
- bits = ''
- text = ''
- for y in range(height):
- for x in range(width):
- pixel = image.getpixel((x, y))
- mean = (pixel[0] + pixel[1] + pixel[2]) / 3
- bit = 1 if mean >= 128 else 0
- bits += str(bit)
- value = value | (bit << (7 - i))
- i += 1
- if i >= 8:
- text += chr(value)
- value = 0
- i = 0
- bits += '\n'
- if verbose:
- print('Input image: {}x{}'.format(width, height))
- print('Bits:')
- print(bits)
- print('Result string:')
- print(text)
- if __name__ == '__main__':
- main(sys.argv[1:])
Символ = в конце полученного текста снова говорит, что перед нами base64. Декодируем:
- python3 monika_decode.py monika_code.png | base64 -d > monika.txt

Кодируем
Теперь поставим обратную задачу — закодировать необходимые данные, чтобы получить такие же .chr файлы.
Юри
Здесь всё просто. Берём текст, кодируем его в base64 без переносов строки (-w 0) и сохраняем в .chr файл:
- cat yuri.txt | base64 -w 0 > yuri.chr
Сайори
Для начала нужно подготовить картинку с qr-кодом. Можно воспользоваться генератором от Google:
- wget -O qr.png "https://chart.googleapis.com/chart?cht=qr&chs=512x512&choe=UTF-8&chl=https://annimon.com/"

Теперь кодируем изображение в аудиофайл, воспользовавшись https://github.com/alexadam/img-encode
Там есть онлайн версия, но я воспользуюсь скриптом на Python:
- $ ./imgencode.py -i qr.png -o qr.wav -t 8
- $ ffmpeg -i qr.wav -b:a 128k sayori.ogg
- $ mv sayori.ogg sayori.chr

Нацуки
Фильтр Polar Coordinates работает в обе стороны. На сей раз нам потребуется круглое или овальное изображение и такие параметры:

Дальше делаем инверсию цвета, сохраняем картинку в jpg и меняем расширение на .chr.
Моника
Для начала закодируем текст в base64:
- cat monika.txt | base64 -w 0 > monika_base64.txt
- #!/usr/bin/python
- import getopt
- import sys
- from math import ceil, sqrt
- from PIL import Image
- def main(argv):
- if len(argv) != 2:
- print('Usage: python monika_encode.py [INPUT_FILE] [OUTPUT_FILE]')
- sys.exit(2)
- with open(argv[0], 'r') as f:
- input = f.read()
- bits_count = len(input) * 8
- size = int(ceil(sqrt(bits_count)))
- image = Image.new("RGB", (size, size), "black")
- pixels = image.load()
- x = 0
- y = 0
- white = (255, 255, 255)
- for ch in input:
- value = ord(ch)
- for i in range(8):
- bit = (value >> (7-i)) & 1
- if bit == 1:
- pixels[x, y] = white
- x += 1
- if x >= size:
- x = 0
- y += 1
- image.save(argv[1], 'png')
- if __name__ == '__main__':
- main(sys.argv[1:])
Конвертируем текст в изображение:
- python monika_encode.py monika_base64.txt code.png

Теперь осталось только наложить это изображение в центр другого изображения.
Квест
Напоследок, предлагаю пройти небольшой квест и декодировать файл персонажа, назовём её Лизой:

JUST MONIKA