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