packages feed

moonlight-planar-1.0.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 (replicateM)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Except (ExceptT, runExceptT, throwE)
import Data.Binary.Get
  ( Get
  , getInt64be
  , getWord16be
  , getWord64be
  , runGetOrFail
  )
import Data.Binary.Put
  ( putInt64be
  , putWord16be
  , putWord64be
  , runPut
  )
import Data.Bits ((.&.), complement, shiftL)
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
  ( HexLayout
  , HexLayoutObstruction
  , HexRegion
  , hexLayout
  , hexLayoutCellCount
  , hexLayoutHeight
  , hexLayoutOrigin
  , hexLayoutWidth
  , hexLayoutWordCount
  , foldHexRegionPackedWords
  , hexRegionGenerate
  , 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
  | HexNonCanonicalPadding !Word64
  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 ()
  serializedWords <- U.fromList <$> lift (replicateM (hexLayoutWordCount layout) getWord64be)
  validatePadding layout serializedWords
  pure (hexRegionGenerate layout (serializedMember layout serializedWords))

validatePadding :: HexLayout -> U.Vector Word64 -> HexDecoder ()
validatePadding layout wordsValue =
  let mask = finalWordMask layout
      canonical =
        U.ifoldl'
          (\valid index word ->
              valid
                && ( index + 1 < hexLayoutWordCount layout
                       || word .&. complement mask == 0
                   )
          )
          True
          wordsValue
   in if canonical
        then pure ()
        else
          let padding = U.foldl' (\_ word -> word .&. complement mask) 0 wordsValue
           in throwE (HexNonCanonicalPadding padding)

serializedMember :: HexLayout -> U.Vector Word64 -> HexCoord -> Bool
serializedMember layout wordsValue (HexCoord q r) =
  let HexCoord originQ originR = hexLayoutOrigin layout
      index = (r - originR) * hexLayoutWidth layout + (q - originQ)
      (wordIndex, bitIndex) = index `quotRem` 64
   in wordsValue `U.unsafeIndex` wordIndex .&. (1 `shiftL` bitIndex) /= 0
{-# INLINE serializedMember #-}

finalWordMask :: HexLayout -> Word64
finalWordMask layout = lowBitMask (((hexLayoutCellCount layout - 1) `rem` 64) + 1)

lowBitMask :: Int -> Word64
lowBitMask count
  | count >= 64 = maxBound
  | count <= 0 = 0
  | otherwise = (1 `shiftL` count) - 1

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)