packages feed

moonlight-planar-1.1.0.0: src-serialize/Moonlight/Hex/Serialization.hs

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}

-- | Versioned canonical bytes for native packed hexagonal regions.
module Moonlight.Hex.Serialization
  ( HexDecodingBudget (..)
  , HexSerializationError (..)
  , hexSerializationVersion
  , encodeHexRegion
  , decodeHexRegion
  ) where

import Control.DeepSeq (NFData)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Except (ExceptT, runExceptT, throwE)
import Data.Binary.Get
  ( Get
  , getByteString
  , getInt64be
  , getWord16be
  , getWord64be
  , runGetOrFail
  )
import Data.Binary.Put
  ( putInt64be
  , putWord16be
  , putWord64be
  , runPut
  )
import Data.Bits ((.|.), shiftL)
import Data.ByteString qualified as BS
import Data.ByteString.Unsafe qualified as BSU
import Data.ByteString.Lazy qualified as BL
import Data.Int (Int64)
import Data.Vector.Unboxed qualified as U
import Data.Word (Word16, Word64)
import GHC.Generics (Generic)
import Moonlight.Hex.Coordinate (HexCoord (..))
import Moonlight.Hex.Region
  ( HexLayoutObstruction
  , HexPackedRegionObstruction (..)
  , HexRegion
  , hexLayout
  , hexLayoutCellCount
  , hexLayoutHeight
  , hexLayoutOrigin
  , hexLayoutWidth
  , hexLayoutWordCount
  , foldHexRegionPackedWords
  , hexRegionFromPackedWords
  , hexRegionLayout
  )

-- | Bounds checked before any payload-sized allocation.
data HexDecodingBudget = HexDecodingBudget
  { hexDecodingMaximumInputBytes :: !Word64
  , hexDecodingMaximumCells :: !Word64
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (NFData)

data HexSerializationError
  = HexBinaryDecodeFailure !Int64 !String
  | HexTrailingBytes !Word64
  | HexInvalidFormatMagic !Word64
  | HexUnsupportedFormatVersion !Word16
  | HexInputByteBudgetExceeded !Word64 !Word64
  | HexCellBudgetExceeded !Word64 !Word64
  | HexCoordinateOutsideInt !Int64 !Int64
  | HexExtentOutsideInt !Word64 !Word64
  | HexSerializedLayoutInvalid !HexLayoutObstruction
  | HexSerializedPackedRegionInvalid !HexPackedRegionObstruction
  deriving stock (Eq, Show, Generic)
  deriving anyclass (NFData)

hexSerializationVersion :: Word16
hexSerializationVersion = 1

formatMagic :: Word64
formatMagic = 0x4d4f4f4e48455801 -- "MOONHEX" + format family 1

encodeHexRegion :: HexRegion -> BL.ByteString
encodeHexRegion region = runPut $ do
  let layout = hexRegionLayout region
      HexCoord originQ originR = hexLayoutOrigin layout
  putWord64be formatMagic
  putWord16be hexSerializationVersion
  putInt64be (fromIntegral originQ)
  putInt64be (fromIntegral originR)
  putWord64be (fromIntegral (hexLayoutWidth layout))
  putWord64be (fromIntegral (hexLayoutHeight layout))
  foldHexRegionPackedWords
    (\writeWords word -> writeWords *> putWord64be word)
    (pure ())
    region

decodeHexRegion
  :: HexDecodingBudget
  -> BL.ByteString
  -> Either HexSerializationError HexRegion
decodeHexRegion budget bytes
  | inputBytes > hexDecodingMaximumInputBytes budget =
      Left (HexInputByteBudgetExceeded inputBytes (hexDecodingMaximumInputBytes budget))
  | otherwise =
      case runGetOrFail (runExceptT (getHexRegion budget)) bytes of
        Left (_, offset, message) -> Left (HexBinaryDecodeFailure offset message)
        Right (_, _, Left obstruction) -> Left obstruction
        Right (trailing, _, Right region)
          | BL.null trailing -> Right region
          | otherwise -> Left (HexTrailingBytes (fromIntegral (BL.length trailing)))
 where
  inputBytes = fromIntegral (BL.length bytes)

type HexDecoder = ExceptT HexSerializationError Get

getHexRegion :: HexDecodingBudget -> HexDecoder HexRegion
getHexRegion budget = do
  magic <- lift getWord64be
  if magic == formatMagic
    then pure ()
    else throwE (HexInvalidFormatMagic magic)
  version <- lift getWord16be
  if version == hexSerializationVersion
    then pure ()
    else throwE (HexUnsupportedFormatVersion version)
  q64 <- lift getInt64be
  r64 <- lift getInt64be
  width64 <- lift getWord64be
  height64 <- lift getWord64be
  (origin, width, height) <-
    case (int64PairToCoord q64 r64, word64ToInt width64, word64ToInt height64) of
      (Just coordinate, Just widthValue, Just heightValue) ->
        pure (coordinate, widthValue, heightValue)
      (Nothing, _, _) -> throwE (HexCoordinateOutsideInt q64 r64)
      (_, Nothing, _) -> throwE (HexExtentOutsideInt width64 height64)
      (_, _, Nothing) -> throwE (HexExtentOutsideInt width64 height64)
  layout <- either (throwE . HexSerializedLayoutInvalid) pure (hexLayout origin width height)
  let cellCount = fromIntegral (hexLayoutCellCount layout)
  if cellCount > hexDecodingMaximumCells budget
    then throwE (HexCellBudgetExceeded cellCount (hexDecodingMaximumCells budget))
    else pure ()
  let wordCount = hexLayoutWordCount layout
  serializedBytes <- lift (getByteString (wordCount * 8))
  let serializedWords = decodePackedWords wordCount serializedBytes
  either (throwE . HexSerializedPackedRegionInvalid) pure (hexRegionFromPackedWords layout serializedWords)

decodePackedWords :: Int -> BS.ByteString -> U.Vector Word64
decodePackedWords wordCount bytes = U.generate wordCount decodeWord
 where
  decodeWord :: Int -> Word64
  decodeWord wordIndex =
    -- 'getByteString' admitted exactly @wordCount * 8@ bytes before this
    -- representation boundary; the eight indexed bytes therefore exist.
    let byteOffset = wordIndex * 8
        byte :: Int -> Word64
        byte index = fromIntegral (BSU.unsafeIndex bytes (byteOffset + index))
     in byte 0 `shiftL` 56
          .|. byte 1 `shiftL` 48
          .|. byte 2 `shiftL` 40
          .|. byte 3 `shiftL` 32
          .|. byte 4 `shiftL` 24
          .|. byte 5 `shiftL` 16
          .|. byte 6 `shiftL` 8
          .|. byte 7
{-# INLINE decodePackedWords #-}

int64PairToCoord :: Int64 -> Int64 -> Maybe HexCoord
int64PairToCoord q r = HexCoord <$> int64ToInt q <*> int64ToInt r

int64ToInt :: Int64 -> Maybe Int
int64ToInt value
  | toInteger value < toInteger (minBound :: Int) = Nothing
  | toInteger value > toInteger (maxBound :: Int) = Nothing
  | otherwise = Just (fromIntegral value)

word64ToInt :: Word64 -> Maybe Int
word64ToInt value
  | toInteger value > toInteger (maxBound :: Int) = Nothing
  | otherwise = Just (fromIntegral value)