first
This commit is contained in:
commit
0b41af265e
26 changed files with 1103 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
dist-newstyle
|
41
Makefile
Normal file
41
Makefile
Normal file
|
@ -0,0 +1,41 @@
|
|||
# only for the web page. use cabal if you actually want to run the program
|
||||
|
||||
OUT := /srv/www/lit/ips
|
||||
|
||||
STATICS != find style -type f
|
||||
SRCS = ips.lhs
|
||||
OUTPUTS = $(OUT)/ips.html \
|
||||
$(patsubst %,$(OUT)/%,$(STATICS))
|
||||
|
||||
HOST ?= rhiannon.website
|
||||
SSH_PORT ?= 35353
|
||||
REMOTE_USER ?= nginx
|
||||
REMOTE_DIR ?= lit/ips
|
||||
|
||||
|
||||
all: $(OUTPUTS)
|
||||
|
||||
clean:
|
||||
rm -rf $(OUT)/*
|
||||
|
||||
$(OUT)/%.html: %.lhs template.html ; $(pandoc)
|
||||
$(OUT)/%: % ; $(copy)
|
||||
|
||||
define copy
|
||||
mkdir -p $(dir $@)
|
||||
cp $< $@
|
||||
endef
|
||||
|
||||
define pandoc
|
||||
mkdir -p $(dir $@)
|
||||
pandoc -M toc-title=contents --toc-depth 2 --toc \
|
||||
--strip-comments \
|
||||
--template template.html $< -o $@
|
||||
endef
|
||||
|
||||
upload:
|
||||
rsync --recursive --partial --progress --copy-links \
|
||||
--compress --human-readable --hard-links --size-only \
|
||||
--delete --delete-after \
|
||||
--rsh='ssh -l $(REMOTE_USER) -p $(SSH_PORT)' \
|
||||
$(OUT)/ $(HOST):$(REMOTE_DIR)/
|
31
ips.cabal
Normal file
31
ips.cabal
Normal file
|
@ -0,0 +1,31 @@
|
|||
cabal-version: 3.0
|
||||
|
||||
name: ips
|
||||
version: 2
|
||||
|
||||
flag tests
|
||||
manual: True
|
||||
default: True
|
||||
|
||||
executable ips2
|
||||
main-is: ips.lhs
|
||||
build-depends:
|
||||
base,
|
||||
bytestring,
|
||||
mtl,
|
||||
random,
|
||||
QuickCheck
|
||||
ghc-options: -O -Wall
|
||||
default-language: GHC2024
|
||||
default-extensions:
|
||||
CPP
|
||||
BlockArguments
|
||||
DerivingVia
|
||||
OverloadedStrings
|
||||
OverloadedLists
|
||||
StrictData
|
||||
if flag(tests)
|
||||
build-depends:
|
||||
QuickCheck
|
||||
cpp-options: -DTESTS
|
||||
ghc-options: -Wno-missing-signatures
|
548
ips.lhs
Normal file
548
ips.lhs
Normal file
|
@ -0,0 +1,548 @@
|
|||
---
|
||||
title: applying patches
|
||||
...
|
||||
|
||||
stuff like rom hacks are usually distributed in one of two formats:
|
||||
[IPS][] or [BPS][].
|
||||
|
||||
[IPS]: http://fileformats.archiveteam.org/wiki/IPS_(binary_patch_format)
|
||||
[BPS]: https://github.com/blakesmith/rombp/blob/master/docs/bps_spec.md
|
||||
|
||||
> import Control.Applicative
|
||||
> import Control.Monad
|
||||
> import Control.Monad.Except
|
||||
> import Control.Monad.State
|
||||
> import Data.Bits
|
||||
> import Data.ByteString (ByteString)
|
||||
> import Data.ByteString qualified as BS
|
||||
> import Data.Word
|
||||
> import Numeric.Natural
|
||||
|
||||
and for tests:
|
||||
|
||||
<!--
|
||||
#ifdef TESTS
|
||||
-->
|
||||
|
||||
> import Numeric
|
||||
> import Data.Either
|
||||
> import Test.QuickCheck
|
||||
> import GHC.Generics
|
||||
|
||||
<!--
|
||||
#endif
|
||||
-->
|
||||
|
||||
|
||||
== basic parsers
|
||||
|
||||
i could just use attoparsec or something, and in the original code i did, but
|
||||
that's not really needed for a program like this, so let's make our own. it's
|
||||
good enough for these purposes. the error messages are bad, but what are you
|
||||
going to do, hand-edit an IPS file???
|
||||
|
||||
a parser has read-write access to the input, a `ByteString`; and the ability to
|
||||
throw errors (string messages are fine for this).
|
||||
|
||||
> newtype Parser a = Parser (StateT ByteString (Except String) a)
|
||||
> deriving (Functor, Applicative, Alternative, Monad, MonadPlus)
|
||||
|
||||
when running a parser we want to check that if it was successful, then it did
|
||||
actually consume all of the input. but not doing so, and returning the remainder
|
||||
instead, will be useful for tests.
|
||||
|
||||
> runParserPartial :: Parser a -> ByteString -> Either String (a, ByteString)
|
||||
> runParserPartial (Parser p) str = runExcept (runStateT p str)
|
||||
>
|
||||
> runParser :: Parser a -> ByteString -> Either String a
|
||||
> runParser p str = do
|
||||
> (result, rest) <- runParserPartial p str
|
||||
> if BS.null rest then
|
||||
> Right result
|
||||
> else
|
||||
> Left ("junk after EOF: " ++ show rest)
|
||||
|
||||
<details>
|
||||
<summary>testing parser functions</summary>
|
||||
<!--
|
||||
#ifdef TESTS
|
||||
-->
|
||||
|
||||
to test these parsers we can generate random inputs and check that they do the
|
||||
right thing. a test input is a byte string, but printed with each byte in hex.
|
||||
|
||||
> newtype ParserInput = ParserInput { fromParserInput :: ByteString }
|
||||
>
|
||||
> instance Show ParserInput where
|
||||
> show = unwords . map showHex2 . piToList
|
||||
>
|
||||
> showHex2 :: Word8 -> String
|
||||
> showHex2 n = case showHex n "" of [c] -> ['0', c]; str -> str
|
||||
>
|
||||
> piToList :: ParserInput -> [Word8]
|
||||
> piToList = BS.unpack . fromParserInput
|
||||
>
|
||||
> listToPI :: [Word8] -> ParserInput
|
||||
> listToPI = ParserInput . BS.pack
|
||||
|
||||
like so:
|
||||
|
||||
```
|
||||
ghci> ParserInput "\0\0\0"
|
||||
00 00 00
|
||||
ghci> ParserInput "Hello"
|
||||
48 65 6c 6c 6f
|
||||
```
|
||||
|
||||
in many cases, we don't really care about the structure of the input, so it can
|
||||
just be a sequence of any bytes. quickcheck uses the `Arbitrary` class for
|
||||
generating test input. the optional `shrink` function produces smaller but
|
||||
related values, for trying to find a minimal test case. the instances for
|
||||
`CoArbitrary` and `Function` is for generating functions which take
|
||||
`ParserInput` as an argument.
|
||||
|
||||
> instance Arbitrary ParserInput where
|
||||
> arbitrary :: Gen ParserInput
|
||||
> arbitrary = listToPI <$> arbitrary
|
||||
>
|
||||
> shrink :: ParserInput -> [ParserInput]
|
||||
> shrink = map listToPI . shrinkList (const []) . piToList
|
||||
>
|
||||
> instance CoArbitrary ParserInput where
|
||||
> coarbitrary = coarbitrary . piToList
|
||||
>
|
||||
> instance Function ParserInput where
|
||||
> function = functionMap piToList listToPI
|
||||
|
||||
in this case, `shrink` tries to remove some bytes from the string, without
|
||||
touching individual bytes.
|
||||
|
||||
<!--
|
||||
#endif
|
||||
-->
|
||||
</details>
|
||||
|
||||
when consuming a given number of characters, we check here that there is
|
||||
actually enough input left.
|
||||
|
||||
> bytes :: Int -> Parser ByteString
|
||||
> bytes len = Parser do
|
||||
> (this, rest) <- BS.splitAt len <$> get
|
||||
> if BS.length this == len then do
|
||||
> put rest
|
||||
> pure this
|
||||
> else
|
||||
> throwError "tried to read past end of input"
|
||||
|
||||
<details>
|
||||
<summary>testing `bytes`</summary>
|
||||
<!--
|
||||
#ifdef TESTS
|
||||
-->
|
||||
|
||||
for these tests, we need a generator for a string together with a valid
|
||||
prefix length. if we tell quickcheck to just guess random numbers with no help,
|
||||
it'll give up before running enough tests.
|
||||
|
||||
> data InputWithPrefix = ParserInput `WithPrefix` Int
|
||||
> deriving (Show, Generic)
|
||||
>
|
||||
> instance Arbitrary InputWithPrefix where
|
||||
> arbitrary = do
|
||||
> input <- arbitrary
|
||||
> len <- chooseInt (0, BS.length (fromParserInput input))
|
||||
> pure (input `WithPrefix` len)
|
||||
> shrink = genericShrink
|
||||
|
||||
|
||||
now, the properties we want are:
|
||||
|
||||
if the remaining input is too short, then `bytes` should fail.
|
||||
|
||||
> prop_bytes_short (ParserInput str) (NonNegative count) =
|
||||
> BS.length str < count ==> isLeft (runParserPartial (bytes count) str)
|
||||
|
||||
if there _is_ enough input, it should succeed.
|
||||
|
||||
> prop_bytes_enough (ParserInput str `WithPrefix` count) =
|
||||
> isRight (runParserPartial (bytes count) str)
|
||||
|
||||
the returned string (the first element of the returned pair) should have the
|
||||
right length.
|
||||
|
||||
> prop_bytes_length (ParserInput str `WithPrefix` count) =
|
||||
> withSuccess (bytes count) str \(result, _) -> BS.length result === count
|
||||
|
||||
appending the returned string and the remainder should give the original input.
|
||||
|
||||
> prop_bytes_append (ParserInput str `WithPrefix` count) =
|
||||
> withSuccess (bytes count) str \(result, rest) -> result <> rest === str
|
||||
|
||||
<!--
|
||||
#endif
|
||||
-->
|
||||
</details>
|
||||
|
||||
it is also useful to take input while a given condition is true. in this case,
|
||||
the parsing always succeeds but might be empty.
|
||||
|
||||
> bytesWhile :: (Word8 -> Bool) -> Parser ByteString
|
||||
> bytesWhile cond = Parser do
|
||||
> (this, rest) <- BS.span cond <$> get
|
||||
> put rest; pure this
|
||||
|
||||
<details>
|
||||
<summary>testing `bytesWhile`</summary>
|
||||
<!--
|
||||
#ifdef TESTS
|
||||
-->
|
||||
|
||||
> withSuccess :: Testable p =>
|
||||
> Parser a -> ByteString -> ((a, ByteString) -> p) -> Property
|
||||
> withSuccess p str f =
|
||||
> case runParserPartial p str of
|
||||
> Left _ -> property False
|
||||
> Right x -> property (f x)
|
||||
|
||||
`bytesWhile` always succeeds (with a possibly-empty result).
|
||||
|
||||
> prop_bytesWhile_succeed (Fn p) (ParserInput str) =
|
||||
> isRight (runParserPartial (bytesWhile p) str)
|
||||
|
||||
`bytesWhile p` returns a value where `p` holds for each byte. this test also
|
||||
categorises cases by result length, and requires the remainder to be non-empty,
|
||||
to check that we're not accidentally creating only constant functions.
|
||||
|
||||
> prop_bytesWhile_result (Fn p) (ParserInput str) = do
|
||||
> withSuccess (bytesWhile p) str \(result, rest) ->
|
||||
> collect (BS.length result)
|
||||
> (not (BS.null rest) ==> BS.all p result)
|
||||
|
||||
if `bytesWhile` doesn't consume the whole input, then `p` _doesn't_ hold for the
|
||||
first byte of the remainder.
|
||||
|
||||
> prop_bytesWhile_rest (Fn p) (ParserInput str) =
|
||||
> withSuccess (bytesWhile p) str \(_, rest) ->
|
||||
> not (BS.null rest) ==> not (p (BS.head rest))
|
||||
|
||||
<!--
|
||||
#endif
|
||||
-->
|
||||
</details>
|
||||
|
||||
|
||||
checking for an exact string can be written in terms of `bytes`.
|
||||
|
||||
> exact :: ByteString -> Parser ()
|
||||
> exact str = do
|
||||
> prefix <- bytes (BS.length str)
|
||||
> unless (prefix == str) do
|
||||
> Parser (throwError ("expected " ++ show str ++ ", got " ++ show prefix))
|
||||
|
||||
to detect the end of input, it's simplest to just look at the remaining input
|
||||
directly.
|
||||
|
||||
> eof :: Parser ()
|
||||
> eof = Parser do
|
||||
> input <- get
|
||||
> unless (BS.null input) do
|
||||
> throwError ("expected end of input, got " ++ show input)
|
||||
|
||||
reading an integer just takes the appropriate number of bytes and shifts them
|
||||
together. **multi-byte integers in the IPS format are big-endian.**
|
||||
|
||||
> word8 :: Parser Word8
|
||||
> word8 = BS.head <$> bytes 1
|
||||
>
|
||||
> word16BE :: Parser Word16
|
||||
> word16BE = do
|
||||
> hi <- word8; lo <- word8
|
||||
> pure $ fromIntegral hi `shiftL` 8 .|. fromIntegral lo
|
||||
>
|
||||
> word24BE :: Parser Word32
|
||||
> word24BE = do
|
||||
> hi <- word16BE; lo <- word8
|
||||
> pure $ fromIntegral hi `shiftL` 8 .|. fromIntegral lo
|
||||
|
||||
|
||||
== IPS file format
|
||||
|
||||
the IPS format is extremely simple. it consists of the ASCII string `PATCH`, a
|
||||
number of [chunks](#chunks), and finally the string `EOF`.
|
||||
|
||||
```{=html}
|
||||
<figure class=shadowed>
|
||||
<svg viewBox="-1 -1 602 32" width=602 height=32>
|
||||
<style>
|
||||
rect { stroke-width: 1px; stroke: currentcolor; }
|
||||
text { text-anchor: middle; }
|
||||
</style>
|
||||
<g>
|
||||
<rect width=15 height=30 fill="oklch(95% 15% 220deg)" />
|
||||
<text x=7 y=22> P </text>
|
||||
</g>
|
||||
<g transform="translate(15 0)">
|
||||
<rect width=15 height=30 fill="oklch(95% 15% 220deg)" />
|
||||
<text x=7 y=22> A </text>
|
||||
</g>
|
||||
<g transform="translate(30 0)">
|
||||
<rect width=15 height=30 fill="oklch(95% 15% 220deg)" />
|
||||
<text x=7 y=22> T </text>
|
||||
</g>
|
||||
<g transform="translate(45 0)">
|
||||
<rect width=15 height=30 fill="oklch(95% 15% 220deg)" />
|
||||
<text x=7 y=22> C </text>
|
||||
</g>
|
||||
<g transform="translate(60 0)">
|
||||
<rect width=15 height=30 fill="oklch(95% 15% 220deg)" />
|
||||
<text x=7 y=22> H </text>
|
||||
</g>
|
||||
<g transform="translate(75 0)">
|
||||
<rect width=80 height=30 fill="oklch(95% 15% 180deg)" />
|
||||
<text x=40 y=22> Chunk1 </text>
|
||||
</g>
|
||||
<g transform="translate(155 0)">
|
||||
<rect width=180 height=30 fill="oklch(95% 15% 160deg)" />
|
||||
<text x=90 y=22> Chunk2 </text>
|
||||
</g>
|
||||
<g transform="translate(335 0)">
|
||||
<rect width=120 height=30 fill="oklch(95% 15% 140deg)" />
|
||||
<text x=60 y=22> Chunk3 </text>
|
||||
</g>
|
||||
<g transform="translate(455 0)">
|
||||
<rect width=100 height=30 fill="oklch(95% 15% 120deg)" />
|
||||
<text x=50 y=22> … </text>
|
||||
</g>
|
||||
<g transform="translate(555 0)">
|
||||
<rect width=15 height=30 fill="oklch(95% 15% 100deg)" />
|
||||
<text x=7 y=22> E </text>
|
||||
</g>
|
||||
<g transform="translate(570 0)">
|
||||
<rect width=15 height=30 fill="oklch(95% 15% 100deg)" />
|
||||
<text x=7 y=22> O </text>
|
||||
</g>
|
||||
<g transform="translate(585 0)">
|
||||
<rect width=15 height=30 fill="oklch(95% 15% 100deg)" />
|
||||
<text x=7 y=22> F </text>
|
||||
</g>
|
||||
</svg>
|
||||
</figure>
|
||||
```
|
||||
|
||||
> type IPS = [IpsChunk]
|
||||
>
|
||||
> ipsFile :: Parser IPS
|
||||
> ipsFile = do exact "PATCH"; ipsChunks
|
||||
|
||||
if the rest of the input is just the `EOF` marker (followed by the actual end),
|
||||
then we're done. otherwise, parse a chunk and repeat.
|
||||
|
||||
explicitly checking for the end of input as well means that chunks with an
|
||||
offset of `0x454F46`, which will _look_ like `EOF`, are still handled
|
||||
correctly.
|
||||
|
||||
> ipsChunks :: Parser [IpsChunk]
|
||||
> ipsChunks = stop <|> go where
|
||||
> stop = do exact "EOF"
|
||||
> eof
|
||||
> pure []
|
||||
> go = do next <- ipsChunk
|
||||
> rest <- ipsChunks
|
||||
> pure (next : rest)
|
||||
|
||||
=== chunks
|
||||
|
||||
a chunk can either be a [run-length-encoded](#rle) or a [literal](#lit) chunk.
|
||||
in either case, they are preceded by a 24-bit offset giving their start
|
||||
position.
|
||||
|
||||
> data IpsChunk = IpsChunk { offset :: Word32, body :: IpsChunkBody }
|
||||
>
|
||||
> data IpsChunkBody
|
||||
> = Lit ByteString
|
||||
> | RLE { size :: Word16, value :: Word8 }
|
||||
>
|
||||
> ipsChunk :: Parser IpsChunk
|
||||
> ipsChunk = IpsChunk <$> word24BE <*> (rle <|> lit)
|
||||
|
||||
a [**run-length encoded chunk**]{#rle} indicates a single byte being repeated a
|
||||
given number of times. it is represented by two zero bytes, followed by a
|
||||
two-byte length, and a single byte value. the zero bytes exist to distinguish it
|
||||
from a literal chunk, which cannot have a zero length.
|
||||
|
||||
```{=html}
|
||||
<figure class=shadowed>
|
||||
<svg viewBox="-1 -1 642 32" width=642 height=32>
|
||||
<style>
|
||||
rect { stroke-width: 1px; stroke: currentcolor; }
|
||||
text { text-anchor: middle }
|
||||
</style>
|
||||
<g>
|
||||
<rect width=240 height=30 fill="oklch(95% 15% 80deg)" />
|
||||
<text x=120 y=22> off (3) </text>
|
||||
</g>
|
||||
<g transform="translate(240 0)">
|
||||
<rect width=160 height=30 fill="oklch(95% 15% 60deg)" />
|
||||
<text x=80 y=22> "00" (2) </text>
|
||||
</g>
|
||||
<g transform="translate(400 0)">
|
||||
<rect width=160 height=30 fill="oklch(95% 15% 40deg)" />
|
||||
<text x=80 y=22> size (2) </text>
|
||||
</g>
|
||||
<g transform="translate(560 0)">
|
||||
<rect width=80 height=30 fill="oklch(95% 15% 20deg)" />
|
||||
<text x=40 y=22> val (1) </text>
|
||||
</g>
|
||||
</svg>
|
||||
</figure>
|
||||
```
|
||||
|
||||
> rle :: Parser IpsChunkBody
|
||||
> rle = do
|
||||
> exact [0,0]
|
||||
> size <- word16BE
|
||||
> value <- word8
|
||||
> pure (RLE { size, value })
|
||||
|
||||
a [**literal chunk**]{#lit} consists of a 16-bit (non-zero) length, followed by
|
||||
the data it contains. actually checking the size is non-zero isn't needed,
|
||||
because `rle` is attempted first and will succeed in that case.
|
||||
|
||||
```{=html}
|
||||
<figure class=shadowed>
|
||||
<svg viewBox="-1 -1 642 32" width=642 height=32>
|
||||
<style>
|
||||
rect, path { stroke-width: 1px; stroke: currentcolor; }
|
||||
text { text-anchor: middle; }
|
||||
</style>
|
||||
<g>
|
||||
<rect width=240 height=30 fill="oklch(95% 15% 80deg)" />
|
||||
<text x=120 y=22> off (3) </text>
|
||||
</g>
|
||||
<g transform="translate(240 0)">
|
||||
<rect width=160 height=30 fill="oklch(95% 15% 60deg)" />
|
||||
<text x=80 y=22> size (2) </text>
|
||||
</g>
|
||||
<g transform="translate(400 0)">
|
||||
<path fill="oklch(95% 15% 40deg)"
|
||||
d="M 240,0 h -240 v 30 h 240
|
||||
l -5,-5 5,-5 -5,-5 5,-5 -5,-5 z" />
|
||||
<text x=120 y=22> data (<tspan font-style=italic>size</tspan>) … </text>
|
||||
</g>
|
||||
</svg>
|
||||
</figure>
|
||||
```
|
||||
|
||||
> lit :: Parser IpsChunkBody
|
||||
> lit = do
|
||||
> size <- word16BE
|
||||
> Lit <$> bytes (fromIntegral size)
|
||||
|
||||
|
||||
== BPS file format
|
||||
|
||||
BPS uses arbitrary-size numbers using a variable-length, **little-endian**
|
||||
encoding.
|
||||
|
||||
Each byte contains seven bits of data, using the most significant bit to
|
||||
indicate whether the number continues. One extra quirk is that each non-final
|
||||
byte is also decremented. the rationale for this is that otherwise, it would be
|
||||
possible to encode `1` as `0x81` but also as `0x01_80`, with the meaning
|
||||
`000 0000 000 0001` with extra zero padding. with this extra step, the second
|
||||
encoding actually represents `000 0001 000 0001`, or 129.
|
||||
|
||||
so we read the input until reaching a byte with the MSB set, plus one more, then
|
||||
shift them all together, right to left.
|
||||
|
||||
|
||||
> bpsNumber :: Parser Natural
|
||||
> bpsNumber = do
|
||||
> rest <- bytesWhile \b -> not (testBit b 7)
|
||||
> end <- word8
|
||||
> pure (BS.foldr' combine (fromIntegral (clearBit end 7)) rest)
|
||||
> where
|
||||
> combine byte acc = fromIntegral byte .|. (acc + 1) `shiftL` 7
|
||||
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
testing `bpsNumber`
|
||||
</summary>
|
||||
<!--
|
||||
#ifdef TESTS
|
||||
-->
|
||||
|
||||
this function is _almost_ a nice fold, but i'm not yet 100% certain the `(+ 1)`
|
||||
is in the right place. so here's a more literal translation of the C code:
|
||||
|
||||
> bpsNumberSpec :: Parser Natural
|
||||
> bpsNumberSpec = loop 0 1 where
|
||||
> loop acc shft = do
|
||||
> byte <- word8
|
||||
> let acc' = acc + fromIntegral (clearBit byte 7) * shft
|
||||
> let shft' = shft `shiftL` 7
|
||||
> if testBit byte 7 then
|
||||
> pure acc'
|
||||
> else
|
||||
> loop (acc' + shft') shft'
|
||||
|
||||
a valid encoding of a number is a string of zero or more bytes with the MSB
|
||||
unset, followed by one with it set.
|
||||
|
||||
> newtype BpsNumber = BpsNumber ByteString
|
||||
> deriving Show via ParserInput
|
||||
>
|
||||
> instance Arbitrary BpsNumber where
|
||||
> arbitrary = do
|
||||
> rest :: [Word8] <- arbitrary
|
||||
> final :: Word8 <- arbitrary
|
||||
> let list = map (\b -> clearBit b 7) rest ++ [setBit final 7]
|
||||
> pure (BpsNumber (BS.pack list))
|
||||
|
||||
both functions should accept any string of this format, and should each give the
|
||||
same result.
|
||||
|
||||
> prop_bpsOk (BpsNumber e) =
|
||||
> let bps' = runParser bpsNumberSpec e
|
||||
> bps = runParser bpsNumber e in
|
||||
> isRight bps' .&&. isRight bps .&&. bps' === bps
|
||||
|
||||
<!--
|
||||
#endif
|
||||
-->
|
||||
</details>
|
||||
|
||||
|
||||
> props :: [(String, Property)]
|
||||
> props =
|
||||
> [("prop_bytes_short", property prop_bytes_short),
|
||||
> ("prop_bytes_enough", property prop_bytes_enough),
|
||||
> ("prop_bytes_length", property prop_bytes_length),
|
||||
> ("prop_bytes_append", property prop_bytes_append),
|
||||
> ("prop_bytesWhile_succeed", property prop_bytesWhile_succeed),
|
||||
> ("prop_bytesWhile_result", property prop_bytesWhile_result),
|
||||
> ("prop_bytesWhile_rest", property prop_bytesWhile_rest),
|
||||
> ("prop_bpsOk", property prop_bpsOk)]
|
||||
>
|
||||
> testAll :: IO ()
|
||||
> testAll = forM_ props \(name, prop) -> do
|
||||
> putStr (name ++ ": ")
|
||||
> quickCheck prop
|
||||
|
||||
|
||||
== applying a patch
|
||||
|
||||
|
||||
> _outSize :: [IpsChunk] -> ByteString -> Int
|
||||
> _outSize patch orig =
|
||||
> foldl' (\s c -> max s (end c)) (BS.length orig) patch
|
||||
> where
|
||||
> end (IpsChunk offset body) = fromIntegral offset + size body
|
||||
> size (Lit str) = BS.length str
|
||||
> size (RLE sz _) = fromIntegral sz
|
||||
|
||||
|
||||
|
||||
|
||||
> main :: IO ()
|
||||
> main = testAll
|
38
style/cross.svg
Normal file
38
style/cross.svg
Normal file
|
@ -0,0 +1,38 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100" width="50" height="50">
|
||||
|
||||
<style><![CDATA[
|
||||
:root {
|
||||
opacity: 0.75;
|
||||
--light: hsl(215deg, 14%, 62%);
|
||||
--dark: hsl(236deg, 10%, 33%);
|
||||
}
|
||||
* {
|
||||
transform-box: border-box;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
#outline, #shadow {
|
||||
stroke: var(--dark);
|
||||
stroke-width: 12px;
|
||||
}
|
||||
#fill { fill: var(--light); }
|
||||
#shadow {
|
||||
opacity: 0.3;
|
||||
transform: translate(5px, 5px);
|
||||
}
|
||||
]]></style>
|
||||
|
||||
<defs>
|
||||
<g id="plus">
|
||||
<rect x="10" y="40" width="80" height="20" />
|
||||
<rect x="40" y="10" width="20" height="80" />
|
||||
</g>
|
||||
</defs>
|
||||
|
||||
<g transform="rotate(-45)">
|
||||
<use href="#plus" id="shadow" />
|
||||
<use href="#plus" id="outline" />
|
||||
<use href="#plus" id="fill" />
|
||||
</g>
|
||||
|
||||
</svg>
|
After Width: | Height: | Size: 764 B |
BIN
style/dark-stripes.png
Normal file
BIN
style/dark-stripes.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
BIN
style/kinda-jean.png
Normal file
BIN
style/kinda-jean.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.4 KiB |
BIN
style/rec/MonoBdItalic.woff2
Normal file
BIN
style/rec/MonoBdItalic.woff2
Normal file
Binary file not shown.
BIN
style/rec/MonoBold.woff2
Normal file
BIN
style/rec/MonoBold.woff2
Normal file
Binary file not shown.
BIN
style/rec/MonoItalic.woff2
Normal file
BIN
style/rec/MonoItalic.woff2
Normal file
Binary file not shown.
BIN
style/rec/MonoLight.woff2
Normal file
BIN
style/rec/MonoLight.woff2
Normal file
Binary file not shown.
BIN
style/rec/MonoLtItalic.woff2
Normal file
BIN
style/rec/MonoLtItalic.woff2
Normal file
Binary file not shown.
BIN
style/rec/MonoRegular.woff2
Normal file
BIN
style/rec/MonoRegular.woff2
Normal file
Binary file not shown.
BIN
style/rec/MonoSemiBd.woff2
Normal file
BIN
style/rec/MonoSemiBd.woff2
Normal file
Binary file not shown.
BIN
style/rec/MonoSmBdItalic.woff2
Normal file
BIN
style/rec/MonoSmBdItalic.woff2
Normal file
Binary file not shown.
BIN
style/rec/SansBdItalic.woff2
Normal file
BIN
style/rec/SansBdItalic.woff2
Normal file
Binary file not shown.
BIN
style/rec/SansBold.woff2
Normal file
BIN
style/rec/SansBold.woff2
Normal file
Binary file not shown.
BIN
style/rec/SansItalic.woff2
Normal file
BIN
style/rec/SansItalic.woff2
Normal file
Binary file not shown.
BIN
style/rec/SansLight.woff2
Normal file
BIN
style/rec/SansLight.woff2
Normal file
Binary file not shown.
BIN
style/rec/SansLtItalic.woff2
Normal file
BIN
style/rec/SansLtItalic.woff2
Normal file
Binary file not shown.
BIN
style/rec/SansRegular.woff2
Normal file
BIN
style/rec/SansRegular.woff2
Normal file
Binary file not shown.
BIN
style/rec/SansSemiBd.woff2
Normal file
BIN
style/rec/SansSemiBd.woff2
Normal file
Binary file not shown.
BIN
style/rec/SansSmBdItalic.woff2
Normal file
BIN
style/rec/SansSmBdItalic.woff2
Normal file
Binary file not shown.
145
style/rec/rec.css
Normal file
145
style/rec/rec.css
Normal file
|
@ -0,0 +1,145 @@
|
|||
@font-face {
|
||||
font-family: 'RecSans';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url(SansLight.woff2);
|
||||
font-feature-settings: "ss01", "ss02", "ss07", "ss08", "ss09", "ss12";
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'RecSans';
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
src: url(SansLtItalic.woff2);
|
||||
font-feature-settings: "ss01", "ss02", "ss07", "ss08", "ss09", "ss12";
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'RecSans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(SansRegular.woff2);
|
||||
font-feature-settings: "ss01", "ss02", "ss07", "ss08", "ss09", "ss12";
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'RecSans';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src: url(SansItalic.woff2);
|
||||
font-feature-settings: "ss01", "ss02", "ss07", "ss08", "ss09", "ss12";
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'RecSans';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url(SansSemiBd.woff2);
|
||||
font-feature-settings: "ss01", "ss02", "ss07", "ss08", "ss09", "ss12";
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'RecSans';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
src: url(SansSmBdItalic.woff2);
|
||||
font-feature-settings: "ss01", "ss02", "ss07", "ss08", "ss09", "ss12";
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'RecSans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url(SansBold.woff2);
|
||||
font-feature-settings: "ss01", "ss02", "ss07", "ss08", "ss09", "ss12";
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'RecSans';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
src: url(SansBdItalic.woff2);
|
||||
font-feature-settings: "ss01", "ss02", "ss07", "ss08", "ss09", "ss12";
|
||||
}
|
||||
|
||||
|
||||
|
||||
@font-face {
|
||||
font-family: 'RecMono';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url(MonoLight.woff2);
|
||||
font-feature-settings:
|
||||
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09",
|
||||
"ss10", "ss12";
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'RecMono';
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
src: url(MonoLtItalic.woff2);
|
||||
font-feature-settings:
|
||||
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09",
|
||||
"ss10", "ss12";
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'RecMono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(MonoRegular.woff2);
|
||||
font-feature-settings:
|
||||
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09",
|
||||
"ss10", "ss12";
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'RecMono';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src: url(MonoItalic.woff2);
|
||||
font-feature-settings:
|
||||
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09",
|
||||
"ss10", "ss12";
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'RecMono';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url(MonoSemiBd.woff2);
|
||||
font-feature-settings:
|
||||
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09",
|
||||
"ss10", "ss12";
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'RecMono';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
src: url(MonoSmBdItalic.woff2);
|
||||
font-feature-settings:
|
||||
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09",
|
||||
"ss10", "ss12";
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'RecMono';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url(MonoBold.woff2);
|
||||
font-feature-settings:
|
||||
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09",
|
||||
"ss10", "ss12";
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'RecMono';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
src: url(MonoBdItalic.woff2);
|
||||
font-feature-settings:
|
||||
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09",
|
||||
"ss10", "ss12";
|
||||
}
|
253
style/style.css
Normal file
253
style/style.css
Normal file
|
@ -0,0 +1,253 @@
|
|||
@import url(rec/rec.css);
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--body-font: RecSans;
|
||||
--mono-font: RecMono;
|
||||
|
||||
--base-hue: 300deg;
|
||||
|
||||
font-family: var(--body-font);
|
||||
|
||||
background:
|
||||
url(dark-stripes.png) fixed,
|
||||
linear-gradient(to bottom,
|
||||
oklch(60% 23% var(--base-hue)),
|
||||
oklch(45% 18% var(--base-hue))) fixed;
|
||||
background-blend-mode: multiply;
|
||||
}
|
||||
|
||||
body {
|
||||
background: url(kinda-jean.png), oklch(93% 10% var(--base-hue));
|
||||
background-blend-mode: multiply;
|
||||
padding: 2em 4em;
|
||||
width: min(95%, 54em);
|
||||
margin: 3em auto;
|
||||
border-radius: 1em;
|
||||
border: 3px solid oklch(20% 15% var(--base-hue));
|
||||
box-shadow: 1em 0.667em 2em oklch(30% 15% var(--base-hue) / 50%);
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
h1 {
|
||||
font-weight: 200;
|
||||
font-size: 250%;
|
||||
margin: 0 0 2rem;
|
||||
padding: 0 0 0.333rem;
|
||||
border-bottom: 2px dotted currentcolor;
|
||||
}
|
||||
}
|
||||
|
||||
:root { --marker-color: oklch(40% 40% var(--base-hue)); }
|
||||
|
||||
:is(main, nav) :is(h2, h3, h4, summary) {
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
|
||||
margin: 1.5em 0 0.25em -1.5rem;
|
||||
}
|
||||
|
||||
:is(main, nav) :is(h2, h3, h4) {
|
||||
&::before {
|
||||
content: '♡';
|
||||
margin-right: 0.5em;
|
||||
color: var(--marker-color);
|
||||
font-size: 90%;
|
||||
display: inline-block;
|
||||
rotate: -23deg;
|
||||
}
|
||||
}
|
||||
|
||||
h2 { font-size: 150%; }
|
||||
h3 { font-size: 125%; }
|
||||
h4 { font-size: 100%; }
|
||||
|
||||
code, .iname {
|
||||
font-family: var(--mono-font);
|
||||
font-size: inherit;
|
||||
}
|
||||
pre { font-size: 85%; }
|
||||
|
||||
.idesc {
|
||||
font-variant: small-caps;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
a {
|
||||
--link-hue: calc(var(--base-hue) + 150deg);
|
||||
--link-col: oklch(40% 60% var(--link-hue));
|
||||
--link-col-fade: oklch(35% 50% var(--link-hue));
|
||||
color: var(--link-col);
|
||||
text-decoration: 1px dotted underline;
|
||||
text-decoration-color: var(--link-col-fade);
|
||||
|
||||
&[href^='#'] { --link-hue: var(--base-hue); }
|
||||
&:visited { color: var(--link-col-fade); }
|
||||
}
|
||||
|
||||
code {
|
||||
.al {} /* Alert */
|
||||
.an {} /* Annotation */
|
||||
.at {} /* Attribute */
|
||||
.bn {} /* BaseN */
|
||||
.bu {} /* BuiltIn */
|
||||
.cf {} /* ControlFlow */
|
||||
.ch {} /* Char */
|
||||
.cn {} /* Constant */
|
||||
.co {
|
||||
/* Comment */
|
||||
color: oklch(30% 10% var(--base-hue));
|
||||
/* font-style: italic; */
|
||||
font-family: var(--body-font);
|
||||
}
|
||||
.do {} /* Documentation */
|
||||
.dt {} /* DataType */
|
||||
.dv {} /* DecVal */
|
||||
.er {} /* Error */
|
||||
.ex {} /* Extension */
|
||||
.fl {} /* Float */
|
||||
.fu {} /* Function */
|
||||
.im {} /* Import */
|
||||
.in {} /* Information */
|
||||
.kw { font-weight: 600; } /* Keyword */
|
||||
.op {} /* Operator */
|
||||
.pp {} /* Preprocessor */
|
||||
.sc {} /* SpecialChar */
|
||||
.ss {} /* SpecialString */
|
||||
.st {} /* String */
|
||||
.va {} /* Variable */
|
||||
.vs {} /* VerbatimString */
|
||||
.wa {} /* Warning */
|
||||
}
|
||||
|
||||
|
||||
|
||||
@property --box-hue {
|
||||
syntax: "<angle>";
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
pre,
|
||||
div:is(.todo, .note),
|
||||
aside:is(.left, .right) {
|
||||
--bg-texture: url(kinda-jean.png);
|
||||
/* --bg-color: oklch(93% 17% var(--box-hue)); */
|
||||
--bg-gradient:
|
||||
linear-gradient(in oklch to right,
|
||||
oklch(91% 15% var(--box-hue)),
|
||||
oklch(87% 18% var(--box-hue)));
|
||||
--base-bg: var(--bg-texture), var(--bg-gradient);
|
||||
background: var(--base-bg);
|
||||
background-blend-mode: multiply;
|
||||
|
||||
border: 1px solid oklch(35% 35% var(--box-hue));
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0.5rem 0.333rem 0.5rem oklch(45% 25% var(--box-hue) / 25%);
|
||||
|
||||
margin: 1rem 2rem;
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
& + & { margin-top: 0.5rem !important; }
|
||||
}
|
||||
pre { --box-hue: 250deg; }
|
||||
.literate { --box-hue: var(--base-hue); }
|
||||
.todo { --box-hue: 350deg; }
|
||||
.note, aside { --box-hue: 90deg; }
|
||||
pre.haskell:not(.literate) {
|
||||
--box-hue: 20deg;
|
||||
background:
|
||||
url(cross.svg) bottom 10px right 10px no-repeat,
|
||||
var(--base-bg);
|
||||
}
|
||||
|
||||
|
||||
.note {
|
||||
font-size: 90%;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
figure {
|
||||
width: fit-content;
|
||||
margin: auto;
|
||||
&.shadowed {
|
||||
filter:
|
||||
drop-shadow(0.5rem 0.333rem 0.5rem oklch(45% 25% var(--base-hue) / 25%));
|
||||
}
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
}
|
||||
dt {
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
grid-area: auto / 1 / auto / auto;
|
||||
padding-right: 2em;
|
||||
}
|
||||
dd {
|
||||
grid-area: auto / 2 / auto / auto;
|
||||
display: list-item;
|
||||
}
|
||||
dt, dd { margin: 0; }
|
||||
dt, dt + dd {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
:is(nav, ul) > li, dd {
|
||||
list-style: '→ ';
|
||||
}
|
||||
|
||||
:is(li, dd)::marker {
|
||||
font-size: 80%;
|
||||
color: var(--marker-color);
|
||||
}
|
||||
|
||||
aside {
|
||||
font-size: 90%;
|
||||
&.left, &.right {
|
||||
width: 30%;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.25em 0.75em;
|
||||
}
|
||||
&.left {
|
||||
float: left;
|
||||
margin-right: 1em;
|
||||
}
|
||||
&.right {
|
||||
float: right;
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
summary {
|
||||
font-size: 125%;
|
||||
font-style: italic;
|
||||
|
||||
&::marker { color: var(--marker-color); }
|
||||
|
||||
&::after {
|
||||
content: '[show]';
|
||||
font-size: 75%;
|
||||
font-style: normal;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
[open] > &::after { content: '[hide]'; }
|
||||
}
|
||||
|
||||
details[open]::after, nav::after {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 2px;
|
||||
background:
|
||||
linear-gradient(to right,
|
||||
transparent, var(--marker-color) 20%,
|
||||
var(--marker-color) 80%, transparent);
|
||||
opacity: 60%;
|
||||
width: 60%;
|
||||
margin: 1rem auto 2rem;
|
||||
}
|
46
template.html
Normal file
46
template.html
Normal file
|
@ -0,0 +1,46 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport
|
||||
content="width=device-width, initial-scale=1.0, user-scalable=yes">
|
||||
|
||||
<link rel=stylesheet href=/ips/style/style.css>
|
||||
$for(css)$
|
||||
<link rel=stylesheet href="$css$">
|
||||
$endfor$
|
||||
|
||||
$for(header-includes)$
|
||||
$header-includes$
|
||||
$endfor$
|
||||
|
||||
<title>$if(title-prefix)$$title-prefix$ – $endif$$pagetitle$</title>
|
||||
|
||||
$for(include-before)$
|
||||
$include-before$
|
||||
$endfor$
|
||||
|
||||
$if(title)$
|
||||
<header>
|
||||
<h1>$title$</h1>
|
||||
$if(subtitle)$
|
||||
<p>$subtitle$
|
||||
$endif$
|
||||
</header>
|
||||
$endif$
|
||||
|
||||
$if(toc)$
|
||||
<nav id="toc" role="doc-toc">
|
||||
$if(toc-title)$
|
||||
<h2>$toc-title$</h2>
|
||||
$endif$
|
||||
$table-of-contents$
|
||||
</nav>
|
||||
$endif$
|
||||
|
||||
<main>
|
||||
$body$
|
||||
</main>
|
||||
|
||||
$for(include-after)$
|
||||
$include-after$
|
||||
$endfor$
|
Loading…
Add table
Add a link
Reference in a new issue