diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # Revision history for bytesmith
 
+## 0.3.13.0 -- 2025-07-11
+
+* Add `hexFixedWord16#`
+* Drop support for base < 4.18, clean up CPP
+* Add more levity-monomorphized variants of bind, pure, and fail
+
 ## 0.3.11.1 -- 2024-02-28
 
 * Update package metadata.
diff --git a/bytesmith.cabal b/bytesmith.cabal
--- a/bytesmith.cabal
+++ b/bytesmith.cabal
@@ -1,6 +1,6 @@
 cabal-version:   3.0
 name:            bytesmith
-version:         0.3.11.1
+version:         0.3.13.0
 synopsis:        Nonresumable byte parser
 description:
   Parse bytes as fast as possible. This is a nonresumable parser
@@ -41,7 +41,7 @@
     Data.Bytes.Parser.Types
 
   build-depends:
-    , base                >=4.12    && <5
+    , base                >=4.18    && <5
     , byteslice           >=0.2.6   && <0.3
     , bytestring          >=0.10.8  && <0.13
     , contiguous          >=0.6     && <0.7
diff --git a/src/Data/Bytes/Parser.hs b/src/Data/Bytes/Parser.hs
--- a/src/Data/Bytes/Parser.hs
+++ b/src/Data/Bytes/Parser.hs
@@ -79,6 +79,7 @@
 
     -- * Repetition
   , replicate
+  , listUntilEoi
 
     -- * Subparsing
   , delimit
@@ -104,23 +105,30 @@
     -- of monadic @>>=@ can be helpful. If @C#@, @I#@, etc. never
     -- get used in your original source code, GHC will not introduce them.
   , bindFromCharToLifted
+  , bindFromCharToByteArrayIntInt
+  , bindFromWordToByteArrayIntInt
   , bindFromLiftedToIntPair
   , bindFromLiftedToInt
   , bindFromIntToIntPair
   , bindFromCharToIntPair
+  , bindFromLiftedToByteArrayIntInt
+  , bindFromByteArrayIntIntToLifted
   , bindFromMaybeCharToIntPair
   , bindFromMaybeCharToLifted
 
     -- * Specialized Pure
   , pureIntPair
+  , pureByteArrayIntInt
 
     -- * Specialized Fail
   , failIntPair
+  , failByteArrayIntInt
   ) where
 
 import Prelude hiding (any, fail, length, replicate, take, takeWhile)
 
 import Data.Bytes.Parser.Internal (Parser (..), Result#, ST#, boxBytes, fail, unboxBytes, uneffectful, uneffectful#, uneffectfulInt#)
+import Data.Bytes.Parser.Internal (failByteArrayIntInt)
 import Data.Bytes.Parser.Types (Result (Failure, Success), Slice (Slice))
 import Data.Bytes.Parser.Unsafe (cursor, expose, unconsume)
 import Data.Bytes.Types (Bytes (..), BytesN (BytesN))
@@ -128,6 +136,7 @@
 import Data.Primitive.Contiguous (Contiguous, Element)
 import Foreign.C.String (CString)
 import GHC.Exts (Char#, Int (I#), Int#, Word#, runRW#, (+#), (-#), (>=#))
+import GHC.Exts (ByteArray#)
 import GHC.ST (ST (..))
 import GHC.Word (Word32 (W32#), Word8)
 
@@ -135,6 +144,7 @@
 import qualified Arithmetic.Types as Arithmetic
 import qualified Data.Bytes as B
 import qualified Data.Bytes.Parser.Internal as Internal
+import qualified Data.List as List
 import qualified Data.Primitive as PM
 import qualified Data.Primitive.Contiguous as C
 import qualified GHC.Exts as Exts
@@ -249,17 +259,13 @@
 cstring e (Exts.Ptr ptr0) = Parser
   ( \(# arr, off0, len0 #) s ->
     let go !ptr !off !len = case
-#if MIN_VERSION_base(4,16,0)
                  Exts.word8ToWord#
-#endif
             (Exts.indexWord8OffAddr# ptr 0#) of
           0## -> (# s, (# | (# (), off, len #) #) #)
           c -> case len of
             0# -> (# s, (# e | #) #)
             _ -> case Exts.eqWord# c (
-#if MIN_VERSION_base(4,16,0)
                  Exts.word8ToWord#
-#endif
               (Exts.indexWord8Array# arr off)) of
               1# -> go (Exts.plusAddr# ptr 1# ) (off +# 1# ) (len -# 1# )
               _ -> (# s, (# e | #) #)
@@ -519,15 +525,15 @@
 -- | Skip while the predicate is matched. This is always inlined.
 skipWhile :: (Word8 -> Bool) -> Parser e s ()
 {-# INLINE skipWhile #-}
-skipWhile f = go
+skipWhile f = goSkipWhile
  where
-  go =
+  goSkipWhile =
     isEndOfInput >>= \case
       True -> pure ()
       False -> do
         w <- anyUnsafe
         if f w
-          then go
+          then goSkipWhile
           else unconsume 1
 
 {- | The parser @satisfy p@ succeeds for any byte for which the
@@ -584,9 +590,7 @@
     (# s1, r #) -> case r of
       (# e | #) -> (# s1, (# e | #) #)
       (# | (# W32# a, b, c #) #) -> (# s1, (# | (#
-#if MIN_VERSION_base(4,16,0)
         Exts.word32ToWord#
-#endif
         a, b, c #) #) #)
   )
 {- FOURMOLU_ENABLE -}
@@ -612,9 +616,7 @@
     (# s1, r #) -> case r of
       (# e | #) -> (# s1, (# e | #) #)
       (# | (# a, b, c #) #) -> (# s1, (# | (# W32# (
-#if MIN_VERSION_base(4,16,0)
         Exts.wordToWord32#
-#endif
         a), b, c #) #) #)
   )
 {- FOURMOLU_ENABLE -}
@@ -696,6 +698,28 @@
             runParser (g y) (# arr, b, c #) s1
     )
 
+bindFromWordToByteArrayIntInt :: Parser s e Word# -> (Word# -> Parser s e (# ByteArray#, Int#, Int# #)) -> Parser s e (# ByteArray#, Int#, Int# #)
+{-# INLINE bindFromWordToByteArrayIntInt #-}
+bindFromWordToByteArrayIntInt (Parser f) g =
+  Parser
+    ( \x@(# arr, _, _ #) s0 -> case f x s0 of
+        (# s1, r0 #) -> case r0 of
+          (# e | #) -> (# s1, (# e | #) #)
+          (# | (# y, b, c #) #) ->
+            runParser (g y) (# arr, b, c #) s1
+    )
+
+bindFromCharToByteArrayIntInt :: Parser s e Char# -> (Char# -> Parser s e (# ByteArray#, Int#, Int# #)) -> Parser s e (# ByteArray#, Int#, Int# #)
+{-# INLINE bindFromCharToByteArrayIntInt #-}
+bindFromCharToByteArrayIntInt (Parser f) g =
+  Parser
+    ( \x@(# arr, _, _ #) s0 -> case f x s0 of
+        (# s1, r0 #) -> case r0 of
+          (# e | #) -> (# s1, (# e | #) #)
+          (# | (# y, b, c #) #) ->
+            runParser (g y) (# arr, b, c #) s1
+    )
+
 bindFromCharToIntPair :: Parser s e Char# -> (Char# -> Parser s e (# Int#, Int# #)) -> Parser s e (# Int#, Int# #)
 {-# INLINE bindFromCharToIntPair #-}
 bindFromCharToIntPair (Parser f) g =
@@ -718,6 +742,34 @@
             runParser (g y) (# arr, b, c #) s1
     )
 
+bindFromByteArrayIntIntToLifted ::
+     Parser s e (# ByteArray#, Int#, Int# #)
+  -> ((# ByteArray#, Int#, Int# #) -> Parser s e a)
+  -> Parser s e a
+{-# INLINE bindFromByteArrayIntIntToLifted #-}
+bindFromByteArrayIntIntToLifted (Parser f) g =
+  Parser
+    ( \x@(# arr, _, _ #) s0 -> case f x s0 of
+        (# s1, r0 #) -> case r0 of
+          (# e | #) -> (# s1, (# e | #) #)
+          (# | (# y, b, c #) #) ->
+            runParser (g y) (# arr, b, c #) s1
+    )
+
+bindFromLiftedToByteArrayIntInt ::
+     Parser s e a
+  -> (a -> Parser s e (# ByteArray#, Int#, Int# #))
+  -> Parser s e (# ByteArray#, Int#, Int# #)
+{-# INLINE bindFromLiftedToByteArrayIntInt #-}
+bindFromLiftedToByteArrayIntInt (Parser f) g =
+  Parser
+    ( \x@(# arr, _, _ #) s0 -> case f x s0 of
+        (# s1, r0 #) -> case r0 of
+          (# e | #) -> (# s1, (# e | #) #)
+          (# | (# y, b, c #) #) ->
+            runParser (g y) (# arr, b, c #) s1
+    )
+
 bindFromLiftedToIntPair :: Parser s e a -> (a -> Parser s e (# Int#, Int# #)) -> Parser s e (# Int#, Int# #)
 {-# INLINE bindFromLiftedToIntPair #-}
 bindFromLiftedToIntPair (Parser f) g =
@@ -776,6 +828,14 @@
   Parser
     (\(# _, b, c #) s -> (# s, (# | (# a, b, c #) #) #))
 
+pureByteArrayIntInt ::
+  (# ByteArray#, Int#, Int# #) ->
+  Parser s e (# ByteArray#, Int#, Int# #)
+{-# INLINE pureByteArrayIntInt #-}
+pureByteArrayIntInt a =
+  Parser
+    (\(# _, b, c #) s -> (# s, (# | (# a, b, c #) #) #))
+
 failIntPair :: e -> Parser e s (# Int#, Int# #)
 {-# INLINE failIntPair #-}
 failIntPair e =
@@ -847,6 +907,20 @@
               _ -> (# s1, (# eleftovers | #) #)
         _ -> (# s0, (# esz | #) #)
     )
+
+{- | Apply the parser repeatedly until there is no more input left
+to consume. Collects the results into a list.
+-}
+listUntilEoi ::
+     Parser e s a -- ^ Parser to repeatedly apply until input is exhausted
+  -> Parser e s [a]
+listUntilEoi p = go []
+  where
+  go !acc = isEndOfInput >>= \case
+    True -> pure $! List.reverse acc
+    False -> do
+      a <- p
+      go (a : acc)
 
 {- | Replicate a parser @n@ times, writing the results into
 an array of length @n@. For @Array@ and @SmallArray@, this
diff --git a/src/Data/Bytes/Parser/BigEndian.hs b/src/Data/Bytes/Parser/BigEndian.hs
--- a/src/Data/Bytes/Parser/BigEndian.hs
+++ b/src/Data/Bytes/Parser/BigEndian.hs
@@ -38,10 +38,6 @@
 
 import Prelude hiding (any, fail, length, takeWhile)
 
-#if MIN_VERSION_base(4,18,0)
-#else
-import Control.Applicative (liftA2)
-#endif
 import Data.Bits (unsafeShiftL, (.|.))
 import Data.Bytes.Parser.Internal (Parser, Result (..), swapArray128, swapArray16, swapArray256, swapArray32, swapArray64, uneffectful)
 import Data.Bytes.Types (Bytes (..))
diff --git a/src/Data/Bytes/Parser/Internal.hs b/src/Data/Bytes/Parser/Internal.hs
--- a/src/Data/Bytes/Parser/Internal.hs
+++ b/src/Data/Bytes/Parser/Internal.hs
@@ -25,6 +25,7 @@
   , unboxBytes
   , unboxResult
   , fail
+  , failByteArrayIntInt
   , indexLatinCharArray
   , upcastUnitSuccess
   -- Swapping
@@ -44,6 +45,7 @@
 import Data.Primitive (ByteArray (ByteArray))
 import Data.Word (Word8)
 import GHC.Exts (ByteArray#, Char (C#), Int (I#), Int#, RuntimeRep, State#, TYPE)
+import GHC.Exts (RuntimeRep(IntRep, BoxedRep, TupleRep), Levity(Unlifted))
 
 import qualified Control.Applicative
 import qualified Control.Monad
@@ -128,6 +130,13 @@
   Parser e s a
 {-# INLINE fail #-}
 fail e = uneffectful $ \_ -> Failure e
+
+failByteArrayIntInt :: forall e s (a :: TYPE ('TupleRep '[ 'BoxedRep 'Unlifted, 'IntRep, 'IntRep ])).
+  -- | Error message
+  e ->
+  Parser e s a
+{-# INLINE failByteArrayIntInt #-}
+failByteArrayIntInt e = Parser (\_ s0 -> (# s0, (# e | #)  #))
 
 instance Applicative (Parser e s) where
   pure = pureParser
diff --git a/src/Data/Bytes/Parser/Latin.hs b/src/Data/Bytes/Parser/Latin.hs
--- a/src/Data/Bytes/Parser/Latin.hs
+++ b/src/Data/Bytes/Parser/Latin.hs
@@ -99,6 +99,7 @@
     -- *** Fixed Length
   , hexFixedWord8
   , hexFixedWord16
+  , hexFixedWord16#
   , hexFixedWord32
   , hexFixedWord64
   , hexFixedWord128
@@ -701,11 +702,7 @@
 {-# inline upcastWord64Result #-}
 upcastWord64Result (# e | #) = (# e | #)
 upcastWord64Result (# | (# a, b, c #) #) = (# | (# W64# (
-#if MIN_VERSION_base(4,17,0)
     Exts.wordToWord64# a
-#else
-    a
-#endif
     ), b, c #) #)
 {- FOURMOLU_ENABLE -}
 
@@ -774,9 +771,7 @@
 {-# inline upcastWord16Result #-}
 upcastWord16Result (# e | #) = (# e | #)
 upcastWord16Result (# | (# a, b, c #) #) = (# | (# W16# (
-#if MIN_VERSION_base(4,16,0)
   Exts.wordToWord16#
-#endif
   a), b, c #) #)
 
 -- Precondition: the word is small enough
@@ -784,9 +779,7 @@
 {-# inline upcastWord32Result #-}
 upcastWord32Result (# e | #) = (# e | #)
 upcastWord32Result (# | (# a, b, c #) #) = (# | (# W32# (
-#if MIN_VERSION_base(4,16,0)
   Exts.wordToWord32#
-#endif
   a), b, c #) #)
 
 -- Precondition: the word is small enough
@@ -794,9 +787,7 @@
 {-# inline upcastWord8Result #-}
 upcastWord8Result (# e | #) = (# e | #)
 upcastWord8Result (# | (# a, b, c #) #) = (# | (# W8# (
-#if MIN_VERSION_base(4,16,0)
   Exts.wordToWord8#
-#endif
   a), b, c #) #)
 {- FOURMOLU_ENABLE -}
 
@@ -1188,9 +1179,7 @@
     (# s1, r #) -> case r of
       (# err | #) -> (# s1, (# err | #) #)
       (# | (# a, b, c #) #) -> (# s1, (# | (# W32# (
-#if MIN_VERSION_base(4,16,0)
         Exts.wordToWord32#
-#endif
         a), b, c #) #) #)
   )
 {- FOURMOLU_ENABLE -}
@@ -1239,12 +1228,7 @@
     (# s1, r #) -> case r of
       (# err | #) -> (# s1, (# err | #) #)
       (# | (# a, b, c #) #) -> (# s1, (# | (# W64# (
-#if MIN_VERSION_base(4,17,0)
           Exts.wordToWord64# a
-#else
-
-          a
-#endif
           ), b, c #) #) #)
   )
 {- FOURMOLU_ENABLE -}
@@ -1298,12 +1282,15 @@
     (# s1, r #) -> case r of
       (# err | #) -> (# s1, (# err | #) #)
       (# | (# a, b, c #) #) -> (# s1, (# | (# W16# (
-#if MIN_VERSION_base(4,16,0)
         Exts.wordToWord16#
-#endif
         a), b, c #) #) #)
   )
 
+-- | Variant of hexFixedWord16 that returns an unboxed result. The result is
+-- a machine-sized word instead of a 16-bit word, but this function guarantees
+-- that only the low 16 bits may be non-zero. This is helpful because the
+-- result needs to be constructed as a machine-sized word. GHC's primitives
+-- for working with 16-bit words lower to most ISAs poorly.
 hexFixedWord16# :: e -> Parser e s Word#
 {-# noinline hexFixedWord16# #-}
 hexFixedWord16# e = uneffectfulWord# $ \chunk -> if length chunk >= 4
@@ -1314,9 +1301,9 @@
         !w3@(W# n3) = oneHex $ PM.indexByteArray (array chunk) (offset chunk + 3)
      in if | w0 .|. w1 .|. w2 .|. w3 /= maxBound ->
              (# |
-                (# (n0 `Exts.timesWord#` 4096##) `Exts.plusWord#`
-                   (n1 `Exts.timesWord#` 256##) `Exts.plusWord#`
-                   (n2 `Exts.timesWord#` 16##) `Exts.plusWord#`
+                (# (n0 `Exts.shiftL#` 12#) `Exts.plusWord#`
+                   (n1 `Exts.shiftL#` 8#) `Exts.plusWord#`
+                   (n2 `Exts.shiftL#` 4#) `Exts.plusWord#`
                    n3
                 ,  unI (offset chunk) +# 4#
                 ,  unI (length chunk) -# 4# #) #)
@@ -1334,9 +1321,7 @@
     (# s1, r #) -> case r of
       (# err | #) -> (# s1, (# err | #) #)
       (# | (# a, b, c #) #) -> (# s1, (# | (# W8# (
-#if MIN_VERSION_base(4,16,0)
         Exts.wordToWord8#
-#endif
         a), b, c #) #) #)
   )
 {- FOURMOLU_ENABLE -}
diff --git a/src/Data/Bytes/Parser/LittleEndian.hs b/src/Data/Bytes/Parser/LittleEndian.hs
--- a/src/Data/Bytes/Parser/LittleEndian.hs
+++ b/src/Data/Bytes/Parser/LittleEndian.hs
@@ -43,10 +43,7 @@
 
 import Prelude hiding (any, fail, length, takeWhile)
 
-#if MIN_VERSION_base(4,18,0)
-#else
 import Control.Applicative (liftA2)
-#endif
 import Data.Bits (unsafeShiftL, (.|.))
 import Data.Bytes.Parser.Internal (Parser, Result (..), swapArray128, swapArray16, swapArray256, swapArray32, swapArray64, uneffectful)
 import Data.Bytes.Types (Bytes (..))
diff --git a/src/Data/Bytes/Parser/Rebindable.hs b/src/Data/Bytes/Parser/Rebindable.hs
--- a/src/Data/Bytes/Parser/Rebindable.hs
+++ b/src/Data/Bytes/Parser/Rebindable.hs
@@ -25,12 +25,7 @@
 import Data.Bytes.Parser.Internal (Parser (..))
 import GHC.Exts (RuntimeRep (..), TYPE)
 import Prelude ()
-
-#if MIN_VERSION_base(4,16,0)
 import GHC.Exts (LiftedRep)
-#else
-type LiftedRep = 'LiftedRep
-#endif
 
 class Bind (ra :: RuntimeRep) (rb :: RuntimeRep) where
   (>>=) ::
diff --git a/src/Data/Bytes/Parser/Utf8.hs b/src/Data/Bytes/Parser/Utf8.hs
--- a/src/Data/Bytes/Parser/Utf8.hs
+++ b/src/Data/Bytes/Parser/Utf8.hs
@@ -46,9 +46,7 @@
       let !w0 = Exts.indexWord8Array# arr off
        in if | oneByteChar (W8# w0) ->
                  (# s0, (# | (# chr# (Exts.word2Int# (
-#if MIN_VERSION_base(4,16,0)
                  Exts.word8ToWord#
-#endif
                  w0)), off +# 1#, len -# 1# #) #) #)
              | twoByteChar (W8# w0) ->
                  if | I# len > 1
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -7,10 +7,6 @@
 {-# LANGUAGE TypeApplications #-}
 {-# OPTIONS_GHC -fno-warn-orphans #-}
 
-#if MIN_VERSION_base(4,18,0)
-#else
-import Control.Applicative (liftA2)
-#endif
 import Control.Monad (replicateM)
 import Control.Monad.ST (runST)
 import Data.Bytes.Parser (Slice (Slice))
