Введение

Предположим, мы читаем двоичную последовательность из файла (или слушаем на проводе) и хотим преобразовать её в некоторую осмысленную структуру. Для этого нам нужен парсер – вычислительный процесс, который, грубо говоря, разобъёт бездушную последовательность единиц и нулей на несущие смысл компоненты и сконструирует из них нашу структуру.

И наоборот, мы хотим сохранить известную структуру данных на диск (например, для последующего архивирования) или передать по сети другому компьютеру. Нам нужно провести обратную операцию – анпарсинг, т.е. преобразовать структуру в последовательность единиц и нулей.

В обоих случаях мы бы хотели, чтобы правила (т.н. грамматика) парсинга и анпарсинга для одной и той-же структуры были согласованы (например, если б это было не так, то записанный на флэш-карту процессором цифрового фотоаппарата файл JPEG был бы нечитаем на других компьютерах).

В комплекте с Haskell идёт замечательный пакет binary, который предоставляет монады Get и Put, а также комбинаторы для высокопроизводительной (1 G/s) десериализации и сериализации двоичных данных в/из типов Haskell. Ветку модулей Data.Binary я использовую для создания Haskell-бииблиотеки чтения/записи игровых карт программы TacOps (файлов с расширением .dat).

Представление местности TacOps на Haskell

Формат карты TacOps – это проприетарный, закрытый формат, о котором ничего не известно. Однако, некоторое время назад я сделал попытку его реверс-инженеринга и выложил спецификацию на GitHub. Вкратце о формате .dat: заголовок с информацией (в частности, о количестве горизонтальных и вертикальных локаций) и блок с последовательными описаниями локаций карты (по два байта на ячейку).

Пример .dat карты TacOps (с оверлеем)

Для начала смоделируем общие особенности местности: высоты, мобильность, лес, город и т.д. Я использую для них те-же названия, что и в самой TacOps: например, E1 означает “первый уровень высоты” (в противопоставление нулевому), NOGO2 – непроходимость для колёсной и гусеничной техники, Losblock – непроходимость линии визирования (оптического и тепловизионного) и т.д.

module TacopsTerrain where

data PrimaryTerrain = Clear
                    | NOGO1 -- no-go for wheeled
                    | NOGO2 -- no-go for wheeled & tracked
                    | NOGO3 -- no-go for vehicles & dismounts
                    | Rough1
                    | Rough2
                    | Rough3
                    | Rough4
                    | Water
       deriving (Eq, Show)

data SecondaryTerrain = Losblock
                      | Road
                      | Woods
                      | Town
       deriving (Eq, Show)

data Elevation = E0 | E1 deriving (Eq, Show)

Теперь опишем тип самой игровой карты TacOps, а также тип одной локации (ячейки) на карте.

data DatMap = DatMap
  { mapNum :: Int
  , mapWidth :: Int
  , mapHeight :: Int
  , mapBmpWidth :: Int
  , mapBmpHeight :: Int
  , mapEasting :: Int
  , mapNorthing :: Int
  , mapVersion :: Int
  , mapName :: String
  , mapCells :: [DatCell]
  } deriving (Show)

data DatCell = DatCell
  { cellPri :: PrimaryTerrain
  , cellSecs :: [SecondaryTerrain]
  , cellElev :: Elevation
  } deriving (Show)

Как видно (и это указано в моей спеке на гитхабе), значений типа SecondaryTerrain в одной локации может быть несколько (например, дорога и лес).

Тайпкласс Binary

Тайпкласс Binary объединяет все типы, которые (де)сериализабельны. Чтобы уметь читать и писать карты из .dat-файлов, нам всего-навсего потребуется реализовать интерфейс Binary для нашей структуры DatMap (это наша репрезентации игровой карты TacOps).

Необходимый интерфейс Binary:

class Binary t where
    put :: t -> Put -- encode a value in the Put monad
    get :: Get t -- decode a value in the Get monad

Монады Get t и Put это монады состояния входного (выходного) бинарного потока. Монадическое связывание предоставляет подобие read-only и write-only контекстов для парсинга и анпарсинга соответственно.

Данные потребляются и автоматически изымаются из входной последовательности в монаде Get. Все комбинаторы начинаются на слово get, le и be означает little и big endian, соответственно, цифры означают количество битов в машинных словах, getByteString читает произвольную последовательность в крутейший тип ByteString.

Пример Get монады (парсера):

hdr <- getWord16le
skip 1
payload <- getByteString 79
cksum <- getWord32le

Напротив, в монаде Put бинарные данные автоматически наращиваются комбинаторами put*:

putWord16le 280
putWord8 0
putLazyByteString payload
putWord8 0

И так далее. Познакомиться с чудесными модулями Data.Binary.Get и Data.Binary.Put можно на Hackage.

Итак, напишем интерфейс к Binary! А Haskell позаботиться обо всём остальном, чтобы мы могли читать и писать игровые карты TacOps.

Ввиду динамичности SecondaryTerrain в одной локации, мы для удобства и простоты чтения кода напишем по три вспомогательные функции для наших парсера и анпарсера:

  1. Преобразование между двумя байтами (“Flags byte” и “Terrain byte”, как я назвал их в спеке) и типом DatCell – нашей репрезентации локации на карте
  2. (Де)кодирование одной ячейки DatCell
  3. (Де)кодирование списка ячеек, т.е. [DatCell]

Десериализатор структуры DatMap

Довольно прямолинейный парсинг, который просто проскакивает неизвестные поля заголовка файла (формат проприетарный…):

instance Binary DatMap where
  get = do
    num <- getWord16le
    skip 4
    width <- getWord16le
    height <- getWord16le
    skip 4
    bmpwidth <- getWord16le
    bmpheight <- getWord16le
    skip 32
    easting <- getWord16le
    northing <- getWord16le
    version <- getWord16le
    name <- getLazyByteString 7
    skip 1 -- the C-string's \NULL byte
    cells <- getRemainingLazyByteString
    return $ DatMap
      (f num)
      (f width)
      (f height)
      (f bmpwidth)
      (f bmpheight)
      (f easting)
      (f northing)
      (f version)
      (BL8.unpack name)
      (runGet decodeCells cells)
      where
        f = fromEnum

Парсер в конце использует наш подпарсер decodeCells для парсинга собственно длиннющего хвоста файла (в котором-то и находятся ячейки). Тот, в свою очередь, опирается на парсинг всего одной ячейки-локации (функция decodeCell).

decodeCells :: Get [DatCell]
decodeCells = do
  e <- isEmpty
  case e of
    True -> return []
    False -> do
      dc <- decodeCell
      dcs <- decodeCells
      return (dc:dcs)

decodeCell :: Get DatCell
decodeCell = do
  secsb <- getWord8
  prib <- getWord8
  return $ (bytesToDatCell prib secsb)

Последняя функция, в свою очередь, использует bytesToDatCell для конструирования локации datCell из “сырых” байтов:

bytesToDatCell :: Word8 -> Word8 -> DatCell
bytesToDatCell prib secsb = DatCell pri secs elev
  where pri = case prib of
                0x00 -> Clear
                0x01 -> NOGO1
                0x02 -> NOGO2
                0x04 -> NOGO3
                0x08 -> Rough1
                0x10 -> Rough2
                0x18 -> Rough3
                0x20 -> Rough4
                0x30 -> Water
                _ -> Clear -- Stay safe from occasional "junk" on the edges of some maps
        elev = case (secsb .&. 0x08) of
                 0 -> E0
                 0x08 -> E1
        secs = f secsb
        f b | b .&. 0x02 == 0x02 = [Losblock] ++ f (b `xor` 0x02)
        f b | b .&. 0x20 == 0x20 = [Road] ++ f (b `xor` 0x20)
        f b | b .&. 0x40 == 0x40 = [Woods] ++ f (b `xor` 0x40)
        f b | b .&. 0x80 == 0x80 = [Town] ++ f (b `xor` 0x80)
        f b | otherwise = []

Здесь мы применяем обычные логические операции над битовыми масками для заглядывания “внутрь” байта “первичных” и байта “вторичных флагов” локации. При этом, в файле двухбайтовая ячейка лежит “вторичным” байтом вперёд.

Сериализатор структуры DatMap

Продолжаем описание instance Binary DatMap. Нам нужно написать put t где t это наша структура. Вот наш анпарсер:

  put (DatMap n w h bmpw bmph ea no ver name dcs) = do
    putWord16le (toEnum n)
    replicateM_ 4 $ putWord8 0
    putWord16le (toEnum w)
    putWord16le (toEnum h)
    replicateM_ 4 $ putWord8 0
    putWord16le (toEnum bmpw)
    putWord16le (toEnum bmph)
    replicateM_ 6 $ putWord8 0
    putWord16le (toEnum bmpw)
    putWord16le (toEnum bmph)
    replicateM_ 22 $ putWord8 0
    putWord16le (toEnum ea)
    putWord16le (toEnum no)
    putWord16le (toEnum ver)
    putLazyByteString (BL8.pack (take 7 name)) -- take only 7 characters
    putWord8 0 -- C-string \NULL
    encodeCells dcs

Входной тип DatMap деконструируется на составные части паттерн-матчингом Haskell. Мы затем начинаем последовательно и нудно писать эти части в поток; код почти-что зеркален с методом get. Перед блоком с ячейками идёт семисимвольное имя карты (так, видимо, когда-то решил разработчик..) и мы должны быть аккуратны, чтобы случайно не вписать туда сколь угодно длинную строку из нашего типа String (который мы используем для репрезентации). После семи ASCII символов мы любезно кладём нуль, чтобы удовлетворить концепцию строк в языке Си. Комбинатор replicateM_ повторяет монадическое выражение n раз и не собирает результат.

encodeCells :: [DatCell] -> Put
encodeCells dcs= do
  foldM_ (\_ dc -> encodeCell dc) () dcs


encodeCell :: DatCell -> Put
encodeCell dc = do
  let (prib, secsb) = datCellToBytes dc
  putWord8 secsb
  putWord8 prib

datCellToBytes :: DatCell -> (Word8, Word8)
datCellToBytes (DatCell pri secs elev) = (prib, secsb)
  where prib = case pri of
                 Clear -> 0x00
                 NOGO1 -> 0x01
                 NOGO2 -> 0x02
                 NOGO3 -> 0x04
                 Rough1 -> 0x08
                 Rough2 -> 0x10
                 Rough3 -> 0x18
                 Rough4 -> 0x20
                 Water -> 0x30
        bits = foldr (\s acc -> f s .|. acc) 0x00 secs
        f Losblock = 0x02
        f Road = 0x20
        f Woods = 0x40
        f Town = 0x80
        secsb = case elev of
          E0 -> bits
          E1 -> bits .|. 0x08

Проверка

Теперь мы можем считывать и записывать .dat карту TacOps из битового потока с помощью операций:

encode :: Binary a => a -> ByteString
decode :: Binary a => ByteString -> a

и других функций из Data.Binary вроде decodeFile.

Пример чтения карты из файла (я подставляю пустой список, чтобы не выводить на экран длиннющий список локаций):

λ> d <- decodeFile "../Map003c.dat" :: IO DatMap
λ> show d{mapCells =[]}
"DatMap {mapNum = 3, mapWidth = 74, mapHeight = 54, mapBmpWidth = 731, mapBmpHeight = 531, mapEasting = 0, mapNorthing = 0, mapVersion = 300, mapName = \"Map003c\", mapCells = []}"

Пример сохранения карты в файл:

λ> BL.writeFile "../Map003c_my.dat" (encode d)
λ> d' <- decodeFile "../Map003c_my.dat" :: IO DatMap
λ> d == d'
True

Видно, что считанная из оригинального .dat-файла и считанная из созданного нами .dat-файла карты эквивалентны. Вот и всё что требуется для поддержки карт TacOps!

Код /= документация

Как нетрудно догадаться, следующее уравнение всегда должно выполнятся для нормальных экземпляров класса Binary:

decode . encode == id

Мы можем показать, что это так, в том и только в том случае, если и запись и чтение данных производятся с использованием одной и той-же спецификации (описания формата файла).

Ещё одна классическа карта для операций на батальонном уровне

Поскольку TacOps – это программа с закрытым исходным текстом, мне многое неизвестно о других полях в заголовке .dat-файла. При чтении из файла я просто проскакиваю неизвестные байты, и это не мешает работе – вся информация из карты отображается корректно в репрезентации. Однако, это также означает, что я и не могу записать данные в эти неизвестные поля заголовка файла, а значит уравнение выше не выполняется.

Надо бы проверить, читает ли стандартный редактор карт TacOps (под Windows) сериализованные моим кодом карты… Думаю, что да.

Напоминание всем поклонникам кодовой политики ядра Linux: исходный код не является надлежащей документацией к закрытому протоколу или формату файла.