Using RGB types to gain intuition about Type Level Programming and Generics

Disclaimer!!!

This has been done with the help of Gemini: Lord knows I can’t come up with the Type level stuff yet on my own. The words typed in this post are all my own; most of the Haskell code has been generated by Gemini bar some name changes while I poked and prodded and comments are mine.

As it currently stands, after three days (closer to one year on and off now), I still can’t recall/or independently come up with this specific solution, meaning I need a much more solid way to learn this.

Full conversation: here

Context

I’m working my way up to build different kinds of renderers for the first time and I was interested in texture formats and how best to represent them. Texture formats are represented by stuffing all the information into a byte-aligned container some-bits wide: often 32-bits. Furthermore, the bit patterns can vary depending on the format. For example: RGBA8 features 4x8 bit channels for the Red, Green, Blue and Extra channels and R9G9B9A5 features 3x9 bits for the R,G and B channels and 5 for the Extra channel etc.

I’ve been inspired mainly by Rebecca Skinner’s Intro to Type Level Programming with the instance theming and Haskell Unfolder Episode 28: Type families and Overlapping Instances and been trying to include more involved type stuff in my personal work and avoid my automatic inclination to use Typeclasses and KindSignatures for absolutely everything.

I’ll focus on the final result but if there is any interest in the initial solutions and why I rejected them, then I’ll share that info. Earlier solutions can be seen in the conversation.

The nicest solution (IMO) so far…

Key types, classes and instances

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE FlexibleContexts #-}

import Data.Bits (shiftL, (.&.), (.|.), shiftR)
import Data.ByteString.Builder (toLazyByteString, word16LE, word32LE)
import Data.ByteString.Lazy (LazyByteString)
import Data.Proxy
import GHC.TypeLits (KnownNat, natVal)
import GHC.TypeNats (Nat)
import GHC.Word (Word16, Word32)
import qualified GHC.TypeNats as Nat

-- | A field representing a value, the Nat corresponds to the width in bits of the field.
newtype Field (w :: Nat) = Field Word32

-- | A data to combine fields together to list the widths of each field in order
data BitStructure (fs :: [Nat]) where
  End :: BitStructure '[]
  (:&:) :: KnownNat w => Field w -> BitStructure ws -> BitStructure (w ': ws)
infixr 5 :&:


-- | A class that uses the type level Nats to manipulate the bits of 
-- the final underlying structure
class BitManip (fs :: [Nat]) where
  packFields   :: BitStructure fs -> Word32
  unpackFields :: Word32 -> BitStructure fs
  totalWidth   :: Proxy fs -> Int

instance BitManip '[] where
  packFields End = 0
  unpackFields _ = End
  totalWidth _   = 0

instance (KnownNat w, BitManip ws) => BitManip (w ': ws) where
  totalWidth _ = fromIntegral (natVal (Proxy @w)) + totalWidth (Proxy @ws)

  packFields (Field val :&: rest) =
    let width      = fromIntegral (natVal (Proxy @w))
        restWidth  = totalWidth (Proxy @ws)
        maskedVal  = val .&. ((1 `shiftL` width) - 1)
    in (maskedVal `shiftL` restWidth) .|. packFields rest

  unpackFields word =
    let width      = fromIntegral (natVal (Proxy @w))
        restWidth  = totalWidth (Proxy @ws)
        mask       = (1 `shiftL` width) - 1
        val        = (word `shiftR` restWidth) .&. mask
    in Field val :&: unpackFields word

-- | Lets us calculate the width of the type level list (aka, the widths of each field in the bit structure)
-- REQUIRES UndecidableInstances!!!
type family SumWidths a where
  SumWidths '[]       = 0
  SumWidths (n ': ns) = n Nat.+ (SumWidths ns)


-- | Allows us to convert textures to records and back again based on a layout specified in the type-level list of widths.
-- Possibly my favourite thing so far.
class TextureRecord r where
  type Layout r :: [Nat]
  toStruct :: r -> BitStructure (Layout r)
  fromStruct :: BitStructure (Layout r) -> r

-- 
data RGBA8 etype = RGBA8 {
    r8 :: Word32,
    g8 :: Word32,
    b8 :: Word32,
    a8 :: Word32 
  } deriving Show

instance TextureRecord (RGBA8888 e) where
  type Layout (RGBA8888 _) = '[8,8,8,8]
  toStruct (RGBA8888 r g b a) = 
    Field r :&: Field g :&: Field b :&: Field a :&: End
  fromStruct (Field r :&: Field g :&: Field b :&: Field a :&: End) = 
    RGBA8888 r g b a

Results

The ‘A’ in RGBA

Firstly, I wanted to be able to determine what the “extra” value was in a field at the type level. A kind signature was effective here.

data ExtraType = Alpha | Emissivity

data RGBA8 (etype :: ExtraType) = RGBA8 {  -- i.e. RGBA8 Alpha
    r8 :: Word32,
    g8 :: Word32,
    b8 :: Word32,
    a8 :: Word32 
  } deriving Show
Creating new format types

Appears to be as easy as defining the record and then specifying its layout in its TextureRecord instance. However, I currently do not like having to manually create the fromStruct and toStruct functions. This feels like it can be determined by whatever Layout Type is.

data RGBA8 (etype :: ExtraType) = RGBA8 {
    r8 :: Word32,
    g8 :: Word32,
    b8 :: Word32,
    a8 :: Word32 
  } deriving Show

instance TextureRecord (RGBA8 e) where
  type Layout (RGBA8 _) = '[8,8,8,8]
  toStruct (RGBA8 r g b a) = 
    Field r :&: Field g :&: Field b :&: Field a :&: End
  fromStruct (Field r :&: Field g :&: Field b :&: Field a :&: End) = 
    RGBA8 r g b a

data R9G9B9A5 (etype :: ExtraType) = R9G9B9A5 {
    r9 :: Word32,
    g9 :: Word32,
    b9 :: Word32,
    a5 :: Word32 
  } deriving Show

instance TextureRecord (R9G9B9A5 e) where
  type Layout (R9G9B9A5 _) = '[9,9,9,5]
  toStruct (R9G9B9A5 r g b a) = 
    Field r :&: Field g :&: Field b :&: Field a :&: End
  fromStruct (Field r :&: Field g :&: Field b :&: Field a :&: End) = 
    R9G9B9A5 r g b a

data RG16 (etype :: ExtraType) = RG16 {
    r16 :: Word32,
    g16 :: Word32
  } deriving Show

instance TextureRecord (RG16 e) where
  type Layout (RG16 _) = '[16,16]
  toStruct (RG16 r g) = 
    Field r :&: Field g :&: End
  fromStruct (Field r :&: Field g :&: End) = 
    RG16 r g
Using packed records
ghci> showHex (packRecord $ RGBA8 255 0 0 1) ""
"ff000001"
ghci> showHex (packRecord $ RGBA8 256 0 0 1) ""
"1"
ghci> showHex (packRecord $ RGBA8 257 0 0 1) ""
"1000001"
ghci> showHex (packRecord $ RGBA8 999 0 0 1) ""
"e7000001"
ghci> showHex (packRecord $ RGBA8 999 0 0x113 1) ""
"e7001301"
ghci> showHex (packRecord $ RGBA8 999 0 0xff 1) ""
"e700ff01"
ghci> showHex (packRecord $ RGBA8 0x77 0x56 0xff 0x12) ""
"7756ff12"
ghci> :t RGBA8888 0x77 0x56 0xff 0x12
RGBA8888 0x77 0x56 0xff 0x12 :: RGBA8888 etype

I need to figure out how to handle values that are too wide for the field. Naturally, the SumWidths instance can only tell if the Fields add up to too great a width, but there is nothing to otherwise constrain the values.

Cons

Currently, all records are specified as Word32 . This feels particularly wasteful when the RGBA8 records could be specified as four Word8s. Changing Field into a datatype with constructors Field8 Word8 | Field16 Word16 | Field32 Word32 feels like the most sensible thing to do.

I tried doing so, but the main pain comes when trying to make the unpackFields function work. I need a function from (Int/Nat) →(CanBeFielded a → Field).

  unpackFields word =
    let width      = fromIntegral (natVal (Proxy @w))
        getFieldT w
          | w <= 8 = Field8
          | w <= 16 = Field16
          | otherwise = Field32
        restWidth  = totalWidth (Proxy @ws)
        mask       = (1 `shiftL` width) - 1
        val        = (word `shiftR` restWidth) .&. mask
    in (getFieldT width) val :&: unpackFields word

• Couldn't match type ‘Word32’ with ‘Word8’
  Expected: Word8 -> Field w1
    Actual: Word32 -> Field w1
• In the expression: Field32
  In an equation for ‘getFieldT’:
      getFieldT w
        | w < 16 = Field8
        | w < 32 = Field16
        | otherwise = Field32
  In the expression:
    let
      width = fromIntegral (natVal (Proxy @w))
      getFieldT w
        | w < 16 = Field8
        | w < 32 = Field16
        | otherwise = Field32
      restWidth = totalWidth (Proxy @ws)
      ....
    in (getFieldT width) val :&: unpackFields word

Where next?

I think one more thing I would really like is to be able to use generic programming to generate the TextureRecord instances more automatically. This feels like something that should be possible but I’m not quite sure how yet and I refuse to prompt Gemini/ask for more help until I can freely replicate this solution independently. This will be my first time attempting anything with GHC.Generics so a lot to read and prepare for.

Recently I encountered this in the toml-parser package where you could deriving Generic` and then deriving (ToTable, ToValue, FromValue) via GenericTomlTable Type which I would really like to replicate here

Final Thoughts

I quite like this solution so far, it satisfies my immediate requirement which was to have a nice way of interacting creating arbitrary texture formats.

A list of field widths is fairly intuitive and not something that I will be changing once defined which is pretty awesome. Furthermore, packing and unpacking 32-bit values. (In reality, I can’t see myself needing to unpack them either which is great!).

I can see how this pattern can extend out to other bit widths which is pretty exciting.

Another thing I am not comfortable with is the wealth of language extensions required to get this working so far. At the moment, I’ve been just enabling extensions whenever the compiler or tutorial suggests I do but now I’ll definitely be doing more reading and making more effort into understanding what each extension does.

On a more personal note

Given how much I’ve struggled to learn and understand type level programming, it is disheartening to see the machine do it so effortlessly and almost flawlessly. In one sense, it’s great to have this tool available but in another sense, after three years of progress, it’s a little bit crushing to feel so far off despite how far I’ve come.

Oh well. Such is life! Time to build a heckin’ renderer.

7 Likes

Nice job. More type-level bit/bytewise libraries! :slight_smile:

Do note that many of the language extensions you use are enabled by the GHC2021 language edition, which is a modern agreed-upon set of language extensions. These shouldn’t be scary to use universally e.g. FlexibleInstances, DataKinds (though it’s nice to know what they do).

In particular, UndecidableInstances gets (or used to get?) a lot of flak. It’s not really problematic safety-wise. Requiring it usually indicates that you’re doing some complex type-level work, where GHC is unable to guarantee that typechecking will terminate. So it permits you writing code that would hang the compiler. It’s not good to keep on by default, but it’s rarely much more than an “oh, I am writing interesting type families now” indicator. (Please someone correct me if I’m wrong.)

4 Likes

Type data are cool :slight_smile: and I’m always glad to see someone interesting in graphical programming.

I am actually working on improving memory abstractions and one of my applied test cases is in porting an old C game engine of mine, so color spaces, pixel formats, and c struct / bitfield layouts are naturally interesting to me on several fronts.

I don’t have a finalized solution quite yet, but my current approach mirrors that of Data.Vector.Unboxed by having a type MemRep that declared a backing storage type which is effective enough for now, and I am leaning towards eg using something like MemTensor [w,h,channels,depth] Bit or MemTensor [w,h,channels] Byte for homogenous RGB sizes, but inhomogenous formats like R5G6B5 are awkward and are technically ragged tensors so I am still pondering them. On the other hand, such formats are usually for storage only, are usually converted to some homogenous format upon loading, and could be represented via MemMatrix w h pxfmt


Don’t give up on type-level programming, it is a powerful worth learning to wield.

Thanks! This probably won’t end up being a library, still got a lot to learn and just experimenting at the moment! binrep does look interesting! In this case, I would be creating BLen RGBA8 instances that looks something along the lines of:

instance BLen (RGBA8 e) where
  blen (RGBA8 r g b a) = blen r + blen g + blen b + blen a

right? And then I imagine I can wrangle the KnownNat n => BLen (Sized n a) instance for the ones which don’t fit neatly into Words?

Need to do some reading :smiley:

That is good to know :slight_smile:

Thank you! Definitely needed to fulfill my ultimate goal and I’m really looking forward to being able to intuitively build robust graphic representations of simulations :slight_smile:

I think this is why the current implementation I have currently just works with every field being a Word32before eventually truncating everything down into whatever size it needs to be. What would be really nice to be able to dynamically choose a container that fits the width specified.

I’m not familiar with the Data.Vector.Unboxed approach but that will make for some interesting reading, thank you :slight_smile:

2 Likes

If Fields has a type level parameter being number of bits in a field, couldn’t you do this.

type family BitAmountToData where 
    BitAmountToData 8 = Word8
    BitAmountToData 16 = Word16
...

Or a gadt approach, which would allow for Word as well.

1 Like

I don’t think I follow. Would I have to make an entry for every possible width? What would I do in the cases where I have non-standard widths like RGB565? Is there a way I could calculate a suitable width based on the type-level Nat?

Never mind, I think I misread your post.

Nupe! You can do some type-level logic like this

-- NOTE: Only necessary for this module
{-# LANGUAGE UndecidableInstances #-}

module U where

import GHC.TypeNats
import Prelude
import Data.Type.Bool

-- NOTE: Could do some shmancy extra logic to extract the size
-- *from the storage type too* but eh have some type-level tuples instead
type HostStorage = 
    [ '(8,  Word8)
    , '(16, Word16)
    , '(32, Word32)
    , '(64, Word64)
    ]

type family FindSmallest (n :: Nat) (ts :: [(Nat, *)]) :: * where
    FindSmallest n ('(limit, t) ': xs) = 
        If (n <=? limit) t (FindSmallest n xs)
    FindSmallest n '[] = Integer

newtype U (n :: Nat) = MkU { unU :: FindSmallest n HostStorage }

deriving newtype instance Eq   (FindSmallest n HostStorage) => Eq   (U n)
deriving newtype instance Ord  (FindSmallest n HostStorage) => Ord  (U n)
deriving newtype instance Num  (FindSmallest n HostStorage) => Num  (U n)
deriving newtype instance Show (FindSmallest n HostStorage) => Show (U n)

And then you can test it:

ghci> x = unU $ (5 :: U 4)
ghci> x
5
ghci> :t x
x :: Word8

This doesn’t get to the level of bit-packing quite yet, so R5, G6, B5 would still get stored as Word8s, but I am working on it and hopefully you can see how this leads there :slight_smile:

2 Likes

This is quite cool! :smiley: I think so long as the final structure is a packed bytearray, having a higher-level representation to base from is totally fine. That said, matching containers to the closest suitable size would be ideal!

I came up with a similar approach to see if I could leverage Fields of different widths.

data Field (w :: Nat) where
  Field8 :: Word8 -> Field w
  Field16 :: Word16 -> Field w
  Field32 :: Word32 -> Field w
  Field :: Word64 -> Field w

-- which led to changing BitManip instances to look something like:

class BitManip (fs :: [Nat]) where
  packFields :: BitStructure fs -> Word64
  unpackFields :: Word64 -> BitStructure fs
  totalWidth :: Proxy fs -> Int

instance BitManip '[] where
  packFields End = 0
  unpackFields _ = End
  totalWidth _ = 0

instance (KnownNat w, BitManip ws) => BitManip (w ': ws) where
  totalWidth _ = fromIntegral (natVal (Proxy @w)) + totalWidth (Proxy @ws)

  packFields (Field8 val :&: rest) =
    let width = fromIntegral (natVal (Proxy @w))
        restWidth = totalWidth (Proxy @ws)
        maskedVal = val .&. ((1 `shiftL` width) - 1)
     in (fromIntegral maskedVal `shiftL` restWidth) .|. packFields rest
  packFields (Field16 val :&: rest) =
    let width = fromIntegral (natVal (Proxy @w))
        restWidth = totalWidth (Proxy @ws)
        maskedVal = val .&. ((1 `shiftL` width) - 1)
     in (fromIntegral maskedVal `shiftL` restWidth) .|. packFields rest
  packFields (Field32 val :&: rest) =
    let width = fromIntegral (natVal (Proxy @w))
        restWidth = totalWidth (Proxy @ws)
        maskedVal = val .&. ((1 `shiftL` width) - 1)
     in (fromIntegral maskedVal `shiftL` restWidth) .|. packFields rest
  packFields (Field val :&: rest) =
    let width = fromIntegral (natVal (Proxy @w))
        restWidth = totalWidth (Proxy @ws)
        maskedVal = val .&. ((1 `shiftL` width) - 1)
     in (maskedVal `shiftL` restWidth) .|. packFields rest

  unpackFields word =
    let width = fromIntegral (natVal (Proxy @w))
        whichField w
          | w <= 8 = Field8 . fromIntegral
          | w <= 16 = Field16 . fromIntegral
          | w <= 32 = Field32 . fromIntegral
          | otherwise = Field
        restWidth = totalWidth (Proxy @ws)
        mask = (1 `shiftL` width) - 1
        val = (word `shiftR` restWidth) .&. mask
     in (whichField width) val :&: unpackFields word 

Annoyingly however, this means that I now need to match all other cases fromStruct with an error case, even though they should not be possible to as far as I can tell!

instance TextureRecord (RGBA8 e) where
  type Layout (RGBA8 _) = '[8, 8, 8, 8]
  toStruct (RGBA8 r g b a) =
    Field8 r :&: Field8 g :&: Field8 b :&: Field8 a :&: End
  fromStruct (Field8 r :&: Field8 g :&: Field8 b :&: Field8 a :&: End) =
    RGBA8 r g b a
  fromStruct _ = error "Not possible"

Working example for GHC.Generics

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE DefaultSignatures #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE FunctionalDependencies #-}

import Data.Bits (shiftL, (.&.), (.|.), shiftR)
import Data.Proxy
import GHC.TypeLits (KnownNat, natVal)
import GHC.TypeNats (Nat)
import GHC.Word (Word16, Word32)
import qualified GHC.TypeNats as Nat
import GHC.Generics
import Data.Kind (Type,Constraint)
-- | A field representing a value, the Nat corresponds to the width in bits of the field.
newtype Field (w :: Nat) = Field Word32

-- | A data to combine fields together to list the widths of each field in order
data BitStructure (fs :: [Nat]) where
  End :: BitStructure '[]
  (:&:) :: KnownNat w => Field w -> BitStructure ws -> BitStructure (w ': ws)
infixr 5 :&:


-- | A class that uses the type level Nats to manipulate the bits of 
-- the final underlying structure
class BitManip (fs :: [Nat]) where
  packFields   :: BitStructure fs -> Word32
  unpackFields :: Word32 -> BitStructure fs
  totalWidth   :: Proxy fs -> Int

instance BitManip '[] where
  packFields End = 0
  unpackFields _ = End
  totalWidth _   = 0

instance (KnownNat w, BitManip ws) => BitManip (w ': ws) where
  totalWidth _ = fromIntegral (natVal (Proxy @w)) + totalWidth (Proxy @ws)
  packFields (Field val :&: rest) =
    let width      = fromIntegral (natVal (Proxy @w))
        restWidth  = totalWidth (Proxy @ws)
        maskedVal  = val .&. ((1 `shiftL` width) - 1)
    in (maskedVal `shiftL` restWidth) .|. packFields rest
  unpackFields word =
    let width      = fromIntegral (natVal (Proxy @w))
        restWidth  = totalWidth (Proxy @ws)
        mask       = (1 `shiftL` width) - 1
        val        = (word `shiftR` restWidth) .&. mask
    in Field val :&: unpackFields word
---------------------------------------------
-- Generic machinery
---------------------------------------------
data Peano = Zero | Succ Peano
type family Length (xs :: [Nat]) :: Peano where
    Length '[] = Zero
    Length (x ': xs) = Succ (Length xs)
type family Add a b where 
  Add Zero b = b
  Add (Succ a) b = Succ (Add a b)
class HasField a b where
  toField :: a -> Field b
  fromField :: Field b -> a
instance HasField Word32 8 where
  toField = Field
  fromField (Field w) = w
class Append lst1 lst2 where
    type AppendResult lst1 lst2 :: [Nat]
    append :: BitStructure lst1 -> BitStructure lst2 -> BitStructure (AppendResult lst1 lst2)
instance Append '[] lst2 where
    type AppendResult '[] lst2 = lst2
    append End bs = bs
instance (KnownNat w, Append ws lst2) => Append (w ': ws) lst2 where
    type AppendResult (w ': ws) lst2 = w ': AppendResult ws lst2
    append (Field val :&: rest) bs = Field val :&: append rest bs
type SplitStruct :: Peano -> [Nat] -> Constraint
class SplitStruct len bignats  where
    type SplitLeft len bignats :: [Nat]
    type SplitRight len bignats :: [Nat]
    splitStruct ::  BitStructure bignats -> (BitStructure (SplitLeft len bignats), BitStructure (SplitRight len bignats))
instance SplitStruct Zero bignats where
    type SplitLeft Zero bignats = '[]
    type SplitRight Zero bignats = bignats
    splitStruct  bs = (End, bs)
instance (KnownNat w,SplitStruct len bignats) => SplitStruct (Succ len) (w ': bignats) where
    type SplitLeft (Succ len) (w ': bignats) = w ': SplitLeft len bignats
    type SplitRight (Succ len) (w ': bignats) = SplitRight len bignats
    splitStruct (Field val :&: rest) = let (subStruct, bigStruct) = splitStruct @len @bignats rest
                                            in (Field val :&: subStruct, bigStruct)
type TextureRecordGeneric :: (k -> Type) -> [Nat] -> Peano -> Constraint
class (Length flds ~ len)=>TextureRecordGeneric a flds len | flds -> len, a -> len where
  toStructGeneric :: a x -> BitStructure flds
  fromStructGeneric :: BitStructure flds -> a x
instance TextureRecordGeneric a flds len => TextureRecordGeneric (M1 i c a) flds len where
    toStructGeneric (M1 x) = toStructGeneric x
    fromStructGeneric bs = M1 (fromStructGeneric bs)
instance (HasField a b, KnownNat b) => TextureRecordGeneric (K1 i a) '[b] (Succ Zero) where
    toStructGeneric (K1 x) = toField x :&: End
    fromStructGeneric (f :&: End) = K1 (fromField f)
instance (ab ~ AppendResult a' b', Length ab ~ ablen,SplitLeft alen ab ~ a', SplitRight alen ab ~ b', ablen ~ Add alen blen,
    Append a' b',SplitStruct alen ab,TextureRecordGeneric a a' alen, TextureRecordGeneric b b' blen)
      => TextureRecordGeneric (a :*: b) ab ablen where
    toStructGeneric (a :*: b) = append @a' @b' (toStructGeneric a) (toStructGeneric b)
    fromStructGeneric bs = let (aStruct, bStruct) = splitStruct @alen @ab bs
                            in fromStructGeneric aStruct :*: fromStructGeneric bStruct

------------------------------------------------------------------------------------------
-- | Allows us to convert textures to records and back again based on a layout specified in the type-level list of widths.
-- Possibly my favourite thing so far.
type Layout :: Type -> [Nat]
type family Layout r
class TextureRecord r where
    toStruct :: r -> BitStructure (Layout r)
    fromStruct :: BitStructure (Layout r) -> r
    default toStruct :: (TextureRecordGeneric (Rep r) (Layout r) (Length (Layout r)), Generic r) => r -> BitStructure (Layout r)
    toStruct = toStructGeneric . from
    default fromStruct :: (TextureRecordGeneric (Rep r) (Layout r) (Length (Layout r)), Generic r) => BitStructure (Layout r) -> r
    fromStruct = to . fromStructGeneric
-- | Example usage of Generic machinery to create a texture record for an RGBA8 texture, where each channel is 8 bits wide.
type instance Layout (RGBA8 etype) = '[8,8,8,8]
data RGBA8 etype = RGBA8 {
    r8 :: Word32,
    g8 :: Word32,
    b8 :: Word32,
    a8 :: Word32
  } deriving (Show,Generic,TextureRecord)

Type errors are pretty bad, especially if a Label type isn’t made. Detecting the undetectable: custom type errors for stuck type families – ( ) might help if you want to give good errors. You can also use deriving via instead of deriving any for the derivation. (I didn’t use AI, so there’s no comments, and the variables might be bad)

1 Like

Wow thank you! At a first glance this looks like what I’m looking for. Generics seem a lot more involved than I was initially hoping but I feel like with something like this problem, it is very helpful :slight_smile:

I’ll study this implementation, making some good notes and I’ll certainly be asking questions!

It’s not really that generics are hard, it’s more that evidence of constraints/fundeps are hard. For example, I had to completely rework the generic class to include a length parameter, and use a functional dependency to make sure that there was only one possibility for the length of the list used in SplitStruct. This caused me to also need to rework SplitStruct. This strategy is use HLS, find errors, fix, repeat. It feels like fighting the compiler, because all you do is fix compiler errors. However, the compiler tries its best, try not to get too frustrated.

1 Like

@tobz619 - Expanding upon my earlier example with some type-level stuff from the deep!

First, we need to turn on some more language features, because it’s going to get funky:

-- ...
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE NoStarIsType #-}
-- For defining fieldWidth and fieldOffset
{-# LANGUAGE AllowAmbiguousTypes #-}

And give our U type a few more instances:

-- ...
deriving newtype instance Enum (FindSmallest n HostStorage) => Enum (U n)
deriving newtype instance Bounded (FindSmallest n HostStorage) => Bounded (U n)
deriving newtype instance Bits (FindSmallest n HostStorage) => Bits (U n)
deriving newtype instance FiniteBits (FindSmallest n HostStorage) => FiniteBits (U n)
deriving newtype instance Real (FindSmallest n HostStorage) => Real (U n)
deriving newtype instance Integral (FindSmallest n HostStorage) => Integral (U n)

And we also need this handy typealias:

type KnownU (n :: Nat) =
    ( KnownNat n
    -- , Eq (U n)
    -- , Ord (U n)
    -- , Num (U n)
    -- , Show (U n )
    -- , Enum (U n)
    -- , Bounded (U n)
    , Bits (U n)
    -- , FiniteBits (U n)
    , Real (U n)
    , Integral (U n)
    )

Why have we done this? So we can do this:

-- A named and sized field
-- NOTE: We are using Symbol here, but we could also have used data kinds. 
newtype Field (name :: Symbol) (width :: Nat) = Field (U width)

-- An unpacked collection of fields
data Bitfield (fs :: [Type]) where
    Nil  :: Bitfield '[]
    Cons :: Field name width -> Bitfield fs -> Bitfield (Field name width : fs)

-- A class for packing things into fields
class (KnownU (PackedWidth fs)) => Packable (fs :: [Type]) where
    type PackedWidth fs :: Nat
    packFields :: Bitfield fs -> U (PackedWidth fs)

instance Packable '[] where
    type PackedWidth '[] = 0
    packFields Nil = 0

instance
    ( KnownU width, KnownU (width + PackedWidth fs), Packable fs
    ) => Packable (Field name width : fs) where
    type PackedWidth (Field name width : fs) = width + PackedWidth fs
    packFields (Cons (Field val) rest) = 
        let offset  = fromIntegral $ natVal (Proxy @(PackedWidth fs))
            bits    = fromIntegral val `shiftL` offset
        in  bits .|. fromIntegral (packFields rest)

-- A newtype for packed fields
newtype Packed (fs :: [Type]) = Packed { getPacked :: U (PackedWidth fs) }

deriving newtype instance Eq   (U (PackedWidth fs)) => Eq   (Packed fs)
deriving newtype instance Ord  (U (PackedWidth fs)) => Ord  (Packed fs)
deriving newtype instance Num  (U (PackedWidth fs)) => Num  (Packed fs)
deriving newtype instance Show (U (PackedWidth fs)) => Show (Packed fs)
deriving newtype instance Enum (U (PackedWidth fs)) => Enum (Packed fs)
deriving newtype instance Bounded (U (PackedWidth fs)) => Bounded (Packed fs)
deriving newtype instance Bits (U (PackedWidth fs)) => Bits (Packed fs)
deriving newtype instance FiniteBits (U (PackedWidth fs)) => FiniteBits (Packed fs)
deriving newtype instance Real (U (PackedWidth fs)) => Real (Packed fs)
deriving newtype instance Integral (U (PackedWidth fs)) => Integral (Packed fs)

-- A convenient function
pack :: (Packable fs) => Bitfield fs -> Packed fs
pack fs = Packed $ packFields fs

-- A color format
type R5G6B5 = '[Field "Red" 5, Field "Green" 6, Field "Blue" 5]

-- A packed color value
packR5G6B5 :: U 5 -> U 6 -> U 5 -> Packed R5G6B5
packR5G6B5 r g b = pack
    $ Cons (Field @"Red" r)
    $ Cons (Field @"Green" g)
    $ Cons (Field @"Blue" b)
    $ Nil

-- Example:
-- ghci> packR5G6B5 1 2 3
-- 2115

Alrighty, so that’s great for packing values into a bitfield, how about getting them out again? We use some type families and a typeclass to provide convenient access!

type family FieldOffset (name :: Symbol) (fs :: [Type]) :: Nat where
    FieldOffset name (Field name w : fs) = PackedWidth fs
    FieldOffset name (Field other w : fs) = FieldOffset name fs
    FieldOffset name '[] = TypeError
        (     'Text "FieldOffset: Field not found: "
        ':<>: 'ShowType name
        ) -- Or '0'

type family FieldWidth (name :: Symbol) (fs :: [Type]) :: Nat where
    FieldWidth name (Field name w : fs) = w
    FieldWidth name (Field other w : fs) = FieldWidth name fs
    FieldWidth name '[] = TypeError
        (     'Text "FieldWidth: Field not found: "
        ':<>: 'ShowType name
        ) -- Or '0'

fieldWidth :: forall name fs. (KnownU (FieldWidth name fs)) => Int
fieldWidth = fromIntegral $ natVal (Proxy @(FieldWidth name fs))

fieldOffset :: forall name fs. (KnownU (FieldOffset name fs)) => Int
fieldOffset = fromIntegral $ natVal (Proxy @(FieldOffset name fs))

-- A class for accessing specific fields from within packed data
class
    ( KnownU (FieldOffset name fs), KnownU (FieldWidth name fs), Packable fs
    ) => PluckField (name :: Symbol) (fs :: [Type]) where
    pluck :: Packed fs -> U (FieldWidth name fs)

instance
    ( KnownU (FieldWidth name fs), KnownU (FieldOffset name fs), Packable fs
    ) => PluckField name fs where
    pluck (Packed val) =
        let offset = fieldOffset @name @fs
            width  = fieldWidth @name @fs
            mask   = (1 `shiftL` width) - 1
            bits = val `shiftR` offset
        in fromIntegral (mask .&. bits)

pluckGreen :: Packed R5G6B5 -> U 6
pluckGreen = pluck @"Green"

-- Example:
-- ghci> pluckGreen $ packR5G6B5 1 2 3
-- 2

And viola! :partying_face:

1 Like

Oh my goodness this looks incredible and provides a really interesting API for defining and retrieving values.

I’m struggling to make sense of it all now (mainly because of the layers of type constraints and inherited instances) but I would like to really sit down and trace through yours and @ashokkimmel ‘s implementations, poke and prod them, and write them up in plain English so I have a solid reference for what’s going on. A job for a few evenings and weekends :smiley:

I think one reason why I’m find Type Level Programming so hard is because at the value level, I can use types to prove this value is allowed to be here but with types, I’m the one having to prove that these types can even exist together in the first place AND I think I’m struggling to intuit with when I’m working with a type or a value.

Also, I had no idea you could use type aliases to alias class constraints! Learn something new every day :smiley:

Thank you so much, this is a really awesome resource for me :slight_smile:

1 Like

Updated to include updating a packed field:

class
    (    KnownU (FieldOffset name fs), KnownU (FieldWidth name fs), Packable fs
    ) => PokeField (name :: Symbol) (fs :: [Type]) where
    poke :: U (FieldWidth name fs) -> Packed fs -> Packed fs

instance
    (    KnownU (FieldOffset name fs), KnownU (FieldWidth name fs), Packable fs
    ) => PokeField name fs where
    poke field (Packed bits) =
        let offset = fieldOffset @name @fs
            width  = fieldWidth @name @fs
            mask   = ((1 `shiftL` width) - 1) `shiftL` offset -- Cacheable, eg via FieldMask or something
            val    = fromIntegral field `shiftL` offset
        in Packed $ (bits .&. complement mask) .|. val

pokeGreen :: U 6 -> Packed R5G6B5 -> Packed R5G6B5
pokeGreen = poke @"Green"

-- Example:
-- ghci> pluckGreen $ pokeGreen 4 $ packR5G6B5 1 2 3
-- 4
2 Likes

Ooo now this is quite interesting because I was thinking it would be possible to update packed fields and thought it would’ve been a bit of a ballache but this is basically holy grail :smiley: This means I can just can just update individual channels in place without having to pack and overwrite the value every single frame which is pretty sweet!

I really like how your implementation keeps track of not only the state of the format but also supports arbitrary formats and ordering.

As an aside, I’ve been playing around with your U newtype and trying to test arranging things in different ways to see if I understand what’ I’m doing with them.

newtype U (n :: Nat) = MkU {unU :: FromSizeToField n}

deriving newtype instance (Eq (FromSizeToField n)) => Eq (U n)

--  deriving all the instances ...

deriving newtype instance (Integral (FromSizeToField n)) => Integral (U n)

-- | KnownU is a class for converting Type-level Nats to Types 
class KnownU (n :: Nat) where
  -- | Calculates the type from the field itself
  type FromSizeToField n :: Type
  -- | Provides constraints that should also hold when using this class
  type WithConstraints n :: Constraint

instance KnownU w where
-- Figures out a container size based on the value of w.  
type
    FromSizeToField w =
      If (w Nat.<=? 8) Word8
        ( If (w Nat.<=? 16) Word16
            (If (w Nat.<=? 32) Word32 (
              If (w Nat.<=? 64) Word Integer))
        )
-- | Not sure what this is doing at this time tbh other than
-- any function that uses (WithConstraints w) => also sets these constraints maybe?
  type WithConstraints w = (KnownNat w, Bits (U w), Real (U w), Integral (U w))

And it compiles…no idea if it’s legal

1 Like

I plan on upgrading the Field type to support a base memory unit a la newtype Field' (name :: Symbol) (width :: Nat) u = Field (U width) where type Field n w = Field' n w Bit so then I can also talk about eg Byte or Float-based memory structures.


Keep at it! :smiling_face_with_three_hearts:

1 Like

So, I’ve been able to intuit how to go about writing unpack. Not that I think I’d be needing it but it was a good exercise.

class (Packable fs) => Unpackable (fs :: [Type]) where
  unpack :: U (PackedWidth fs) -> BitStructure fs

instance Unpackable '[] where
  unpack _ = End

instance
  ( KnownU w,
    KnownU (PackedWidth ws),
    Packable (Field name w ': ws),
    Unpackable ws
  ) =>
  Unpackable (Field name w ': ws)
  where
  unpack val =
    let width = fromIntegral $ natVal (Proxy @w)
        offset = fromIntegral $ natVal (Proxy @(PackedWidth ws))
        extracted = (val `shiftR` offset) .&. ((1 `shiftL` width) - 1) -- Be careful not to truncate val too early
     in Field @name @w (fromIntegral extracted) :&: unpack @ws (fromIntegral val) 

As a curiosity, I was wondering if it was possible to create pack in a varargs kind of manner. Inspired by this Haskell unfolder episode I tried to give it a go and unfortunately failed to come up anything with myself.

Eventually, I gave up and Gemini came up with something that I later adapted closer to my use case.

class PackToTarget (target :: [Type]) r where
  packStep :: (BitStructure target -> r) -> WithFields target r

type family WithFields (fs :: [Type]) r where
  WithFields '[] r = r
  WithFields (Field name w ': ws) r = Field name w -> WithFields ws r

instance PackToTarget '[] r where
  packStep fill = fill End

instance (KnownU w, PackToTarget ws r) => PackToTarget (Field name w ': ws) r where
  packStep fillFunc field = packStep @ws @r (\rest -> fillFunc (field :&: rest))
packAs' ::
  forall struct.
  (KnownU (PackedWidth struct), Packable struct, PackToTarget struct (U (PackedWidth struct))) =>
  WithFields struct (U (PackedWidth struct))
packAs' = packStep @struct @(U (PackedWidth struct)) pack

type RG16 = [Field 'R 16, Field 'G 16]

packRG16 :: Field R 16 -> Field G 16 -> U 32
packRG16 r g = packAs' @RG16 r g -- works fine 
-- >>> showHex (packRG16 0xefef 0xabab) "" 
-- "efefabab"

packRG16' r g = Packed $ packAs' @RG16 r g -- complains that PackedWidth fs could not be determined the same as 32
-- >>> showHex (packRG16' 0xefef 0xabab) ""
-- Couldn't match type `PackedWidth fs0_a1lQeo[tau:1]' with `32'
--   arising from a use of packRG16'
-- The type variable `fs0_a1lQeo[tau:1]' is ambiguous
-- In the first argument of `showHex', namely
--   `(packRG16' 0xefef 0xabab)'
-- In the expression: showHex (packRG16' 0xefef 0xabab) ""
-- In an equation for `it_a1lQcr':
--     it_a1lQcr = showHex (packRG16' 0xefef 0xabab) ""

Again, makes for good reading and something to inspect, but still a bit annoyed how…nebulous and how difficult I’m finding all of this understand.

I’m guessing that in my case, I can just get rid of the Packed newtype wrapper since the U value is basically the same thing - but it would be *really* nice to have that safety wrapper carried around :confused: .

Still, this implementation is really nice as I only have to specify the layout once and the required arguments are figured out for me :slight_smile:

EDIT:

Of course, Packed also needs the type application.

packRG16' :: Field R 16 -> Field G 16 -> Packed RG16 -- say it here and it's an RG16 here and it's all fine!
packRG16' r g = Packed $ (packAs' @RG16 r g)
-- >>> showHex (packRG16' 0xefef 0xabab) ""
-- "efefabab"

This though is quite interesting as it doesn’t quite fully restrict things. It allows things that fit the same container to be packed into each other. But I think in practice, this will be hard to encounter accidentally. Actually this may even be a benefit as similar types and widths may be coerced into each other on the fly!

-- If the packing function says it's an RGBA8 Alpha, it will be treated like one if the container fits!
packRG16' :: Field R 16 -> Field G 16 -> Packed (RGBA8 Alpha)
packRG16' r g = Packed $ (packAs' @RG16 r g)
-- >>> showHex (packRG16' 0xefef 0xabab) ""
-- "efefabab"
1 Like