Привет, Хабр!


На днях я захотел достать ресурсы одной визуальной новеллы, созданной с помощью Ren'Py (Да, да, того самого "Бесконечного Лета"). Опытным путем было установлено, что все они хранятся в файле archive.rpa. Я нашел готовые скрипты для распаковки на Github, но решил достать их сам, Haskell в помощь...


archive.rpa


Во-первых, давайте разберемся с чем конкретно мы имеем дело, то есть как ресурсы хранятся в нашем "архиве". Откроем его в dhex или в аналогичной программе:



Что мы видим? Сначала данные о Ren'Py, потом еще что-то, а потом — начало PNG файла! Вот оно!
Долистаем до конца PNG:



Про начало и конец PNG

Тип бинарного файла можно определить по его первым байтам (т. н. "магическому числу"). У PNG "магические" байты таковы: 89 50 4e 47 0d 0a 1a 0a. Конец же PNG файла тоже всегда одинаков: 49 45 4e 44 ae 42 60 82
Список "магических чисел" других форматов можно найти здесь


Ага! PNG кончился, после него байты 4d 61 64 65 20 77 69 74 68 20 52 65 6e 27 50 79 2e (в ASCII: "Made with Ren'Py."), после чего — начало нового PNG. Вывод: данные ресурсов в .rpa архивах разделяются этой последовательностью байтов. Время подумать о программе.


Пишем код


Итак, что же конкретно должна делать программа? Вот это:


  1. Считать файл
  2. Разбить его на файлы ресурсов
  3. Записать файлы ресурсов в папку, определенную пользователем
  4. Приделать каждому файлу соответствующее расширение

Разбиваем на части


Начнем, пожалуй, с функции разбиения на файлы:


Extractor.hs:


module Extractor where

import qualified Data.ByteString.Lazy as B

import Data.List.Split

extractRes :: B.ByteString -> [B.ByteString]
extractRes bs = map B.pack $ splitOn magicSep $ B.unpack bs

magicSep = [0x4d, 0x61, 0x64, 0x65,
            0x20, 0x77, 0x69, 0x74,
            0x68, 0x20, 0x52, 0x65,
            0x6e, 0x27, 0x50, 0x79,
            0x2e]

Здесь magicSep — это те самые "магические" байты, разделяющие файлы ресурсов. Функция extractRes берет строку байтов, преобразует ее в список, разбивает его на списки байтов файлов, и каждый из этих списков преобразуется обратно в строку байтов. Остается один вопрос: зачем я использовал ленивую разновидность строк байтов? Ответ на него мы вскоре получим.


Устанавливаем расширение


ExtensionId.hs:


module ExtensionId where

import qualified Data.ByteString.Lazy as B

import Data.List (isPrefixOf)

import System.Directory

type FileType = String

readExtension :: FilePath -> IO (Maybe FileType)
readExtension path = B.readFile path >>= (return . getExtension)

writeExtension :: FilePath -> Maybe FileType -> IO ()
writeExtension _ Nothing       = return ()
writeExtension path (Just ext) = renameFile path $ path ++ ext

getExtension :: B.ByteString -> Maybe FileType
getExtension = magicLookup magicMap

magicLookup :: [(B.ByteString, FileType)] -> B.ByteString -> Maybe FileType
magicLookup [] _ = Nothing
magicLookup ((magic, fileType) : rest) bytes
  | (B.unpack magic) `isPrefixOf` (B.unpack bytes) = Just fileType
  | otherwise                                      = magicLookup rest bytes

magicMap = [(pngMagic, ".png"),
            (jpgMagic, ".jpg"),
            (oggMagic, ".ogg")]

pngMagic = B.pack [0x89, 0x50, 0x4e, 0x47]
jpgMagic = B.pack [0xff, 0xd8, 0xff]
oggMagic = B.pack [0x4f, 0x67, 0x67, 0x53]

Здесь мы видим две главные функции: readExtension и writeExtension. Несложно догадаться, что одна считывает первые байты файла и на их основе устанавливает расширение, а вторая — переименовывает файл в соответствии с ним. Всю грязную работу по определению расширения на себя берет функция magicLookup, которая просто проходит по списку известных "магических чисел" и сравнивает их с началом файла. magicMap представляет из себя список соответствий байтов и расширений, pngMagic, jpgMagic и oggMagic — "магические числа" форматов файлов. Их список может быть расширен читателем, но я точно знал, что кроме этих форматов в архиве ничего не встретится.


Уффф...


Остался наш дорогой Main.hs:


module Main where

import qualified Data.ByteString.Lazy as B

import System.Environment
import System.Directory
import System.FilePath
import System.IO

import ExtensionId
import Extractor

main :: IO ()
main = do
  args <- getArgs
  if (length args) /= 2
  then usage
  else extractToFolder (args !! 0) (args !! 1)

extractToFolder :: FilePath -> FilePath -> IO ()
extractToFolder input output = do
  b <- doesFileExist input
  if b
  then do
    b <- doesDirectoryExist output
    if b
    then do
      c <- confirm $ "Folder " ++ output ++ " already exists, overwrite it? (y/n): "
      if c
      then do
        removeDirectoryRecursive output
        createDirectory output
        extractToFolder' input output
      else return ()
    else extractToFolder' input output
  else putStrLn $ "Input file " ++ input ++ " does not exist"

extractToFolder' :: FilePath -> FilePath -> IO ()
extractToFolder' input output = do
  bytes <- B.readFile input
  let bs = extractRes bytes
  doNastyWork bs 0
  where doNastyWork []          _ = putStrLn "Done."
        doNastyWork (bs : rest) n = do
          let path = output </> ("extraction_" ++ (show n))
          B.writeFile path bs
          ext <- readExtension path
          writeExtension path ext
          putStrLn $ (show $ n + 1) ++ " files processed..."
          doNastyWork rest (n + 1)

confirm :: String -> IO Bool
confirm q = do
  putStr q
  hFlush stdout
  line <- getLine
  case head line of
    'y' -> return True
    'n' -> return False
    _ -> confirm q

usage :: IO ()
usage = putStrLn "Usage: extractrpa [ARCHIVE] [OUTPUT FOLDER]"

Что же здесь? Функция main просто проверяет количество аргументов, и если их два, то делегирует грязную работу (тоже не шибко чистой) функции extractToFolder, в противном случае посылает нас курить мануал читать usage.


В extractToFolder творится примерно то же самое: проверка корректности ввода (защита от дурака), и если папка, в которую надо выводить, не существует или пользователь дал согласие на уничтожение ее содержимого, обращается за помощью к extractToFolder'


В других языках способ обработки данных примерно такой: читаем все данные, после чего разбиваем их на части, после чего каждую записываем в файл и даем расширение. Haskell же предлагает несколько другой подход: обрабатывать данные по мере их поступления. Это возможно благодаря ленивым вычислениям (вот почему я использовал ленивые строки байтов). Такой подход позволяет пользователю лучше видеть процесс обработки, что уже неплохо. extractToFolder' — своеобразный конвейер, на котором и происходит обработка архива: читаем файл, разбиваем его на части, пока части не кончатся записываем их в файлы с уникальными именами.


Ну вот и все, набираем в терминале заветные команды:


$ ghc --make Main.hs -o extractrpa
...
$ extractrpa archive.rpa archive
...
Done.

И наслаждаемся ловко стыренным искусно добытым контентом!
Вопросы, жалобы, предложения — прошу в комментарии!

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


  1. ionicman
    06.02.2018 20:39

    Если задача была только извлечь графику — решить все это можно было Game Extractor-ом без единой строчки кода и головной боли ;)


    1. s2002kir Автор
      06.02.2018 21:40

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


  1. koldyr
    06.02.2018 21:40

    Про быстрее — не правда. unpack, splitOn и map pack должны быть вычислены, поэтому сэкономить там не получится.


    1. s2002kir Автор
      06.02.2018 21:43

      Ну, наверное, исправим.


  1. 45Green
    07.02.2018 15:49

    1. s2002kir Автор
      07.02.2018 15:49

      Да, именно этот скрипт я видел на Github.


  1. kolyandex
    07.02.2018 15:50

    Писал подобную штуку для игры Sheep Dog'n'wolf, только там немного проще было — в начале файла структуры (имя, размер, смещение) и далее собственно картинки.