summary refs log tree commit diff
path: root/bin
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--bin/.gitignore37
-rw-r--r--bin/LICENSE661
-rw-r--r--bin/Makefile142
-rw-r--r--bin/README.789
-rw-r--r--bin/beef.c141
-rw-r--r--bin/bibsort.pl69
-rw-r--r--bin/bit.y202
-rw-r--r--bin/c.sh121
-rw-r--r--bin/c11.l144
-rw-r--r--bin/dehtml.l150
-rw-r--r--bin/downgrade.c362
-rw-r--r--bin/dtch.c271
-rw-r--r--bin/enc.sh70
-rw-r--r--bin/ever.c119
-rw-r--r--bin/freecell.c388
-rw-r--r--bin/git-comment.pl92
-rw-r--r--bin/glitch.c605
-rw-r--r--bin/hilex.c406
-rw-r--r--bin/hilex.h50
-rw-r--r--bin/htagml.c223
-rw-r--r--bin/html.mk47
-rw-r--r--bin/html.sh66
-rw-r--r--bin/make.l127
-rw-r--r--bin/man1/beef.191
-rw-r--r--bin/man1/bibsort.140
-rw-r--r--bin/man1/bit.155
-rw-r--r--bin/man1/c.145
-rw-r--r--bin/man1/dehtml.138
-rw-r--r--bin/man1/downgrade.1122
-rw-r--r--bin/man1/dtch.167
-rw-r--r--bin/man1/enc.155
-rw-r--r--bin/man1/ever.151
-rw-r--r--bin/man1/git-comment.1117
-rw-r--r--bin/man1/glitch.177
-rw-r--r--bin/man1/hilex.1218
-rw-r--r--bin/man1/htagml.175
-rw-r--r--bin/man1/modem.131
-rw-r--r--bin/man1/mtags.176
-rw-r--r--bin/man1/nudge.144
-rw-r--r--bin/man1/order.138
-rw-r--r--bin/man1/pbd.166
-rw-r--r--bin/man1/pngo.164
-rw-r--r--bin/man1/psf2png.153
-rw-r--r--bin/man1/ptee.151
-rw-r--r--bin/man1/qf.171
-rw-r--r--bin/man1/quick.166
-rw-r--r--bin/man1/relay.148
-rw-r--r--bin/man1/scheme.159
-rw-r--r--bin/man1/shotty.1115
-rw-r--r--bin/man1/sup.151
-rw-r--r--bin/man1/title.151
-rw-r--r--bin/man1/up.177
-rw-r--r--bin/man1/when.1100
-rw-r--r--bin/man1/xx.168
-rw-r--r--bin/man3/png.390
-rw-r--r--bin/man6/freecell.650
-rw-r--r--bin/mdoc.l60
-rw-r--r--bin/modem.c102
-rw-r--r--bin/mtags.c105
-rw-r--r--bin/nudge.c78
-rw-r--r--bin/order.y195
-rw-r--r--bin/pbd.c151
-rw-r--r--bin/png.h108
-rw-r--r--bin/pngo.c941
-rw-r--r--bin/psf2png.c107
-rw-r--r--bin/ptee.c151
-rw-r--r--bin/qf.c294
-rw-r--r--bin/quick.c163
-rw-r--r--bin/relay.c218
-rw-r--r--bin/scheme.c278
-rw-r--r--bin/sh.l181
-rw-r--r--bin/shotty.l597
-rw-r--r--bin/sup.sh283
-rw-r--r--bin/title.c211
-rw-r--r--bin/up.sh94
-rw-r--r--bin/when.y353
-rw-r--r--bin/xx.c142
77 files changed, 11714 insertions, 0 deletions
diff --git a/bin/.gitignore b/bin/.gitignore
new file mode 100644
index 00000000..42269bac
--- /dev/null
+++ b/bin/.gitignore
@@ -0,0 +1,37 @@
+*.html
+*.o
+beef
+bibsort
+bit
+c
+config.mk
+dehtml
+downgrade
+dtch
+enc
+ever
+freecell
+git-comment
+glitch
+hilex
+htagml
+htmltags
+modem
+mtags
+nudge
+order
+pbd
+pngo
+psf2png
+ptee
+qf
+quick
+relay
+scheme
+shotty
+sup
+tags
+title
+up
+when
+xx
diff --git a/bin/LICENSE b/bin/LICENSE
new file mode 100644
index 00000000..dba13ed2
--- /dev/null
+++ b/bin/LICENSE
@@ -0,0 +1,661 @@
+                    GNU AFFERO GENERAL PUBLIC LICENSE
+                       Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+  A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate.  Many developers of free software are heartened and
+encouraged by the resulting cooperation.  However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+  The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community.  It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server.  Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+  An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals.  This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU Affero General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Remote Network Interaction; Use with the GNU General Public License.
+
+  Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software.  This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time.  Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source.  For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code.  There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<http://www.gnu.org/licenses/>.
diff --git a/bin/Makefile b/bin/Makefile
new file mode 100644
index 00000000..bb1535d6
--- /dev/null
+++ b/bin/Makefile
@@ -0,0 +1,142 @@
+PREFIX ?= ~/.local
+MANDIR ?= ${PREFIX}/share/man
+
+LIBS_PREFIX ?= /usr/local
+CFLAGS += -I${LIBS_PREFIX}/include
+LDFLAGS += -L${LIBS_PREFIX}/lib
+
+CFLAGS += -Wall -Wextra -Wpedantic -Wno-gnu-case-range
+
+BINS += beef
+BINS += bibsort
+BINS += bit
+BINS += c
+BINS += dehtml
+BINS += dtch
+BINS += enc
+BINS += git-comment
+BINS += glitch
+BINS += hilex
+BINS += htagml
+BINS += modem
+BINS += mtags
+BINS += nudge
+BINS += order
+BINS += pbd
+BINS += pngo
+BINS += psf2png
+BINS += ptee
+BINS += qf
+BINS += quick
+BINS += scheme
+BINS += shotty
+BINS += sup
+BINS += title
+BINS += up
+BINS += when
+BINS += xx
+
+BSD += ever
+
+GAMES += freecell
+
+TLS += downgrade
+TLS += relay
+
+MANS = ${BINS:%=man1/%.1}
+MANS.BSD = ${BSD:%=man1/%.1}
+MANS.GAMES = ${GAMES:%=man6/%.6}
+MANS.TLS = ${TLS:%=man1/%.1}
+
+LDLIBS.downgrade = -ltls
+LDLIBS.dtch = -lutil
+LDLIBS.fbclock = -lz
+LDLIBS.freecell = -lcurses
+LDLIBS.glitch = -lz
+LDLIBS.modem = -lutil
+LDLIBS.pngo = -lz
+LDLIBS.ptee = -lutil
+LDLIBS.qf = -lcurses
+LDLIBS.relay = -ltls
+LDLIBS.scheme = -lm
+LDLIBS.title = -lcurl
+LDLIBS.typer = -ltls
+
+ALL ?= meta any
+
+-include config.mk
+
+all: ${ALL}
+
+meta: .gitignore tags
+
+any: ${BINS}
+
+bsd: ${BSD}
+
+games: ${GAMES}
+
+tls: ${TLS}
+
+IGNORE = *.o *.html
+IGNORE += ${BINS} ${BSD} ${GAMES} ${TLS}
+IGNORE += tags htmltags
+
+.gitignore: Makefile
+	echo config.mk '${IGNORE}' | tr ' ' '\n' | sort > $@
+
+tags: *.[chly]
+	ctags -w *.[chly]
+
+clean:
+	rm -f ${IGNORE}
+
+install: ${ALL:%=install-%}
+
+install-meta:
+	install -d ${PREFIX}/bin ${MANDIR}/man1
+
+install-any: install-meta ${BINS} ${MANS}
+	install ${BINS} ${PREFIX}/bin
+	install -m 644 ${MANS} ${MANDIR}/man1
+
+install-bsd: install-meta ${BSD} ${MANS.BSD}
+	install ${BSD} ${PREFIX}/bin
+	install -m 644 ${MANS.BSD} ${MANDIR}/man1
+
+install-games: install-meta ${GAMES} ${MANS.GAMES}
+	install ${GAMES} ${PREFIX}/bin
+	install -m 644 ${MANS.GAMES} ${MANDIR}/man6
+
+install-tls: install-meta ${TLS} ${MANS.TLS}
+	install ${TLS} ${PREFIX}/bin
+	install -m 644 ${MANS.TLS} ${MANDIR}/man1
+
+uninstall:
+	rm -f ${BINS:%=${PREFIX}/bin/%} ${MANS:%=${MANDIR}/%}
+	rm -f ${BSD:%=${PREFIX}/bin/%} ${MANS.BSD:%=${MANDIR}/%}
+	rm -f ${GAMES:%=${PREFIX}/bin/%} ${MANS.GAMES:%=${MANDIR}/%}
+	rm -f ${TLS:%=${PREFIX}/bin/%} ${MANS.TLS:%=${MANDIR}/%}
+
+.SUFFIXES: .pl
+
+.c:
+	${CC} ${CFLAGS} ${LDFLAGS} $< ${LDLIBS.$@} -o $@
+
+.o:
+	${CC} ${LDFLAGS} $< ${LDLIBS.$@} -o $@
+
+.pl:
+	cp -f $< $@
+	chmod a+x $@
+
+OBJS.hilex = c11.o hilex.o make.o mdoc.o sh.o
+
+hilex: ${OBJS.hilex}
+	${CC} ${LDFLAGS} ${OBJS.$@} ${LDLIBS.$@} -o $@
+
+${OBJS.hilex}: hilex.h
+
+psf2png.o scheme.o: png.h
+
+include html.mk
diff --git a/bin/README.7 b/bin/README.7
new file mode 100644
index 00000000..100e183e
--- /dev/null
+++ b/bin/README.7
@@ -0,0 +1,89 @@
+.Dd June  2, 2022
+.Dt BIN 7
+.Os "Causal Agency"
+.
+.Sh NAME
+.Nm bin
+.Nd various utilities
+.
+.Sh DESCRIPTION
+Various tools primarily targeting
+.Fx ,
+.Ox
+and macOS.
+.
+.Pp
+.Bl -tag -width "git-comment(1)" -compact
+.It Xr beef 1
+Befunge-93 interpreter
+.It Xr bibsort 1
+reformat bibliography
+.It Xr bit 1
+calculator
+.It Xr c 1
+run C statements
+.It Xr dehtml 1
+extract text from HTML
+.It Xr downgrade 1
+IRC features for all
+.It Xr dtch 1
+detached sessions
+.It Xr enc 1
+encrypt and decrypt files
+.It Xr ever 1
+watch files
+.It Xr freecell 6
+patience game
+.It Xr git-comment 1
+add commit comments
+.It Xr glitch 1
+PNG glitcher
+.It Xr hilex 1
+syntax highlighter
+.It Xr htagml 1
+tags HTMLizer
+.It Xr modem 1
+fixed baud rate wrapper
+.It Xr mtags 1
+miscellaneous tags
+.It Xr nudge 1
+terminal vibrator
+.It Xr order 1
+operator precedence
+.It Xr pbd 1
+macOS pasteboard daemon
+.It Xr pngo 1
+PNG optimizer
+.It Xr psf2png 1
+PSF2 to PNG renderer
+.It Xr ptee 1
+tee for PTYs
+.It Xr qf 1
+grep pager
+.It Xr quick 1
+terrible HTTP/CGI server
+.It Xr relay 1
+IRC relay bot
+.It Xr scheme 1
+color scheme
+.It Xr shotty 1
+terminal capture
+.It Xr sup 1
+single-use passwords
+.It Xr title 1
+page titles
+.It Xr up 1
+upload file
+.It Xr when 1
+date calculator
+.It Xr xx 1
+hexdump
+.El
+.
+.Pp
+One piece of reused code.
+.Pp
+.Bl -tag -width "png(3)" -compact
+.It Xr png 3
+basic PNG output
+.El
diff --git a/bin/beef.c b/bin/beef.c
new file mode 100644
index 00000000..556f3088
--- /dev/null
+++ b/bin/beef.c
@@ -0,0 +1,141 @@
+/* Copyright (C) 2019  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <err.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <time.h>
+
+enum {
+	Cols = 80,
+	Rows = 25,
+};
+static char page[Rows][Cols];
+
+static char get(int y, int x) {
+	if (y < 0 || y >= Rows) return 0;
+	if (x < 0 || x >= Cols) return 0;
+	return page[y][x];
+}
+static void put(int y, int x, char v) {
+	if (y < 0 || y >= Rows) return;
+	if (x < 0 || x >= Cols) return;
+	page[y][x] = v;
+}
+
+enum { StackLen = 1024 };
+static long stack[StackLen];
+static size_t top = StackLen;
+
+static void push(long val) {
+	if (!top) errx(EX_SOFTWARE, "stack overflow");
+	stack[--top] = val;
+}
+static long pop(void) {
+	if (top == StackLen) return 0;
+	return stack[top++];
+}
+
+static struct {
+	int y, x;
+	int dy, dx;
+} pc = { .dx = 1 };
+
+static void inc(void) {
+	pc.y += pc.dy;
+	pc.x += pc.dx;
+	if (pc.y == -1) pc.y += Rows;
+	if (pc.x == -1) pc.x += Cols;
+	if (pc.y == Rows) pc.y -= Rows;
+	if (pc.x == Cols) pc.x -= Cols;
+}
+
+static bool string;
+
+static bool step(void) {
+	char ch = page[pc.y][pc.x];
+
+	if (ch == '"') {
+		string ^= true;
+	} else if (string) {
+		push(ch);
+		inc();
+		return true;
+	}
+
+	if (ch == '?') ch = "><^v"[rand() % 4];
+
+	long x, y, v;
+	switch (ch) {
+		break; case '+': push(pop() + pop());
+		break; case '-': y = pop(); x = pop(); push(x - y);
+		break; case '*': push(pop() * pop());
+		break; case '/': y = pop(); x = pop(); push(x / y);
+		break; case '%': y = pop(); x = pop(); push(x % y);
+		break; case '!': push(!pop());
+		break; case '`': y = pop(); x = pop(); push(x > y);
+		break; case '>': pc.dy = 0; pc.dx = +1;
+		break; case '<': pc.dy = 0; pc.dx = -1;
+		break; case '^': pc.dy = -1; pc.dx = 0;
+		break; case 'v': pc.dy = +1; pc.dx = 0;
+		break; case '_': pc.dy = 0; pc.dx = (pop() ? -1 : +1);
+		break; case '|': pc.dx = 0; pc.dy = (pop() ? -1 : +1);
+		break; case ':': x = pop(); push(x); push(x);
+		break; case '\\': y = pop(); x = pop(); push(y); push(x);
+		break; case '$': pop();
+		break; case '.': printf("%ld ", pop()); fflush(stdout);
+		break; case ',': printf("%c", (char)pop()); fflush(stdout);
+		break; case '#': inc();
+		break; case 'g': y = pop(); x = pop(); push(get(y, x));
+		break; case 'p': y = pop(); x = pop(); v = pop(); put(y, x, v);
+		break; case '&': x = EOF; scanf("%ld", &x); push(x);
+		break; case '~': push(getchar());
+		break; case '@': return false;
+		break; default:  if (ch >= '0' && ch <= '9') push(ch - '0');
+	}
+
+	inc();
+	return true;
+}
+
+int main(int argc, char *argv[]) {
+	srand(time(NULL));
+	memset(page, ' ', sizeof(page));
+
+	FILE *file = stdin;
+	if (argc > 1) {
+		file = fopen(argv[1], "r");
+		if (!file) err(EX_NOINPUT, "%s", argv[1]);
+	}
+
+	int y = 0;
+	char *line = NULL;
+	size_t cap = 0;
+	while (y < Rows && 0 < getline(&line, &cap, file)) {
+		for (int x = 0; x < Cols; ++x) {
+			if (line[x] == '\n' || !line[x]) break;
+			page[y][x] = line[x];
+		}
+		y++;
+	}
+	free(line);
+
+	while (step());
+	return pop();
+}
diff --git a/bin/bibsort.pl b/bin/bibsort.pl
new file mode 100644
index 00000000..a4a8956a
--- /dev/null
+++ b/bin/bibsort.pl
@@ -0,0 +1,69 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+
+while (<>) {
+	print;
+	last if /^[.]Sh STANDARDS$/;
+}
+
+my ($ref, @refs);
+while (<>) {
+	next if /^[.](Bl|It|$)/;
+	last if /^[.]El$/;
+	if (/^[.]Rs$/) {
+		$ref = {};
+	} elsif (/^[.]%(.) (.*)/) {
+		$ref->{$1} = [] unless $ref->{$1};
+		push @{$ref->{$1}}, $2;
+	} elsif (/^[.]Re$/) {
+		push @refs, $ref;
+	} else {
+		print;
+	}
+}
+
+sub byLast {
+	my ($af, $al) = split /\s(\S+)(,.*)?$/, $a;
+	my ($bf, $bl) = split /\s(\S+)(,.*)?$/, $b;
+	($al // $af) cmp ($bl // $bf) || $af cmp $bf;
+}
+
+foreach $ref (@refs) {
+	@{$ref->{A}} = sort byLast @{$ref->{A}};
+	@{$ref->{Q}} = sort @{$ref->{Q}} if $ref->{Q};
+	if ($ref->{N} && $ref->{N}[0] =~ /RFC/) {
+		$ref->{R} = $ref->{N};
+		delete $ref->{N};
+	}
+	if ($ref->{R} && $ref->{R}[0] =~ /RFC (\d+)/ && !$ref->{U}) {
+		$ref->{U} = ["https://tools.ietf.org/html/rfc${1}"];
+	}
+}
+
+sub byAuthor {
+	my ($ta, $tb) = ($a->{T}[0], $b->{T}[0]);
+	local ($a, $b) = ($a->{A}[0], $b->{A}[0]);
+	byLast() || $ta cmp $tb;
+}
+
+{
+	local ($,, $\) = (' ', "\n");
+	print '.Bl', '-item';
+	foreach $ref (sort byAuthor @refs) {
+		print '.It';
+		print '.Rs';
+		foreach my $key (qw(A T B I J R N V U P Q C D O)) {
+			next unless $ref->{$key};
+			foreach (@{$ref->{$key}}) {
+				print ".%${key}", $_;
+			}
+		}
+		print '.Re';
+	}
+	print '.El';
+}
+
+while (<>) {
+	print;
+}
diff --git a/bin/bit.y b/bin/bit.y
new file mode 100644
index 00000000..1119bce6
--- /dev/null
+++ b/bin/bit.y
@@ -0,0 +1,202 @@
+/* Copyright (C) 2019  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+%{
+
+#include <ctype.h>
+#include <err.h>
+#include <inttypes.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <sysexits.h>
+
+#define MASK(b) ((1ULL << (b)) - 1)
+
+#define YYSTYPE uint64_t
+
+static int yylex(void);
+static void yyerror(const char *str);
+static void print(uint64_t val);
+
+static uint64_t vars[128];
+
+%}
+
+%token Int Var
+
+%left '$'
+%right '='
+%left '|'
+%left '^'
+%left '&'
+%left Shl Shr Sar
+%left '+' '-'
+%left '*' '/' '%'
+%right '~'
+%left 'K' 'M' 'G' 'T'
+
+%%
+
+stmt:
+	| stmt expr '\n' { print(vars['_'] = $2); printf("\n"); }
+	| stmt expr ',' { print(vars['_'] = $2); }
+	| stmt '\n'
+	;
+
+expr:
+	Int
+	| Var { $$ = vars[$1]; }
+	| '(' expr ')' { $$ = $2; }
+	| expr 'K' { $$ = $1 << 10; }
+	| expr 'M' { $$ = $1 << 20; }
+	| expr 'G' { $$ = $1 << 30; }
+	| expr 'T' { $$ = $1 << 40; }
+	| '~' expr { $$ = ~$2; }
+	| '&' expr %prec '~' { $$ = MASK($2); }
+	| '+' expr %prec '~' { $$ = +$2; }
+	| '-' expr %prec '~' { $$ = -$2; }
+	| expr '*' expr { $$ = $1 * $3; }
+	| expr '/' expr { $$ = $1 / $3; }
+	| expr '%' expr { $$ = $1 % $3; }
+	| expr '+' expr { $$ = $1 + $3; }
+	| expr '-' expr { $$ = $1 - $3; }
+	| expr Shl expr { $$ = $1 << $3; }
+	| expr Shr expr { $$ = $1 >> $3; }
+	| expr Sar expr { $$ = (int64_t)$1 >> $3; }
+	| expr '&' expr { $$ = $1 & $3; }
+	| expr '^' expr { $$ = $1 ^ $3; }
+	| expr '|' expr { $$ = $1 | $3; }
+	| Var '=' expr { $$ = vars[$1] = $3; }
+	| expr '$' { $$ = $1; }
+	;
+
+%%
+
+static int lexInt(uint64_t base) {
+	yylval = 0;
+	for (int ch; EOF != (ch = getchar());) {
+		uint64_t digit = base;
+		if (ch == '_') {
+			continue;
+		} else if (isdigit(ch)) {
+			digit = ch - '0';
+		} else if (isxdigit(ch)) {
+			digit = 0xA + toupper(ch) - 'A';
+		}
+		if (digit >= base) {
+			ungetc(ch, stdin);
+			return Int;
+		}
+		yylval *= base;
+		yylval += digit;
+	}
+	return Int;
+}
+
+static int yylex(void) {
+	int ch;
+	while (isblank(ch = getchar()));
+	if (ch == '\'') {
+		yylval = 0;
+		while (EOF != (ch = getchar()) && ch != '\'') {
+			yylval <<= 8;
+			yylval |= ch;
+		}
+		return Int;
+	} else if (ch == '0') {
+		ch = getchar();
+		if (ch == 'b') {
+			return lexInt(2);
+		} else if (ch == 'x') {
+			return lexInt(16);
+		} else {
+			ungetc(ch, stdin);
+			return lexInt(8);
+		}
+	} else if (isdigit(ch)) {
+		ungetc(ch, stdin);
+		return lexInt(10);
+	} else if (ch == '_' || islower(ch)) {
+		yylval = ch;
+		return Var;
+	} else if (ch == '<') {
+		char ne = getchar();
+		if (ne == '<') {
+			return Shl;
+		} else {
+			ungetc(ne, stdin);
+			return ch;
+		}
+	} else if (ch == '-' || ch == '>') {
+		char ne = getchar();
+		if (ne == '>') {
+			return (ch == '-' ? Sar : Shr);
+		} else {
+			ungetc(ne, stdin);
+			return ch;
+		}
+	} else {
+		return ch;
+	}
+}
+
+static void yyerror(const char *str) {
+	warnx("%s", str);
+}
+
+static const char *Codes[128] = {
+	"NUL", "SOH", "STX", "ETX", "EOT", "ENQ", "ACK", "BEL",
+	"BS",  "HT",  "NL",  "VT",  "NP",  "CR",  "SO",  "SI",
+	"DLE", "DC1", "DC2", "DC3", "DC4", "NAK", "SYN", "ETB",
+	"CAN", "EM",  "SUB", "ESC", "FS",  "GS",  "RS",  "US",
+	[127] = "DEL",
+};
+
+static void print(uint64_t val) {
+	int bits = val > UINT32_MAX ? 64
+		: val > UINT16_MAX ? 32
+		: val > UINT8_MAX ? 16
+		: 8;
+	printf("0x%0*"PRIX64" %"PRId64"", bits >> 2, val, (int64_t)val);
+	if (bits == 8) {
+		char bin[9] = {0};
+		for (int i = 0; i < 8; ++i) {
+			bin[i] = '0' + (val >> (7 - i) & 1);
+		}
+		printf(" %#"PRIo64" 0b%s", val, bin);
+	}
+	if (val < 128) {
+		if (isprint(val)) printf(" '%c'", (char)val);
+		if (Codes[val]) printf(" %s", Codes[val]);
+	}
+	if (val) {
+		if (!(val & MASK(40))) {
+			printf(" %"PRIu64"T", val >> 40);
+		} else if (!(val & MASK(30))) {
+			printf(" %"PRIu64"G", val >> 30);
+		} else if (!(val & MASK(20))) {
+			printf(" %"PRIu64"M", val >> 20);
+		} else if (!(val & MASK(10))) {
+			printf(" %"PRIu64"K", val >> 10);
+		}
+	}
+	printf("\n");
+}
+
+int main(void) {
+	while (yyparse());
+}
diff --git a/bin/c.sh b/bin/c.sh
new file mode 100644
index 00000000..ff059437
--- /dev/null
+++ b/bin/c.sh
@@ -0,0 +1,121 @@
+#!/bin/sh
+set -eu
+
+temp=$(mktemp -d)
+trap 'rm -r "${temp}"' EXIT
+
+exec 3>>"${temp}/run.c"
+
+cat >&3 <<EOF
+#include <assert.h>
+#include <ctype.h>
+#include <errno.h>
+#include <inttypes.h>
+#include <limits.h>
+#include <locale.h>
+#include <math.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <wchar.h>
+#include <wctype.h>
+
+#include <dirent.h>
+#include <fcntl.h>
+#include <strings.h>
+#include <unistd.h>
+EOF
+
+expr=
+type=
+while getopts 'e:i:t' opt; do
+	case "${opt}" in
+		(e) expr=$OPTARG;;
+		(i) echo "#include <${OPTARG}>" >&3;;
+		(t) type=1;;
+		(?) exit 1;;
+	esac
+done
+shift $((OPTIND - 1))
+
+cat >&3 <<EOF
+int main(int argc, char *argv[]) {
+	(void)argc;
+	(void)argv;
+	$*;
+EOF
+
+if [ -n "${type}" ]; then
+	cat >&3 <<EOF
+	printf(
+		_Generic(
+			${expr},
+			char: "(char) ",
+			char *: "(char *) ",
+			const char *: "(const char *) ",
+			wchar_t *: "(wchar_t *) ",
+			const wchar_t *: "(const wchar_t *) ",
+			signed char: "(signed char) ",
+			short: "(short) ",
+			int: "(int) ",
+			long: "(long) ",
+			long long: "(long long) ",
+			unsigned char: "(unsigned char) ",
+			unsigned short: "(unsigned short) ",
+			unsigned int: "(unsigned int) ",
+			unsigned long: "(unsigned long) ",
+			unsigned long long: "(unsigned long long) ",
+			float: "(float) ",
+			double: "(double) ",
+			long double: "(long double) ",
+			default: "(void *) "
+		)
+	);
+EOF
+fi
+
+if [ -n "${expr}" ]; then
+	cat >&3 <<EOF
+	printf(
+		_Generic(
+			${expr},
+			char: "%c\n",
+			char *: "%s\n",
+			const char *: "%s\n",
+			wchar_t *: "%ls\n",
+			const wchar_t *: "%ls\n",
+			signed char: "%hhd\n",
+			short: "%hd\n",
+			int: "%d\n",
+			long: "%ld\n",
+			long long: "%lld\n",
+			unsigned char: "%hhu\n",
+			unsigned short: "%hu\n",
+			unsigned int: "%u\n",
+			unsigned long: "%lu\n",
+			unsigned long long: "%llu\n",
+			float: "%g\n",
+			double: "%g\n",
+			long double: "%Lg\n",
+			default: "%p\n"
+		),
+		${expr}
+	);
+EOF
+fi
+
+if [ $# -eq 0 -a -z "${expr}" ]; then
+	cat >&3
+fi
+
+echo '}' >&3
+
+cat >"${temp}/Makefile" <<EOF
+CFLAGS += -Wall -Wextra -Wpedantic
+EOF
+
+make -s -C "${temp}" run
+"${temp}/run"
diff --git a/bin/c11.l b/bin/c11.l
new file mode 100644
index 00000000..b1f0b960
--- /dev/null
+++ b/bin/c11.l
@@ -0,0 +1,144 @@
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+%option prefix="c11"
+%option noinput nounput noyywrap
+
+%{
+#include "hilex.h"
+%}
+
+%s MacroLine MacroInclude
+%x CharLiteral StringLiteral
+
+ident [_[:alpha:]][_[:alnum:]]*
+width "*"|[0-9]+
+
+%%
+	static int pop = INITIAL;
+
+[[:blank:]]+ { return Normal; }
+
+^"%"[%{}]? {
+	BEGIN(pop = MacroLine);
+	return Macro;
+}
+
+([-+*/%&|^=!<>]|"<<"|">>")"="? |
+[=~.?:]|"["|"]"|"++"|"--"|"&&"|"||"|"->" |
+sizeof|(_A|alignof) {
+	return Operator;
+}
+
+([1-9][0-9]*|"0"[0-7]*|"0x"[[:xdigit:]]+)([ulUL]{0,3}) |
+([0-9]+("."[0-9]*)?|[0-9]*"."[0-9]+)([eE][+-]?[0-9]+)?[flFL]? |
+"0x"[[:xdigit:]]*("."[[:xdigit:]]*)?([pP][+-]?[0-9]+)[flFL]? {
+	return Number;
+}
+
+auto|break|case|const|continue|default|do|else|enum|extern|for|goto|if|inline |
+register|restrict|return|static|struct|switch|typedef|union|volatile|while |
+(_A|a)lignas|_Atomic|_Generic|(_N|n)oreturn|(_S|s)tatic_assert |
+(_T|t)hread_local {
+	return Keyword;
+}
+
+^"#"[[:blank:]]*(include|import) {
+	BEGIN(pop = MacroInclude);
+	return Macro;
+}
+^"#"[[:blank:]]*{ident} {
+	BEGIN(pop = MacroLine);
+	return Macro;
+}
+<MacroInclude>"<"[^>]+">" {
+	return String;
+}
+<MacroLine,MacroInclude>{
+	"\n" {
+		BEGIN(pop = INITIAL);
+		return Normal;
+	}
+	"\\\n" { return Macro; }
+	{ident} { return Macro; }
+}
+
+{ident} { return Ident; }
+
+"//"([^\n]|"\\\n")* |
+"/*"([^*]|"*"+[^*/])*"*"+"/" {
+	return Comment;
+}
+
+[LUu]?"'"/[^\\] {
+	BEGIN(CharLiteral);
+	yymore();
+}
+[LUu]?"'" {
+	BEGIN(CharLiteral);
+	return String;
+}
+([LU]|u8?)?"\""/[^\\%] {
+	BEGIN(StringLiteral);
+	yymore();
+}
+([LU]|u8?)?"\"" {
+	BEGIN(StringLiteral);
+	return String;
+}
+
+<CharLiteral,StringLiteral>{
+	"\\\n" |
+	"\\"[''""?\\abfnrtv] |
+	"\\"([0-7]{1,3}) |
+	"\\x"([[:xdigit:]]{2}) |
+	"\\u"([[:xdigit:]]{4}) |
+	"\\U"([[:xdigit:]]{8}) {
+		return Escape;
+	}
+}
+<StringLiteral>{
+	"%%" |
+	"%"[EO]?[ABCDFGHIMRSTUVWXYZabcdeghjmnprtuwxyz] |
+	"%"[ #+-0]*{width}?("."{width})?([Lhjltz]|hh|ll)?[AEFGXacdefginopsux] {
+		return Format;
+	}
+}
+
+<CharLiteral>{
+	[^\\'']*"'" {
+		BEGIN(pop);
+		return String;
+	}
+	[^\\'']+|. { return String; }
+}
+<StringLiteral>{
+	[^%\\""]*"\"" {
+		BEGIN(pop);
+		return String;
+	}
+	[^%\\""]+|. { return String; }
+}
+
+<MacroLine,MacroInclude>. {
+	return Macro;
+}
+
+.|\n { return Normal; }
+
+%%
+
+const struct Lexer LexC = { yylex, &yyin, &yytext };
diff --git a/bin/dehtml.l b/bin/dehtml.l
new file mode 100644
index 00000000..799f0926
--- /dev/null
+++ b/bin/dehtml.l
@@ -0,0 +1,150 @@
+/* Copyright (C) 2021  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+%option noinput nounput noyywrap
+
+%{
+enum Token {
+	Doctype = 1,
+	Comment,
+	TagOpen,
+	TagClose,
+	Entity,
+	Text,
+	Space,
+};
+%}
+
+%%
+
+"<!DOCTYPE "[^>]*">" { return Doctype; }
+"<!--"([^-]|-[^-]|--[^>])*"-->" { return Comment; }
+"</"[^>]*">" { return TagClose; }
+"<"[^>]*">" { return TagOpen; }
+"&"[^;]*";" { return Entity; }
+[^<&[:space:]]+ { return Text; }
+[[:space:]]+ { return Space; }
+
+%%
+
+#include <err.h>
+#include <locale.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sysexits.h>
+#include <unistd.h>
+#include <wchar.h>
+
+static const struct {
+	wchar_t ch;
+	const char *name;
+} Entities[] = {
+	{ L'&', "&amp;" },
+	{ L'<', "&lt;" },
+	{ L'>', "&gt;" },
+	{ L'"', "&quot;" },
+	{ L' ', "&nbsp;" },
+	{ L'\u00A9', "&copy;" },
+	{ L'\u00B7', "&middot;" },
+	{ L'\u00BB', "&raquo;" },
+	{ L'\u200F', "&rlm;" },
+	{ L'\u2014', "&mdash;" },
+	{ L'\u2191', "&uarr;" },
+};
+
+static void entity(void) {
+	wchar_t ch = 0;
+	if (yytext[1] == '#') {
+		if (yytext[2] == 'x') {
+			ch = strtoul(&yytext[3], NULL, 16);
+		} else {
+			ch = strtoul(&yytext[2], NULL, 10);
+		}
+	} else {
+		for (size_t i = 0; i < sizeof(Entities) / sizeof(Entities[0]); ++i) {
+			if (strcmp(Entities[i].name, yytext)) continue;
+			ch = Entities[i].ch;
+			break;
+		}
+	}
+	if (ch) {
+		printf("%lc", (wint_t)ch);
+	} else {
+		warnx("unknown entity %s", yytext);
+		printf("%s", yytext);
+	}
+}
+
+static bool isTag(const char *tag) {
+	const char *ptr = &yytext[1];
+	if (*ptr == '/') ptr++;
+	size_t len = strlen(tag);
+	if (strncasecmp(ptr, tag, len)) return false;
+	ptr += len;
+	return *ptr == ' ' || *ptr == '>';
+}
+
+int main(int argc, char *argv[]) {
+	setlocale(LC_CTYPE, "");
+
+	bool collapse = 0;
+	for (int opt; 0 < (opt = getopt(argc, argv, "s"));) {
+		switch (opt) {
+			break; case 's': collapse = true;
+			break; default:  return EX_USAGE;
+		}
+	}
+	argc -= optind;
+	argv += optind;
+
+	if (!argc) argc++;
+	for (int i = 0; i < argc; ++i) {
+		yyin = (argv[i] ? fopen(argv[i], "r") : stdin);
+		if (!yyin) err(EX_NOINPUT, "%s", argv[i]);
+
+		bool space = true;
+		bool discard = false;
+		bool pre = false;
+		for (enum Token tok; (tok = yylex());) {
+			if (tok == TagOpen || tok == TagClose) {
+				if (isTag("title") || isTag("style") || isTag("script")) {
+					discard = (tok == TagOpen);
+				} else if (isTag("pre")) {
+					pre = (tok == TagOpen);
+				}
+			} else if (discard) {
+				continue;
+			} else if (tok == Entity) {
+				entity();
+				space = false;
+			} else if (tok == Text) {
+				printf("%s", yytext);
+				space = false;
+			} else if (tok == Space) {
+				if (collapse && !pre) {
+					if (space) continue;
+					printf("%c", yytext[0]);
+				} else {
+					printf("%s", yytext);
+				}
+				space = true;
+			}
+		}
+	}
+}
diff --git a/bin/downgrade.c b/bin/downgrade.c
new file mode 100644
index 00000000..31019714
--- /dev/null
+++ b/bin/downgrade.c
@@ -0,0 +1,362 @@
+/* Copyright (C) 2021  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <assert.h>
+#include <err.h>
+#include <signal.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <tls.h>
+#include <unistd.h>
+
+#ifdef __FreeBSD__
+#include <capsicum_helpers.h>
+#endif
+
+enum { BufferCap = 8192 + 512 };
+
+static bool verbose;
+static struct tls *client;
+
+static void clientWrite(const char *ptr, size_t len) {
+	if (verbose) printf("%.*s", (int)len, ptr);
+	while (len) {
+		ssize_t ret = tls_write(client, ptr, len);
+		if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT) continue;
+		if (ret < 0) errx(EX_IOERR, "tls_write: %s", tls_error(client));
+		ptr += ret;
+		len -= ret;
+	}
+}
+
+static void format(const char *format, ...) {
+	char buf[BufferCap];
+	va_list ap;
+	va_start(ap, format);
+	int len = vsnprintf(buf, sizeof(buf), format, ap);
+	va_end(ap);
+	assert((size_t)len < sizeof(buf));
+	clientWrite(buf, len);
+}
+
+static bool invite;
+static const char *join;
+
+enum { Cap = 1024 };
+static struct Message {
+	char *id;
+	char *nick;
+	char *chan;
+	char *mesg;
+} msgs[Cap];
+static size_t m;
+
+static void push(struct Message msg) {
+	struct Message *dst = &msgs[m++ % Cap];
+	free(dst->id);
+	free(dst->nick);
+	free(dst->chan);
+	free(dst->mesg);
+	dst->id = strdup(msg.id);
+	dst->nick = strdup(msg.nick);
+	dst->chan = strdup(msg.chan);
+	if (!dst->id || !dst->nick || !dst->chan) err(EX_OSERR, "strdup");
+	dst->mesg = NULL;
+	if (msg.mesg) {
+		dst->mesg = strdup(msg.mesg);
+		if (!dst->mesg) err(EX_OSERR, "strdup");
+	}
+}
+
+static struct Message *find(const char *id) {
+	for (size_t i = 0; i < Cap; ++i) {
+		if (!msgs[i].id) return NULL;
+		if (!strcmp(msgs[i].id, id)) return &msgs[i];
+	}
+	return NULL;
+}
+
+static void handle(char *ptr) {
+	char *tags = NULL;
+	char *origin = NULL;
+	if (ptr && *ptr == '@') tags = 1 + strsep(&ptr, " ");
+	if (ptr && *ptr == ':') origin = 1 + strsep(&ptr, " ");
+	char *cmd = strsep(&ptr, " ");
+	if (!cmd) return;
+	if (!strcmp(cmd, "CAP")) {
+		strsep(&ptr, " ");
+		char *sub = strsep(&ptr, " ");
+		if (!sub) errx(EX_PROTOCOL, "CAP without subcommand");
+		if (!strcmp(sub, "NAK")) {
+			errx(EX_CONFIG, "server does not support %s", ptr);
+		} else if (!strcmp(sub, "ACK")) {
+			if (!ptr) errx(EX_PROTOCOL, "CAP ACK without caps");
+			if (*ptr == ':') ptr++;
+			if (!strcmp(ptr, "sasl")) format("AUTHENTICATE EXTERNAL\r\n");
+		}
+	} else if (!strcmp(cmd, "AUTHENTICATE")) {
+		format("AUTHENTICATE +\r\nCAP END\r\n");
+	} else if (!strcmp(cmd, "433")) {
+		strsep(&ptr, " ");
+		char *nick = strsep(&ptr, " ");
+		if (!nick) errx(EX_PROTOCOL, "ERR_NICKNAMEINUSE missing nick");
+		format("NICK %s_\r\n", nick);
+	} else if (!strcmp(cmd, "001")) {
+		if (join) format("JOIN %s\r\n", join);
+	} else if (!strcmp(cmd, "005")) {
+		char *self = strsep(&ptr, " ");
+		if (!self) errx(EX_PROTOCOL, "RPL_ISUPPORT missing nick");
+		while (ptr && *ptr != ':') {
+			char *tok = strsep(&ptr, " ");
+			char *key = strsep(&tok, "=");
+			if (!strcmp(key, "BOT") && tok) {
+				format("MODE %s +%s\r\n", self, tok);
+			}
+		}
+	} else if (!strcmp(cmd, "INVITE") && invite) {
+		strsep(&ptr, " ");
+		if (!ptr) errx(EX_PROTOCOL, "INVITE missing channel");
+		if (*ptr == ':') ptr++;
+		format("JOIN %s\r\n", ptr);
+	} else if (!strcmp(cmd, "PING")) {
+		if (!ptr) errx(EX_PROTOCOL, "PING missing parameter");
+		format("PONG %s\r\n", ptr);
+	} else if (!strcmp(cmd, "ERROR")) {
+		if (!ptr) errx(EX_PROTOCOL, "ERROR missing parameter");
+		if (*ptr == ':') ptr++;
+		errx(EX_UNAVAILABLE, "%s", ptr);
+	}
+
+	if (
+		strcmp(cmd, "PRIVMSG") &&
+		strcmp(cmd, "NOTICE") &&
+		strcmp(cmd, "TAGMSG")
+	) return;
+	if (!origin) errx(EX_PROTOCOL, "%s missing origin", cmd);
+
+	struct Message msg = {
+		.nick = strsep(&origin, "!"),
+		.chan = strsep(&ptr, " "),
+	};
+	if (!msg.chan) errx(EX_PROTOCOL, "%s missing target", cmd);
+	if (msg.chan[0] == ':') msg.chan++;
+	if (msg.chan[0] != '#') return;
+	if (strcmp(cmd, "TAGMSG")) msg.mesg = (*ptr == ':' ? &ptr[1] : ptr);
+
+	if (msg.mesg) {
+		if (!strncmp(msg.mesg, "\1ACTION ", 8)) msg.mesg += 8;
+		size_t len = strlen(msg.mesg);
+		if (msg.mesg[len-1] == '\1') msg.mesg[len-1] = '\0';
+	}
+
+	char *reply = NULL;
+	char *react = NULL;
+	char *typing = NULL;
+	if (!tags) return;
+	while (tags) {
+		char *tag = strsep(&tags, ";");
+		char *key = strsep(&tag, "=");
+		if (!strcmp(key, "msgid")) {
+			if (tag) msg.id = tag;
+		} else if (!strcmp(key, "+draft/reply")) {
+			if (tag) reply = tag;
+		} else if (!strcmp(key, "+draft/react")) {
+			if (!tag) continue;
+			for (char *ptr = tag; (ptr = strchr(ptr, '\\')); ptr += !!*ptr) {
+				switch (ptr[1]) {
+					break; case ':': ptr[1] = ';';
+					break; case 's': ptr[1] = ' ';
+					//break; case 'r': ptr[1] = '\r';
+					//break; case 'n': ptr[1] = '\n';
+				}
+				memmove(ptr, &ptr[1], strlen(&ptr[1]) + 1);
+			}
+			react = tag;
+		} else if (!strcmp(key, "+typing") || !strcmp(key, "+draft/typing")) {
+			if (tag) typing = tag;
+		}
+	}
+	if (msg.id) push(msg);
+
+	if (typing) {
+		if (!strcmp(typing, "active")) {
+			format("NOTICE %s :* %s is typing...\r\n", msg.chan, msg.nick);
+		} else if (!strcmp(typing, "paused")) {
+			format(
+				"NOTICE %s :* %s is thinking hard...\r\n", msg.chan, msg.nick
+			);
+		} else if (!strcmp(typing, "done")) {
+			format("NOTICE %s :* %s has given up :(\r\n", msg.chan, msg.nick);
+		} else {
+			format(
+				"NOTICE %s :* %s is doing some wacky %s typing!\r\n",
+				msg.chan, msg.nick, typing
+			);
+		}
+	} else if (react && reply) {
+		struct Message *to = find(reply);
+		format("NOTICE %s :* %s reacted to ", msg.chan, msg.nick);
+		if (to && strcmp(to->chan, msg.chan)) {
+			format("a message in another channel");
+		} else if (to && to->mesg) {
+			size_t len = 0;
+			for (size_t n; to->mesg[len]; len += n) {
+				n = 1 + strcspn(&to->mesg[len+1], " ");
+				if (len + n > 50) break;
+			}
+			format(
+				"%s's message (\"%.*s\"%s)",
+				to->nick, (int)len, to->mesg, (to->mesg[len] ? "..." : "")
+			);
+		} else if (to) {
+			format("%s's reaction", to->nick);
+		} else {
+			format("an unknown message");
+		}
+		format(" with \"%s\"\r\n", react);
+	} else if (react) {
+		format(
+			"NOTICE %s :* %s reacted to nothing with \"%s\"\r\n",
+			msg.chan, msg.nick, react
+		);
+	} else if (reply) {
+		struct Message *to = find(reply);
+		format("NOTICE %s :* %s was replying to ", msg.chan, msg.nick);
+		if (to && strcmp(to->chan, msg.chan)) {
+			format("a message in another channel!\r\n");
+		} else if (to && to->mesg) {
+			size_t len = 0;
+			for (size_t n; to->mesg[len]; len += n) {
+				n = 1 + strcspn(&to->mesg[len+1], " ");
+				if (len + n > 50) break;
+			}
+			format(
+				"%s's message (\"%.*s\"%s)\r\n",
+				to->nick, (int)len, to->mesg, (to->mesg[len] ? "..." : "")
+			);
+		} else if (to) {
+			format("%s's reaction\r\n", to->nick);
+		} else {
+			format("an unknown message!\r\n");
+		}
+	}
+}
+
+static void quit(int sig) {
+	(void)sig;
+	format("QUIT\r\n");
+	tls_close(client);
+	exit(EX_OK);
+}
+
+int main(int argc, char *argv[]) {
+	const char *host = NULL;
+	const char *port = "6697";
+	const char *nick = "downgrade";
+	const char *cert = NULL;
+	const char *priv = NULL;
+
+	for (int opt; 0 < (opt = getopt(argc, argv, "c:ij:k:n:p:v"));) {
+		switch (opt) {
+			break; case 'c': cert = optarg;
+			break; case 'i': invite = true;
+			break; case 'j': join = optarg;
+			break; case 'k': priv = optarg;
+			break; case 'n': nick = optarg;
+			break; case 'p': port = optarg;
+			break; case 'v': verbose = true;
+			break; default:  return EX_USAGE;
+		}
+	}
+	if (optind == argc) errx(EX_USAGE, "host required");
+	host = argv[optind];
+
+	client = tls_client();
+	if (!client) errx(EX_SOFTWARE, "tls_client");
+
+	struct tls_config *config = tls_config_new();
+	if (!config) errx(EX_SOFTWARE, "tls_config_new");
+
+	if (cert) {
+		if (!priv) priv = cert;
+		int error = tls_config_set_keypair_file(config, cert, priv);
+		if (error) errx(EX_NOINPUT, "%s: %s", cert, tls_config_error(config));
+	}
+
+	int error = tls_configure(client, config);
+	if (error) errx(EX_SOFTWARE, "tls_configure: %s", tls_error(client));
+
+	error = tls_connect(client, host, port);
+	if (error) errx(EX_UNAVAILABLE, "tls_connect: %s", tls_error(client));
+
+	do {
+		error = tls_handshake(client);
+	} while (error == TLS_WANT_POLLIN || error == TLS_WANT_POLLOUT);
+	if (error) errx(EX_PROTOCOL, "tls_handshake: %s", tls_error(client));
+	tls_config_clear_keys(config);
+
+#ifdef __OpenBSD__
+	error = pledge("stdio", NULL);
+	if (error) err(EX_OSERR, "pledge");
+#endif
+
+#ifdef __FreeBSD__
+	error = caph_enter() || caph_limit_stdio();
+	if (error) err(EX_OSERR, "caph_enter");
+#endif
+
+	signal(SIGHUP, quit);
+	signal(SIGINT, quit);
+	signal(SIGTERM, quit);
+	format(
+		"CAP REQ :echo-message message-tags\r\n"
+		"NICK %s\r\n"
+		"USER %s 0 * :https://causal.agency/bin/downgrade.html\r\n",
+		nick, nick
+	);
+	if (cert) {
+		format("CAP REQ sasl\r\n");
+	} else {
+		format("CAP END\r\n");
+	}
+
+	size_t len = 0;
+	char buf[BufferCap];
+	for (;;) {
+		ssize_t n = tls_read(client, &buf[len], sizeof(buf) - len);
+		if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT) continue;
+		if (n < 0) errx(EX_IOERR, "tls_read: %s", tls_error(client));
+		if (!n) errx(EX_UNAVAILABLE, "disconnected");
+		len += n;
+
+		char *ptr = buf;
+		for (
+			char *crlf;
+			(crlf = memmem(ptr, &buf[len] - ptr, "\r\n", 2));
+			ptr = crlf + 2
+		) {
+			*crlf = '\0';
+			if (verbose) printf("%s\n", ptr);
+			handle(ptr);
+		}
+		len -= ptr - buf;
+		memmove(buf, ptr, len);
+	}
+}
diff --git a/bin/dtch.c b/bin/dtch.c
new file mode 100644
index 00000000..026493dd
--- /dev/null
+++ b/bin/dtch.c
@@ -0,0 +1,271 @@
+/* Copyright (C) 2017-2019  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/un.h>
+#include <sys/wait.h>
+#include <sysexits.h>
+#include <termios.h>
+#include <unistd.h>
+
+#if defined __FreeBSD__
+#include <libutil.h>
+#elif defined __linux__
+#include <pty.h>
+#else
+#include <util.h>
+#endif
+
+static char _;
+static struct iovec iov = { .iov_base = &_, .iov_len = 1 };
+
+static ssize_t sendfd(int sock, int fd) {
+	size_t len = CMSG_SPACE(sizeof(int));
+	char buf[len];
+	struct msghdr msg = {
+		.msg_iov = &iov,
+		.msg_iovlen = 1,
+		.msg_control = buf,
+		.msg_controllen = len,
+	};
+
+	struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
+	cmsg->cmsg_len = CMSG_LEN(sizeof(int));
+	cmsg->cmsg_level = SOL_SOCKET;
+	cmsg->cmsg_type = SCM_RIGHTS;
+	*(int *)CMSG_DATA(cmsg) = fd;
+
+	return sendmsg(sock, &msg, 0);
+}
+
+static int recvfd(int sock) {
+	size_t len = CMSG_SPACE(sizeof(int));
+	char buf[len];
+	struct msghdr msg = {
+		.msg_iov = &iov,
+		.msg_iovlen = 1,
+		.msg_control = buf,
+		.msg_controllen = len,
+	};
+	if (0 > recvmsg(sock, &msg, 0)) return -1;
+
+	struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
+	if (!cmsg || cmsg->cmsg_type != SCM_RIGHTS) {
+		errno = ENOMSG;
+		return -1;
+	}
+	return *(int *)CMSG_DATA(cmsg);
+}
+
+static struct sockaddr_un addr = { .sun_family = AF_UNIX };
+
+static void handler(int sig) {
+	unlink(addr.sun_path);
+	_exit(-sig);
+}
+
+static void detach(int server, bool sink, char *argv[]) {
+	int pty;
+	pid_t pid = forkpty(&pty, NULL, NULL, NULL);
+	if (pid < 0) err(EX_OSERR, "forkpty");
+
+	if (!pid) {
+		execvp(argv[0], argv);
+		err(EX_NOINPUT, "%s", argv[0]);
+	}
+
+	signal(SIGINT, handler);
+	signal(SIGTERM, handler);
+
+	int error = listen(server, 0);
+	if (error) err(EX_OSERR, "listen");
+
+	struct pollfd fds[] = {
+		{ .events = POLLIN, .fd = server },
+		{ .events = POLLIN, .fd = pty },
+	};
+	while (0 < poll(fds, (sink ? 2 : 1), -1)) {
+		if (fds[0].revents) {
+			int client = accept(server, NULL, NULL);
+			if (client < 0) err(EX_IOERR, "accept");
+
+			ssize_t len = sendfd(client, pty);
+			if (len < 0) warn("sendfd");
+
+			len = recv(client, &_, sizeof(_), 0);
+			if (len < 0) warn("recv");
+
+			close(client);
+		}
+
+		if (fds[1].revents) {
+			char buf[4096];
+			ssize_t len = read(pty, buf, sizeof(buf));
+			if (len < 0) err(EX_IOERR, "read");
+		}
+
+		int status;
+		pid_t dead = waitpid(pid, &status, WNOHANG);
+		if (dead < 0) err(EX_OSERR, "waitpid");
+		if (dead) {
+			unlink(addr.sun_path);
+			exit(WIFEXITED(status) ? WEXITSTATUS(status) : -WTERMSIG(status));
+		}
+	}
+	err(EX_IOERR, "poll");
+}
+
+static struct termios saveTerm;
+static void restoreTerm(void) {
+	tcsetattr(STDIN_FILENO, TCSADRAIN, &saveTerm);
+	fprintf(stderr, "\33c"); // RIS
+	warnx("detached");
+}
+
+static void nop(int sig) {
+	(void)sig;
+}
+
+static void attach(int client) {
+	int error;
+
+	int pty = recvfd(client);
+	if (pty < 0) err(EX_IOERR, "recvfd");
+	warnx("attached");
+
+	struct winsize window;
+	error = ioctl(STDIN_FILENO, TIOCGWINSZ, &window);
+	if (error) err(EX_IOERR, "ioctl");
+
+	struct winsize redraw = { .ws_row = 1, .ws_col = 1 };
+	error = ioctl(pty, TIOCSWINSZ, &redraw);
+	if (error) err(EX_IOERR, "ioctl");
+
+	error = ioctl(pty, TIOCSWINSZ, &window);
+	if (error) err(EX_IOERR, "ioctl");
+
+	error = tcgetattr(STDIN_FILENO, &saveTerm);
+	if (error) err(EX_IOERR, "tcgetattr");
+	atexit(restoreTerm);
+
+	struct termios raw = saveTerm;
+	cfmakeraw(&raw);
+	error = tcsetattr(STDIN_FILENO, TCSADRAIN, &raw);
+	if (error) err(EX_IOERR, "tcsetattr");
+
+	signal(SIGWINCH, nop);
+
+	char buf[4096];
+	struct pollfd fds[] = {
+		{ .events = POLLIN, .fd = STDIN_FILENO },
+		{ .events = POLLIN, .fd = pty },
+	};
+	for (;;) {
+		int nfds = poll(fds, 2, -1);
+		if (nfds < 0) {
+			if (errno != EINTR) err(EX_IOERR, "poll");
+
+			error = ioctl(STDIN_FILENO, TIOCGWINSZ, &window);
+			if (error) err(EX_IOERR, "ioctl");
+
+			error = ioctl(pty, TIOCSWINSZ, &window);
+			if (error) err(EX_IOERR, "ioctl");
+
+			continue;
+		}
+
+		if (fds[0].revents) {
+			ssize_t len = read(STDIN_FILENO, buf, sizeof(buf));
+			if (len < 0) err(EX_IOERR, "read");
+			if (!len) break;
+
+			if (len == 1 && buf[0] == CTRL('Q')) break;
+
+			len = write(pty, buf, len);
+			if (len < 0) err(EX_IOERR, "write");
+		}
+
+		if (fds[1].revents) {
+			ssize_t len = read(pty, buf, sizeof(buf));
+			if (len < 0) err(EX_IOERR, "read");
+			if (!len) break;
+
+			len = write(STDOUT_FILENO, buf, len);
+			if (len < 0) err(EX_IOERR, "write");
+		}
+	}
+}
+
+int main(int argc, char *argv[]) {
+	int error;
+
+	bool atch = false;
+	bool sink = false;
+
+	int opt;
+	while (0 < (opt = getopt(argc, argv, "as"))) {
+		switch (opt) {
+			break; case 'a': atch = true;
+			break; case 's': sink = true;
+			break; default:  return EX_USAGE;
+		}
+	}
+	if (optind == argc) errx(EX_USAGE, "no session name");
+	const char *name = argv[optind++];
+
+	if (optind == argc) {
+		argv[--optind] = getenv("SHELL");
+		if (!argv[optind]) errx(EX_CONFIG, "SHELL unset");
+	}
+
+	const char *home = getenv("HOME");
+	if (!home) errx(EX_CONFIG, "HOME unset");
+
+	int fd = open(home, 0);
+	if (fd < 0) err(EX_CANTCREAT, "%s", home);
+
+	error = mkdirat(fd, ".dtch", 0700);
+	if (error && errno != EEXIST) err(EX_CANTCREAT, "%s/.dtch", home);
+
+	close(fd);
+
+	int sock = socket(PF_UNIX, SOCK_STREAM, 0);
+	if (sock < 0) err(EX_OSERR, "socket");
+	fcntl(sock, F_SETFD, FD_CLOEXEC);
+
+	snprintf(addr.sun_path, sizeof(addr.sun_path), "%s/.dtch/%s", home, name);
+
+	if (atch) {
+		error = connect(sock, (struct sockaddr *)&addr, SUN_LEN(&addr));
+		if (error) err(EX_NOINPUT, "%s", addr.sun_path);
+		attach(sock);
+	} else {
+		error = bind(sock, (struct sockaddr *)&addr, SUN_LEN(&addr));
+		if (error) err(EX_CANTCREAT, "%s", addr.sun_path);
+		detach(sock, sink, &argv[optind]);
+	}
+}
diff --git a/bin/enc.sh b/bin/enc.sh
new file mode 100644
index 00000000..4233f0a3
--- /dev/null
+++ b/bin/enc.sh
@@ -0,0 +1,70 @@
+#!/bin/sh
+set -eu
+
+readonly Command='openssl enc -ChaCha20 -pbkdf2'
+
+base64=
+stdout=false
+mode=encrypt
+force=false
+
+while getopts 'acdef' opt; do
+	case $opt in
+		(a) base64=-a;;
+		(c) stdout=true;;
+		(d) mode=decrypt;;
+		(e) mode=encrypt;;
+		(f) force=true;;
+		(?) exit 1;;
+	esac
+done
+shift $((OPTIND - 1))
+
+confirm() {
+	$force && return 0
+	while :; do
+		printf '%s: overwrite %s? [y/N] ' "$0" "$1" >&2
+		read -r confirm
+		case "$confirm" in
+			(Y*|y*) return 0;;
+			(N*|n*|'') return 1;;
+		esac
+	done
+}
+
+encrypt() {
+	if test -z "${1:-}"; then
+		$Command -e $base64
+	elif $stdout; then
+		$Command -e $base64 -in "$1"
+	else
+		input=$1
+		output="${1}.enc"
+		if test -e "$output" && ! confirm "$output"; then
+			return
+		fi
+		$Command -e $base64 -in "$input" -out "$output"
+	fi
+}
+
+decrypt() {
+	if test -z "${1:-}"; then
+		$Command -d $base64
+	elif $stdout || [ "${1%.enc}" = "$1" ]; then
+		$Command -d $base64 -in "$1"
+	else
+		input=$1
+		output=${1%.enc}
+		if test -e "$output" && ! confirm "$output"; then
+			return
+		fi
+		$Command -d $base64 -in "$input" -out "$output"
+	fi
+}
+
+for input; do
+	$mode "$input"
+done
+if [ $# -eq 0 ]; then
+	$mode
+fi
diff --git a/bin/ever.c b/bin/ever.c
new file mode 100644
index 00000000..f8ff943b
--- /dev/null
+++ b/bin/ever.c
@@ -0,0 +1,119 @@
+/* Copyright (C) 2017  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <sys/types.h>
+
+#include <err.h>
+#include <fcntl.h>
+#include <stdbool.h>
+#include <stdlib.h>
+#include <sys/event.h>
+#include <sys/wait.h>
+#include <sysexits.h>
+#include <unistd.h>
+
+static int watch(int kq, char *path) {
+	int fd = open(path, O_CLOEXEC);
+	if (fd < 0) err(EX_NOINPUT, "%s", path);
+
+	struct kevent event;
+	EV_SET(
+		&event,
+		fd,
+		EVFILT_VNODE,
+		EV_ADD | EV_CLEAR,
+		NOTE_WRITE | NOTE_DELETE,
+		0,
+		path
+	);
+	int nevents = kevent(kq, &event, 1, NULL, 0, NULL);
+	if (nevents < 0) err(EX_OSERR, "kevent");
+
+	return fd;
+}
+
+static bool quiet;
+static void exec(int fd, char *const argv[]) {
+	pid_t pid = fork();
+	if (pid < 0) err(EX_OSERR, "fork");
+
+	if (!pid) {
+		dup2(fd, STDIN_FILENO);
+		execvp(*argv, argv);
+		err(EX_NOINPUT, "%s", *argv);
+	}
+
+	int status;
+	pid = wait(&status);
+	if (pid < 0) err(EX_OSERR, "wait");
+
+	if (quiet) return;
+	if (WIFEXITED(status)) {
+		warnx("exit %d\n", WEXITSTATUS(status));
+	} else if (WIFSIGNALED(status)) {
+		warnx("signal %d\n", WTERMSIG(status));
+	} else {
+		warnx("status %d\n", status);
+	}
+}
+
+int main(int argc, char *argv[]) {
+	bool input = false;
+
+	for (int opt; 0 < (opt = getopt(argc, argv, "iq"));) {
+		switch (opt) {
+			break; case 'i': input = true;
+			break; case 'q': quiet = true;
+			break; default:  return EX_USAGE;
+		}
+	}
+	argc -= optind;
+	argv += optind;
+	if (argc < 2) return EX_USAGE;
+
+	int kq = kqueue();
+	if (kq < 0) err(EX_OSERR, "kqueue");
+
+	int i;
+	for (i = 0; i < argc - 1; ++i) {
+		if (argv[i][0] == '-') {
+			i++;
+			break;
+		}
+		watch(kq, argv[i]);
+	}
+
+	if (!input) {
+		exec(STDIN_FILENO, &argv[i]);
+	}
+
+	for (;;) {
+		struct kevent event;
+		int nevents = kevent(kq, NULL, 0, &event, 1, NULL);
+		if (nevents < 0) err(EX_OSERR, "kevent");
+
+		if (event.fflags & NOTE_DELETE) {
+			close(event.ident);
+			sleep(1);
+			event.ident = watch(kq, (char *)event.udata);
+		} else if (input) {
+			off_t off = lseek(event.ident, 0, SEEK_SET);
+			if (off < 0) err(EX_IOERR, "lseek");
+		}
+
+		exec((input ? event.ident : STDIN_FILENO), &argv[i]);
+	}
+}
diff --git a/bin/freecell.c b/bin/freecell.c
new file mode 100644
index 00000000..fbc0fe22
--- /dev/null
+++ b/bin/freecell.c
@@ -0,0 +1,388 @@
+/* Copyright (C) 2019, 2021  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <assert.h>
+#include <ctype.h>
+#include <curses.h>
+#include <err.h>
+#include <locale.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <sysexits.h>
+#include <time.h>
+#include <unistd.h>
+
+typedef unsigned uint;
+typedef unsigned char byte;
+
+typedef byte Card;
+enum {
+	A = 1,
+	J = 11,
+	Q = 12,
+	K = 13,
+	Rank = 0x0F,
+	Suit = 0x30,
+	Color = 0x10,
+	Club = 0x00,
+	Diamond = 0x10,
+	Spade = 0x20,
+	Heart = 0x30,
+};
+
+enum { StackCap = 52 };
+struct Stack {
+	byte len;
+	Card cards[StackCap];
+};
+static void push(struct Stack *stack, Card card) {
+	assert(stack->len < StackCap);
+	stack->cards[stack->len++] = card;
+}
+static Card pop(struct Stack *stack) {
+	if (!stack->len) return 0;
+	return stack->cards[--stack->len];
+}
+static Card peek(struct Stack *stack) {
+	if (!stack->len) return 0;
+	return stack->cards[stack->len-1];
+}
+
+enum {
+	Foundation,
+	Cell = Foundation + 4,
+	Tableau = Cell + 4,
+	Stacks = Tableau + 8,
+};
+static struct Stack stacks[Stacks];
+
+struct Move {
+	byte dst;
+	byte src;
+};
+
+enum { QCap = 16 };
+static struct {
+	struct Move moves[QCap];
+	uint r, w, u;
+} q;
+static void enq(byte dst, byte src) {
+	q.moves[q.w % QCap].dst = dst;
+	q.moves[q.w % QCap].src = src;
+	q.w++;
+}
+static void deq(void) {
+	struct Move move = q.moves[q.r++ % QCap];
+	push(&stacks[move.dst], pop(&stacks[move.src]));
+}
+static void undo(void) {
+	uint len = q.w - q.u;
+	if (!len || len > QCap) return;
+	for (uint i = len-1; i < len; --i) {
+		struct Move move = q.moves[(q.u+i) % QCap];
+		push(&stacks[move.src], pop(&stacks[move.dst]));
+	}
+	q.r = q.w = q.u;
+}
+
+// https://rosettacode.org/wiki/Deal_cards_for_FreeCell
+static uint lcgState;
+static uint lcg(void) {
+	lcgState = (214013 * lcgState + 2531011) % (1 << 31);
+	return lcgState >> 16;
+}
+static void deal(uint game) {
+	lcgState = game;
+	struct Stack deck = {0};
+	for (Card i = A; i <= K; ++i) {
+		push(&deck, Club | i);
+		push(&deck, Diamond | i);
+		push(&deck, Heart | i);
+		push(&deck, Spade | i);
+	}
+	for (uint stack = 0; deck.len; ++stack) {
+		uint i = lcg() % deck.len;
+		Card card = deck.cards[i];
+		deck.cards[i] = deck.cards[--deck.len];
+		push(&stacks[Tableau + stack%8], card);
+	}
+}
+
+static bool win(void) {
+	for (uint i = Foundation; i < Cell; ++i) {
+		if (stacks[i].len != 13) return false;
+	}
+	return true;
+}
+
+static bool valid(uint dst, Card card) {
+	Card top = peek(&stacks[dst]);
+	if (dst < Cell) {
+		if (!top) return (card & Rank) == A;
+		return (card & Suit) == (top & Suit)
+			&& (card & Rank) == (top & Rank) + 1;
+	}
+	if (!top) return true;
+	if (dst >= Tableau) {
+		return (card & Color) != (top & Color)
+			&& (card & Rank) == (top & Rank) - 1;
+	}
+	return false;
+}
+
+static void autoEnq(void) {
+	Card min[] = { K, K };
+	for (uint i = Cell; i < Stacks; ++i) {
+		for (uint j = 0; j < stacks[i].len; ++j) {
+			Card card = stacks[i].cards[j];
+			if ((card & Rank) < min[!!(card & Color)]) {
+				min[!!(card & Color)] = card & Rank;
+			}
+		}
+	}
+	for (uint src = Cell; src < Stacks; ++src) {
+		Card card = peek(&stacks[src]);
+		if (!card) continue;
+		if (min[!(card & Color)] < (card & Rank)-1) continue;
+		for (uint dst = Foundation; dst < Cell; ++dst) {
+			if (valid(dst, card)) {
+				enq(dst, src);
+				return;
+			}
+		}
+	}
+}
+
+static void moveSingle(uint dst, uint src) {
+	if (!valid(dst, peek(&stacks[src]))) return;
+	q.u = q.w;
+	enq(dst, src);
+}
+
+static uint freeCells(uint cells[static 4]) {
+	uint len = 0;
+	for (uint i = Cell; i < Tableau; ++i) {
+		if (!stacks[i].len) cells[len++] = i;
+	}
+	return len;
+}
+
+static uint moveDepth(uint src) {
+	struct Stack stack = stacks[src];
+	if (stack.len < 2) return stack.len;
+	uint n = 1;
+	for (uint i = stack.len-2; i < stack.len; --i, ++n) {
+		if ((stack.cards[i] & Color) == (stack.cards[i+1] & Color)) break;
+		if ((stack.cards[i] & Rank) != (stack.cards[i+1] & Rank) + 1) break;
+	}
+	return n;
+}
+
+static void moveColumn(uint dst, uint src) {
+	uint depth;
+	uint cells[4];
+	uint free = freeCells(cells);
+	for (depth = moveDepth(src); depth; --depth) {
+		if (free < depth-1) continue;
+		if (valid(dst, stacks[src].cards[stacks[src].len-depth])) break;
+	}
+	if (depth < 2 || dst < Tableau) {
+		moveSingle(dst, src);
+		return;
+	}
+	q.u = q.w;
+	for (uint i = 0; i < depth-1; ++i) {
+		enq(cells[i], src);
+	}
+	enq(dst, src);
+	for (uint i = depth-2; i < depth-1; --i) {
+		enq(dst, cells[i]);
+	}
+}
+
+static void curse(void) {
+	setlocale(LC_CTYPE, "");
+	initscr();
+	cbreak();
+	noecho();
+	curs_set(0);
+	start_color();
+	use_default_colors();
+	init_pair(1, COLOR_BLACK, COLOR_WHITE);
+	init_pair(2, COLOR_RED, COLOR_WHITE);
+	init_pair(3, COLOR_GREEN, -1);
+}
+
+static void drawCard(bool hi, int y, int x, Card card) {
+	if (!card) return;
+	move(y, x);
+	attr_set(hi ? A_REVERSE : A_NORMAL, (card & Color) ? 2 : 1, NULL);
+	switch (card & Suit) {
+		break; case Club: addstr("\u2663");
+		break; case Diamond: addstr("\u2666");
+		break; case Spade: addstr("\u2660");
+		break; case Heart: addstr("\u2665");
+		break; default:;
+	}
+	switch (card & Rank) {
+		break; case A: addstr(" A");
+		break; case 10: addstr("10");
+		break; case J: addstr(" J");
+		break; case Q: addstr(" Q");
+		break; case K: addstr(" K");
+		break; default: {
+			addch(' ');
+			addch('0' + (card & Rank));
+		}
+	}
+	attr_set(A_NORMAL, 0, NULL);
+}
+
+static void drawStack(bool hi, int y, int x, const struct Stack *stack) {
+	for (uint i = 0; i < stack->len; ++i) {
+		drawCard(hi && i == stack->len-1, y++, x, stack->cards[i]);
+	}
+}
+
+enum {
+	Padding = 1,
+	CardWidth = 3,
+	CardHeight = 1,
+	CellX = Padding,
+	CellY = 2*CardHeight,
+	FoundationX = CellX + 4*(CardWidth+Padding),
+	FoundationY = CellY,
+	TableauX = CellX,
+	TableauY = CellY + 2*CardHeight,
+};
+
+static uint game;
+static uint srcStack = Stacks;
+
+static void draw(void) {
+	erase();
+	static char buf[256];
+	if (!buf[0]) snprintf(buf, sizeof(buf), "Game #%u", game);
+	attr_set(A_NORMAL, 3, NULL);
+	mvaddstr(0, Padding, buf);
+	for (uint i = 0; i < Stacks; ++i) {
+		int y, x;
+		char key;
+		if (i < Cell) {
+			y = FoundationY;
+			x = FoundationX + (3-(i-Foundation)) * (CardWidth+Padding);
+			key = '_';
+		} else if (i < Tableau) {
+			y = CellY;
+			x = CellX + (i-Cell) * (CardWidth+Padding);
+			key = '1' + i-Cell;
+		} else {
+			y = TableauY;
+			x = TableauX + (i-Tableau) * (CardWidth+Padding);
+			key = "QWERASDF"[i-Tableau];
+		}
+		if (i < Tableau) {
+			mvaddch(y, x+1, COLOR_PAIR(3) | key);
+		} else {
+			mvaddch(y + 8*CardHeight, x+1, COLOR_PAIR(3) | key);
+		}
+		if (i < Cell) {
+			drawCard(false, y, x, peek(&stacks[i]));
+		} else {
+			drawStack(i == srcStack, y, x, &stacks[i]);
+		}
+	}
+}
+
+static void input(void) {
+	char ch = getch();
+	uint stack = Stacks;
+	switch (tolower(ch)) {
+		break; case '\33': srcStack = Stacks;
+		break; case 'u': case '\b': case '\177': undo();
+		break; case '1': case '!': stack = Cell+0;
+		break; case '2': case '@': stack = Cell+1;
+		break; case '3': case '#': stack = Cell+2;
+		break; case '4': case '$': stack = Cell+3;
+		break; case '_': case ' ': stack = Foundation;
+		break; case 'q': stack = Tableau+0;
+		break; case 'w': stack = Tableau+1;
+		break; case 'e': stack = Tableau+2;
+		break; case 'r': stack = Tableau+3;
+		break; case 'a': stack = Tableau+4;
+		break; case 's': stack = Tableau+5;
+		break; case 'd': stack = Tableau+6;
+		break; case 'f': stack = Tableau+7;
+	}
+	if (stack == Stacks) return;
+
+	if (srcStack < Stacks) {
+		Card card = peek(&stacks[srcStack]);
+		if (stack == Foundation) {
+			for (; stack < Cell; ++stack) {
+				if (valid(stack, card)) break;
+			}
+			if (stack == Cell) return;
+		}
+		if (stack == srcStack) {
+			for (stack = Cell; stack < Stacks; ++stack) {
+				if (!stacks[stack].len) break;
+			}
+			if (stack == Stacks) return;
+		}
+		if (isupper(ch)) {
+			moveSingle(stack, srcStack);
+		} else {
+			moveColumn(stack, srcStack);
+		}
+		srcStack = Stacks;
+
+	} else if (stack >= Cell && stacks[stack].len) {
+		srcStack = stack;
+	}
+}
+
+static void status(void) {
+	printf("Game #%u %s!\n", game, win() ? "win" : "lose");
+}
+
+int main(int argc, char *argv[]) {
+	game = 1 + time(NULL) % 32000;
+	uint delay = 50;
+	for (int opt; 0 < (opt = getopt(argc, argv, "d:n:"));) {
+		switch (opt) {
+			break; case 'd': delay = strtoul(optarg, NULL, 10);
+			break; case 'n': game = strtoul(optarg, NULL, 10);
+			break; default:  return EX_USAGE;
+		}
+	}
+	curse();
+	deal(game);
+	atexit(status);
+	while (!win()) {
+		while (q.r < q.w) {
+			deq();
+			draw();
+			refresh();
+			usleep(delay * 1000);
+			if (q.r == q.w) autoEnq();
+		}
+		draw();
+		input();
+	}
+	endwin();
+}
diff --git a/bin/git-comment.pl b/bin/git-comment.pl
new file mode 100644
index 00000000..5352702d
--- /dev/null
+++ b/bin/git-comment.pl
@@ -0,0 +1,92 @@
+#!/usr/bin/env perl
+# Copyright (C) 2021  June McEnroe <june@causal.agency>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+use lib (split(/:/, $ENV{GITPERLLIB} || '/usr/local/share/perl5'));
+
+use strict;
+use warnings;
+use Getopt::Long qw(:config pass_through);
+use Git;
+
+my $repo = Git->repository();
+
+my ($all, $minGroup, $minRepeat, $noRepeat) = (0, 2, 30, 0);
+my $commentStart = $repo->config('comment.start') // "/*";
+my $commentLead = $repo->config('comment.lead') // " *";
+my $commentEnd = $repo->config('comment.end') // " */";
+my $pretty = $repo->config('comment.pretty') // 'format:%h %s%n%n%-b';
+GetOptions(
+	'all' => \$all,
+	'comment-start=s' => \$commentStart,
+	'comment-lead=s' => \$commentLead,
+	'comment-end:s' => \$commentEnd,
+	'min-group=i' => \$minGroup,
+	'min-repeat=i' => \$minRepeat,
+	'no-repeat' => \$noRepeat,
+	'pretty=s' => \$pretty,
+) or die;
+
+sub printComment {
+	my ($indent, $summary, @body) = @_;
+	print "$indent$commentStart $summary";
+	if (@body) {
+		print "\n";
+		foreach (@body) {
+			print "$indent$commentLead";
+			print " $_" if $_;
+			print "\n";
+		}
+		print "$indent$commentEnd\n" if $commentEnd;
+	} else {
+		print "$commentEnd\n";
+	}
+}
+
+my ($pipe, $ctx) = $repo->command_output_pipe('blame', '--porcelain', @ARGV);
+
+my ($commit, $nr, $group, $printed, %message, %nrs);
+while (<$pipe>) {
+	chomp;
+	if (/^([[:xdigit:]]+) \d+ (\d+) (\d+)/) {
+		($commit, $nr, $group, $printed) = ($1, $2, $3, 0);
+		next if $message{$commit};
+		if ($commit =~ /^0+$/) {
+			$message{$commit} = ['Not committed yet'];
+			next;
+		}
+		my @message = $repo->command(
+			'show', '--no-patch', "--pretty=$pretty", $commit
+		);
+		$message{$commit} = \@message;
+	} elsif (/^\t(\s*)(.*)/) {
+		my ($indent, $line) = ($1, $2);
+		unless ($printed || $line =~ /^[})]?;?$/) {
+			$printed = 1;
+			if (
+				$group >= $minGroup &&
+				!($noRepeat && $nrs{$commit}) &&
+				!($nrs{$commit} && $nr < $nrs{$commit} + $minRepeat) &&
+				($all || @{$message{$commit}} > 1)
+			) {
+				$nrs{$commit} = $nr;
+				printComment($indent, @{$message{$commit}});
+			}
+		}
+		print "$indent$line\n";
+	}
+}
+
+$repo->command_close_pipe($pipe, $ctx);
diff --git a/bin/glitch.c b/bin/glitch.c
new file mode 100644
index 00000000..d0c926f9
--- /dev/null
+++ b/bin/glitch.c
@@ -0,0 +1,605 @@
+/* Copyright (C) 2018, 2021  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <err.h>
+#include <inttypes.h>
+#include <limits.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <unistd.h>
+#include <zlib.h>
+
+#define ARRAY_LEN(a) (sizeof(a) / sizeof(a[0]))
+
+static const char *path;
+static FILE *file;
+static uint32_t crc;
+
+static void pngRead(void *ptr, size_t len, const char *desc) {
+	size_t n = fread(ptr, len, 1, file);
+	if (!n && ferror(file)) err(EX_IOERR, "%s", path);
+	if (!n) errx(EX_DATAERR, "%s: missing %s", path, desc);
+	crc = crc32(crc, ptr, len);
+}
+
+static void pngWrite(const void *ptr, size_t len) {
+	size_t n = fwrite(ptr, len, 1, file);
+	if (!n) err(EX_IOERR, "%s", path);
+	crc = crc32(crc, ptr, len);
+}
+
+static const uint8_t Sig[8] = "\x89PNG\r\n\x1A\n";
+
+static void sigRead(void) {
+	uint8_t sig[sizeof(Sig)];
+	pngRead(sig, sizeof(sig), "signature");
+	if (memcmp(sig, Sig, sizeof(sig))) {
+		errx(EX_DATAERR, "%s: invalid signature", path);
+	}
+}
+
+static void sigWrite(void) {
+	pngWrite(Sig, sizeof(Sig));
+}
+
+static uint32_t u32Read(const char *desc) {
+	uint8_t b[4];
+	pngRead(b, sizeof(b), desc);
+	return (uint32_t)b[0] << 24 | (uint32_t)b[1] << 16
+		| (uint32_t)b[2] << 8 | (uint32_t)b[3];
+}
+
+static void u32Write(uint32_t x) {
+	uint8_t b[4] = { x >> 24 & 0xFF, x >> 16 & 0xFF, x >> 8 & 0xFF, x & 0xFF };
+	pngWrite(b, sizeof(b));
+}
+
+struct Chunk {
+	uint32_t len;
+	char type[5];
+};
+
+static struct Chunk chunkRead(void) {
+	struct Chunk chunk;
+	chunk.len = u32Read("chunk length");
+	crc = crc32(0, Z_NULL, 0);
+	pngRead(chunk.type, 4, "chunk type");
+	chunk.type[4] = 0;
+	return chunk;
+}
+
+static void chunkWrite(struct Chunk chunk) {
+	u32Write(chunk.len);
+	crc = crc32(0, Z_NULL, 0);
+	pngWrite(chunk.type, 4);
+}
+
+static void crcRead(void) {
+	uint32_t expect = crc;
+	uint32_t actual = u32Read("CRC32");
+	if (actual == expect) return;
+	errx(
+		EX_DATAERR, "%s: expected CRC32 %08X, found %08X",
+		path, expect, actual
+	);
+}
+
+static void crcWrite(void) {
+	u32Write(crc);
+}
+
+static void chunkSkip(struct Chunk chunk) {
+	if (!(chunk.type[0] & 0x20)) {
+		errx(EX_CONFIG, "%s: unsupported critical chunk %s", path, chunk.type);
+	}
+	uint8_t buf[4096];
+	while (chunk.len > sizeof(buf)) {
+		pngRead(buf, sizeof(buf), "chunk data");
+		chunk.len -= sizeof(buf);
+	}
+	if (chunk.len) pngRead(buf, chunk.len, "chunk data");
+	crcRead();
+}
+
+enum Color {
+	Grayscale = 0,
+	Truecolor = 2,
+	Indexed = 3,
+	GrayscaleAlpha = 4,
+	TruecolorAlpha = 6,
+};
+enum Compression {
+	Deflate,
+};
+enum FilterMethod {
+	Adaptive,
+};
+enum Interlace {
+	Progressive,
+	Adam7,
+};
+
+enum { HeaderLen = 13 };
+static struct {
+	uint32_t width;
+	uint32_t height;
+	uint8_t depth;
+	uint8_t color;
+	uint8_t compression;
+	uint8_t filter;
+	uint8_t interlace;
+} header;
+
+static size_t pixelLen;
+static size_t lineLen;
+static size_t dataLen;
+
+static void recalc(void) {
+	size_t pixelBits = header.depth;
+	switch (header.color) {
+		break; case GrayscaleAlpha: pixelBits *= 2;
+		break; case Truecolor: pixelBits *= 3;
+		break; case TruecolorAlpha: pixelBits *= 4;
+	}
+	pixelLen = (pixelBits + 7) / 8;
+	lineLen = (header.width * pixelBits + 7) / 8;
+	dataLen = (1 + lineLen) * header.height;
+}
+
+static void headerRead(struct Chunk chunk) {
+	if (chunk.len != HeaderLen) {
+		errx(
+			EX_DATAERR, "%s: expected %s length %" PRIu32 ", found %" PRIu32,
+			path, chunk.type, (uint32_t)HeaderLen, chunk.len
+		);
+	}
+	header.width = u32Read("header width");
+	header.height = u32Read("header height");
+	pngRead(&header.depth, 1, "header depth");
+	pngRead(&header.color, 1, "header color");
+	pngRead(&header.compression, 1, "header compression");
+	pngRead(&header.filter, 1, "header filter");
+	pngRead(&header.interlace, 1, "header interlace");
+	crcRead();
+	recalc();
+}
+
+static void headerWrite(void) {
+	struct Chunk ihdr = { HeaderLen, "IHDR" };
+	chunkWrite(ihdr);
+	u32Write(header.width);
+	u32Write(header.height);
+	pngWrite(&header.depth, 1);
+	pngWrite(&header.color, 1);
+	pngWrite(&header.compression, 1);
+	pngWrite(&header.filter, 1);
+	pngWrite(&header.interlace, 1);
+	crcWrite();
+}
+
+static struct {
+	uint32_t len;
+	uint8_t rgb[256][3];
+} pal;
+
+static struct {
+	uint32_t len;
+	uint8_t a[256];
+} trans;
+
+static void palClear(void) {
+	pal.len = 0;
+	trans.len = 0;
+}
+
+static void palRead(struct Chunk chunk) {
+	if (chunk.len % 3) {
+		errx(
+			EX_DATAERR, "%s: %s length %" PRIu32 " not divisible by 3",
+			path, chunk.type, chunk.len
+		);
+	}
+	pal.len = chunk.len / 3;
+	if (pal.len > 256) {
+		errx(
+			EX_DATAERR, "%s: %s length %" PRIu32 " > 256",
+			path, chunk.type, pal.len
+		);
+	}
+	pngRead(pal.rgb, chunk.len, "palette data");
+	crcRead();
+}
+
+static void palWrite(void) {
+	struct Chunk plte = { 3 * pal.len, "PLTE" };
+	chunkWrite(plte);
+	pngWrite(pal.rgb, plte.len);
+	crcWrite();
+}
+
+static void transRead(struct Chunk chunk) {
+	trans.len = chunk.len;
+	if (trans.len > 256) {
+		errx(
+			EX_DATAERR, "%s: %s length %" PRIu32 " > 256",
+			path, chunk.type, trans.len
+		);
+	}
+	pngRead(trans.a, chunk.len, "transparency data");
+	crcRead();
+}
+
+static void transWrite(void) {
+	struct Chunk trns = { trans.len, "tRNS" };
+	chunkWrite(trns);
+	pngWrite(trans.a, trns.len);
+	crcWrite();
+}
+
+static uint8_t *data;
+
+static void dataAlloc(void) {
+	data = malloc(dataLen);
+	if (!data) err(EX_OSERR, "malloc");
+}
+
+static void dataRead(struct Chunk chunk) {
+	z_stream stream = { .next_out = data, .avail_out = dataLen };
+	int error = inflateInit(&stream);
+	if (error != Z_OK) errx(EX_SOFTWARE, "inflateInit: %s", stream.msg);
+
+	for (;;) {
+		if (strcmp(chunk.type, "IDAT")) {
+			errx(EX_DATAERR, "%s: missing IDAT chunk", path);
+		}
+
+		uint8_t *idat = malloc(chunk.len);
+		if (!idat) err(EX_OSERR, "malloc");
+
+		pngRead(idat, chunk.len, "image data");
+		crcRead();
+		
+		stream.next_in = idat;
+		stream.avail_in = chunk.len;
+		error = inflate(&stream, Z_SYNC_FLUSH);
+		free(idat);
+
+		if (error == Z_STREAM_END) break;
+		if (error != Z_OK) {
+			errx(EX_DATAERR, "%s: inflate: %s", path, stream.msg);
+		}
+
+		chunk = chunkRead();
+	}
+	inflateEnd(&stream);
+	if ((size_t)stream.total_out != dataLen) {
+		errx(
+			EX_DATAERR, "%s: expected data length %zu, found %zu",
+			path, dataLen, (size_t)stream.total_out
+		);
+	}
+}
+
+static void dataWrite(void) {
+	z_stream stream = {
+		.next_in = data,
+		.avail_in = dataLen,
+	};
+	int error = deflateInit2(
+		&stream, Z_BEST_COMPRESSION, Z_DEFLATED, 15, 8, Z_FILTERED
+	);
+	if (error != Z_OK) errx(EX_SOFTWARE, "deflateInit2: %s", stream.msg);
+
+	uLong bound = deflateBound(&stream, dataLen);
+	uint8_t *buf = malloc(bound);
+	if (!buf) err(EX_OSERR, "malloc");
+
+	stream.next_out = buf;
+	stream.avail_out = bound;
+	deflate(&stream, Z_FINISH);
+	deflateEnd(&stream);
+
+	struct Chunk idat = { stream.total_out, "IDAT" };
+	chunkWrite(idat);
+	pngWrite(buf, stream.total_out);
+	crcWrite();
+	free(buf);
+
+	struct Chunk iend = { 0, "IEND" };
+	chunkWrite(iend);
+	crcWrite();
+}
+
+enum Filter {
+	None,
+	Sub,
+	Up,
+	Average,
+	Paeth,
+	FilterCap,
+};
+
+struct Bytes {
+	uint8_t x, a, b, c;
+};
+
+static bool brokenPaeth;
+static uint8_t paethPredictor(struct Bytes f) {
+	int32_t p = (int32_t)f.a + (int32_t)f.b - (int32_t)f.c;
+	int32_t pa = labs(p - (int32_t)f.a);
+	int32_t pb = labs(p - (int32_t)f.b);
+	int32_t pc = labs(p - (int32_t)f.c);
+	if (pa <= pb && pa <= pc) return f.a;
+	if (brokenPaeth) {
+		if (pb < pc) return f.b;
+	} else {
+		if (pb <= pc) return f.b;
+	}
+	return f.c;
+}
+
+static uint8_t recon(enum Filter type, struct Bytes f) {
+	switch (type) {
+		case None:    return f.x;
+		case Sub:     return f.x + f.a;
+		case Up:      return f.x + f.b;
+		case Average: return f.x + ((uint32_t)f.a + (uint32_t)f.b) / 2;
+		case Paeth:   return f.x + paethPredictor(f);
+		default: abort();
+	}
+}
+
+static uint8_t filt(enum Filter type, struct Bytes f) {
+	switch (type) {
+		case None:    return f.x;
+		case Sub:     return f.x - f.a;
+		case Up:      return f.x - f.b;
+		case Average: return f.x - ((uint32_t)f.a + (uint32_t)f.b) / 2;
+		case Paeth:   return f.x - paethPredictor(f);
+		default: abort();
+	}
+}
+
+static uint8_t *lineType(uint32_t y) {
+	return &data[y * (1 + lineLen)];
+}
+static uint8_t *lineData(uint32_t y) {
+	return 1 + lineType(y);
+}
+
+static struct Bytes origBytes(uint32_t y, size_t i) {
+	bool a = (i >= pixelLen), b = (y > 0), c = (a && b);
+	return (struct Bytes) {
+		.x = lineData(y)[i],
+		.a = (a ? lineData(y)[i-pixelLen] : 0),
+		.b = (b ? lineData(y-1)[i] : 0),
+		.c = (c ? lineData(y-1)[i-pixelLen] : 0),
+	};
+}
+
+static bool reconFilter;
+static void dataRecon(void) {
+	for (uint32_t y = 0; y < header.height; ++y) {
+		for (size_t i = 0; i < lineLen; ++i) {
+			if (reconFilter) {
+				lineData(y)[i] = filt(*lineType(y), origBytes(y, i));
+			} else {
+				lineData(y)[i] = recon(*lineType(y), origBytes(y, i));
+			}
+		}
+		*lineType(y) = None;
+	}
+}
+
+static bool filterRecon;
+static size_t applyFilter;
+static enum Filter applyFilters[256];
+static size_t declFilter;
+static enum Filter declFilters[256];
+
+static void dataFilter(void) {
+	uint8_t *filter[FilterCap];
+	for (enum Filter i = None; i < FilterCap; ++i) {
+		filter[i] = malloc(lineLen);
+		if (!filter[i]) err(EX_OSERR, "malloc");
+	}
+	for (uint32_t y = header.height-1; y < header.height; --y) {
+		uint32_t heuristic[FilterCap] = {0};
+		enum Filter minType = None;
+		for (enum Filter type = None; type < FilterCap; ++type) {
+			for (size_t i = 0; i < lineLen; ++i) {
+				if (filterRecon) {
+					filter[type][i] = recon(type, origBytes(y, i));
+				} else {
+					filter[type][i] = filt(type, origBytes(y, i));
+				}
+				heuristic[type] += abs((int8_t)filter[type][i]);
+			}
+			if (heuristic[type] < heuristic[minType]) minType = type;
+		}
+		if (declFilter) {
+			*lineType(y) = declFilters[y % declFilter];
+		} else {
+			*lineType(y) = minType;
+		}
+		if (applyFilter) {
+			memcpy(lineData(y), filter[applyFilters[y % applyFilter]], lineLen);
+		} else {
+			memcpy(lineData(y), filter[minType], lineLen);
+		}
+	}
+	for (enum Filter i = None; i < FilterCap; ++i) {
+		free(filter[i]);
+	}
+}
+
+static bool invertData;
+static bool mirrorData;
+static bool zeroX;
+static bool zeroY;
+
+static void glitch(const char *inPath, const char *outPath) {
+	if (inPath) {
+		path = inPath;
+		file = fopen(path, "r");
+		if (!file) err(EX_NOINPUT, "%s", path);
+	} else {
+		path = "stdin";
+		file = stdin;
+	}
+
+	sigRead();
+	struct Chunk ihdr = chunkRead();
+	if (strcmp(ihdr.type, "IHDR")) {
+		errx(EX_DATAERR, "%s: expected IHDR, found %s", path, ihdr.type);
+	}
+	headerRead(ihdr);
+	if (header.interlace != Progressive) {
+		errx(EX_CONFIG, "%s: unsupported interlacing", path);
+	}
+
+	palClear();
+	dataAlloc();
+	for (;;) {
+		struct Chunk chunk = chunkRead();
+		if (!strcmp(chunk.type, "PLTE")) {
+			palRead(chunk);
+		} else if (!strcmp(chunk.type, "tRNS")) {
+			transRead(chunk);
+		} else if (!strcmp(chunk.type, "IDAT")) {
+			dataRead(chunk);
+		} else if (!strcmp(chunk.type, "IEND")) {
+			break;
+		} else {
+			chunkSkip(chunk);
+		}
+	}
+	fclose(file);
+
+	dataRecon();
+	dataFilter();
+
+	if (invertData) {
+		for (uint32_t y = 0; y < header.height; ++y) {
+			for (size_t i = 0; i < lineLen; ++i) {
+				lineData(y)[i] ^= 0xFF;
+			}
+		}
+	}
+	if (mirrorData) {
+		for (uint32_t y = 0; y < header.height; ++y) {
+			for (size_t i = 0, j = lineLen-1; i < j; ++i, --j) {
+				uint8_t x = lineData(y)[i];
+				lineData(y)[i] = lineData(y)[j];
+				lineData(y)[j] = x;
+			}
+		}
+	}
+	if (zeroX) {
+		for (uint32_t y = 0; y < header.height; ++y) {
+			memset(lineData(y), 0, pixelLen);
+		}
+	}
+	if (zeroY) {
+		memset(lineData(0), 0, lineLen);
+	}
+
+	char buf[PATH_MAX];
+	if (outPath) {
+		path = outPath;
+		if (outPath == inPath) {
+			snprintf(buf, sizeof(buf), "%sg", outPath);
+			file = fopen(buf, "wx");
+			if (!file) err(EX_CANTCREAT, "%s", buf);
+		} else {
+			file = fopen(path, "w");
+			if (!file) err(EX_CANTCREAT, "%s", outPath);
+		}
+	} else {
+		path = "stdout";
+		file = stdout;
+	}
+
+	sigWrite();
+	headerWrite();
+	if (header.color == Indexed) {
+		palWrite();
+		if (trans.len) transWrite();
+	}
+	dataWrite();
+	free(data);
+	int error = fclose(file);
+	if (error) err(EX_IOERR, "%s", path);
+
+	if (outPath && outPath == inPath) {
+		error = rename(buf, outPath);
+		if (error) err(EX_CANTCREAT, "%s", outPath);
+	}
+}
+
+static enum Filter parseFilter(const char *str) {
+	switch (str[0]) {
+		case 'N': case 'n': return None;
+		case 'S': case 's': return Sub;
+		case 'U': case 'u': return Up;
+		case 'A': case 'a': return Average;
+		case 'P': case 'p': return Paeth;
+		default: errx(EX_USAGE, "invalid filter type %s", str);
+	}
+}
+
+static size_t parseFilters(enum Filter *filters, char *str) {
+	size_t len = 0;
+	while (str) {
+		char *filt = strsep(&str, ",");
+		filters[len++] = parseFilter(filt);
+	}
+	return len;
+}
+
+int main(int argc, char *argv[]) {
+	bool stdio = false;
+	char *outPath = NULL;
+
+	for (int opt; 0 < (opt = getopt(argc, argv, "a:cd:fimo:prxy"));) {
+		switch (opt) {
+			break; case 'a': applyFilter = parseFilters(applyFilters, optarg);
+			break; case 'c': stdio = true;
+			break; case 'd': declFilter = parseFilters(declFilters, optarg);
+			break; case 'f': reconFilter = true;
+			break; case 'i': invertData = true;
+			break; case 'm': mirrorData = true;
+			break; case 'o': outPath = optarg;
+			break; case 'p': brokenPaeth = true;
+			break; case 'r': filterRecon = true;
+			break; case 'x': zeroX = true;
+			break; case 'y': zeroY = true;
+			break; default:  return EX_USAGE;
+		}
+	}
+
+	if (optind < argc) {
+		for (int i = optind; i < argc; ++i) {
+			glitch(argv[i], (stdio ? NULL : outPath ? outPath : argv[i]));
+		}
+	} else {
+		glitch(NULL, outPath);
+	}
+}
diff --git a/bin/hilex.c b/bin/hilex.c
new file mode 100644
index 00000000..7d7b3f2d
--- /dev/null
+++ b/bin/hilex.c
@@ -0,0 +1,406 @@
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <assert.h>
+#include <ctype.h>
+#include <err.h>
+#include <regex.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/wait.h>
+#include <sysexits.h>
+#include <unistd.h>
+
+#include "hilex.h"
+
+#define ARRAY_LEN(a) (sizeof(a) / sizeof(a[0]))
+
+static const char *Class[] = {
+#define X(class) [class] = #class,
+	ENUM_CLASS
+#undef X
+};
+
+static FILE *yyin;
+static char *yytext;
+static int yylex(void) {
+	static size_t cap = 0;
+	return (getline(&yytext, &cap, yyin) < 0 ? None : Normal);
+}
+static const struct Lexer LexText = { yylex, &yyin, &yytext };
+
+static const struct {
+	const struct Lexer *lexer;
+	const char *name;
+	const char *namePatt;
+	const char *linePatt;
+} Lexers[] = {
+	{ &LexC, "c", "[.][chlmy]$", NULL },
+	{ &LexMake, "make", "[.](mk|am)$|^Makefile$", NULL },
+	{ &LexMdoc, "mdoc", "[.][1-9]$", "^[.]Dd" },
+	{ &LexSh, "sh", "[.]sh$|^[.](profile|shrc)$", "^#![ ]?/bin/k?sh" },
+	{ &LexText, "text", "[.]txt$", NULL },
+};
+
+static const struct Lexer *parseLexer(const char *name) {
+	for (size_t i = 0; i < ARRAY_LEN(Lexers); ++i) {
+		if (!strcmp(name, Lexers[i].name)) return Lexers[i].lexer;
+	}
+	errx(EX_USAGE, "unknown lexer %s", name);
+}
+
+static void ungets(const char *str, FILE *file) {
+	size_t len = strlen(str);
+	for (size_t i = len-1; i < len; --i) {
+		int ch = ungetc(str[i], file);
+		if (ch == EOF) errx(EX_IOERR, "cannot push back string");
+	}
+}
+
+static const struct Lexer *matchLexer(const char *name, FILE *file) {
+	char buf[256];
+	regex_t regex;
+	for (size_t i = 0; i < ARRAY_LEN(Lexers); ++i) {
+		int error = regcomp(
+			&regex, Lexers[i].namePatt, REG_EXTENDED | REG_NOSUB
+		);
+		assert(!error);
+		error = regexec(&regex, name, 0, NULL, 0);
+		regfree(&regex);
+		if (!error) return Lexers[i].lexer;
+	}
+	char *line = fgets(buf, sizeof(buf), file);
+	if (!line) return NULL;
+	for (size_t i = 0; i < ARRAY_LEN(Lexers); ++i) {
+		if (!Lexers[i].linePatt) continue;
+		int error = regcomp(
+			&regex, Lexers[i].linePatt, REG_EXTENDED | REG_NOSUB
+		);
+		assert(!error);
+		error = regexec(&regex, line, 0, NULL, 0);
+		regfree(&regex);
+		if (!error) {
+			ungets(line, file);
+			return Lexers[i].lexer;
+		}
+	}
+	ungets(line, file);
+	return NULL;
+}
+
+#define ENUM_OPTION \
+	X(Document, "document") \
+	X(Inline, "inline") \
+	X(Monospace, "monospace") \
+	X(Pre, "pre") \
+	X(Style, "style") \
+	X(Tab, "tab") \
+	X(Title, "title")
+
+enum Option {
+#define X(option, key) option,
+	ENUM_OPTION
+#undef X
+	OptionCap,
+};
+
+typedef void Header(const char *opts[]);
+typedef void Output(const char *opts[], enum Class class, const char *text);
+
+static bool pager;
+static void ansiHeader(const char *opts[]) {
+	(void)opts;
+	if (!pager) return;
+	const char *shell = getenv("SHELL");
+	const char *pager = getenv("PAGER");
+	if (!shell) shell = "/bin/sh";
+	if (!pager) pager = "less";
+	setenv("LESS", "FRX", 0);
+
+	int rw[2];
+	int error = pipe(rw);
+	if (error) err(EX_OSERR, "pipe");
+
+	pid_t pid = fork();
+	if (pid < 0) err(EX_OSERR, "fork");
+	if (!pid) {
+		dup2(rw[0], STDIN_FILENO);
+		close(rw[0]);
+		close(rw[1]);
+		execl(shell, shell, "-c", pager, NULL);
+		err(EX_CONFIG, "%s", shell);
+	}
+	dup2(rw[1], STDOUT_FILENO);
+	close(rw[0]);
+	close(rw[1]);
+	setlinebuf(stdout);
+
+#ifdef __OpenBSD__
+	error = pledge("stdio", NULL);
+	if (error) err(EX_OSERR, "pledge");
+#endif
+}
+
+static void ansiFooter(const char *opts[]) {
+	(void)opts;
+	if (!pager) return;
+	int status;
+	fclose(stdout);
+	wait(&status);
+}
+
+static const char *SGR[ClassCap] = {
+	[Keyword] = "37",
+	[Macro]   = "32",
+	[Comment] = "34",
+	[String]  = "36",
+	[Format]  = "36;1;96",
+	[Subst]   = "33",
+};
+
+static void ansiFormat(const char *opts[], enum Class class, const char *text) {
+	(void)opts;
+	if (!SGR[class]) {
+		printf("%s", text);
+		return;
+	}
+	// Set color on each line for piping to less -R:
+	for (const char *nl; (nl = strchr(text, '\n')); text = &nl[1]) {
+		printf("\33[%sm%.*s\33[m\n", SGR[class], (int)(nl - text), text);
+	}
+	if (*text) printf("\33[%sm%s\33[m", SGR[class], text);
+}
+
+static void
+debugFormat(const char *opts[], enum Class class, const char *text) {
+	if (class != Normal) {
+		printf("%s(", Class[class]);
+		ansiFormat(opts, class, text);
+		printf(")");
+	} else {
+		printf("%s", text);
+	}
+}
+
+static const char *IRC[ClassCap] = {
+	[Keyword] = "\00315",
+	[Macro]   = "\0033",
+	[Comment] = "\0032",
+	[String]  = "\00310",
+	[Format]  = "\00311",
+	[Subst]   = "\0037",
+};
+
+static void ircHeader(const char *opts[]) {
+	if (opts[Monospace]) printf("\21");
+}
+
+static const char *stop(const char *text) {
+	return (*text == ',' || isdigit(*text) ? "\2\2" : "");
+}
+
+static void ircFormat(const char *opts[], enum Class class, const char *text) {
+	for (const char *nl; (nl = strchr(text, '\n')); text = &nl[1]) {
+		if (IRC[class]) printf("%s%s", IRC[class], stop(text));
+		printf("%.*s\n", (int)(nl - text), text);
+		if (opts[Monospace]) printf("\21");
+	}
+	if (*text) {
+		if (IRC[class]) {
+			printf("%s%s%s\17", IRC[class], stop(text), text);
+			if (opts[Monospace]) printf("\21");
+		} else {
+			printf("%s", text);
+		}
+	}
+}
+
+static void htmlEscape(const char *text) {
+	while (*text) {
+		switch (*text) {
+			break; case '"': text++; printf("&quot;");
+			break; case '&': text++; printf("&amp;");
+			break; case '<': text++; printf("&lt;");
+		}
+		size_t len = strcspn(text, "\"&<");
+		if (len) fwrite(text, len, 1, stdout);
+		text += len;
+	}
+}
+
+static const char *Styles[ClassCap] = {
+	[Keyword] = "color: dimgray;",
+	[Macro]   = "color: green;",
+	[Comment] = "color: navy;",
+	[String]  = "color: teal;",
+	[Format]  = "color: teal; font-weight: bold;",
+	[Subst]   = "color: olive;",
+};
+
+static void styleTabSize(const char *tab) {
+	printf("-moz-tab-size: ");
+	htmlEscape(tab);
+	printf("; tab-size: ");
+	htmlEscape(tab);
+	printf(";");
+}
+
+static void htmlHeader(const char *opts[]) {
+	if (!opts[Document]) goto body;
+
+	printf("<!DOCTYPE html>\n<title>");
+	if (opts[Title]) htmlEscape(opts[Title]);
+	printf("</title>\n");
+
+	if (opts[Style]) {
+		printf("<link rel=\"stylesheet\" href=\"");
+		htmlEscape(opts[Style]);
+		printf("\">\n");
+	} else if (!opts[Inline]) {
+		printf("<style>\n");
+		if (opts[Tab]) {
+			printf("pre.hilex { ");
+			styleTabSize(opts[Tab]);
+			printf(" }\n");
+		}
+		for (enum Class class = 0; class < ClassCap; ++class) {
+			if (!Styles[class]) continue;
+			printf("pre.hilex .%.2s { %s }\n", Class[class], Styles[class]);
+		}
+		printf("</style>\n");
+	}
+
+body:
+	if ((opts[Document] || opts[Pre]) && opts[Inline] && opts[Tab]) {
+		printf("<pre class=\"hilex\" style=\"");
+		styleTabSize(opts[Tab]);
+		printf("\">");
+	} else if (opts[Document] || opts[Pre]) {
+		printf("<pre class=\"hilex\">");
+	}
+}
+
+static void htmlFooter(const char *opts[]) {
+	if (opts[Document] || opts[Pre]) printf("</pre>");
+	if (opts[Document]) printf("\n");
+}
+
+static void htmlFormat(const char *opts[], enum Class class, const char *text) {
+	if (class != Normal) {
+		if (opts[Inline]) {
+			printf("<span style=\"%s\">", Styles[class] ? Styles[class] : "");
+		} else {
+			printf("<span class=\"%.2s\">", Class[class]);
+		}
+		htmlEscape(text);
+		printf("</span>");
+	} else {
+		htmlEscape(text);
+	}
+}
+
+static const struct Formatter {
+	const char *name;
+	Header *header;
+	Output *format;
+	Header *footer;
+} Formatters[] = {
+	{ "ansi", ansiHeader, ansiFormat, ansiFooter },
+	{ "debug", NULL, debugFormat, NULL },
+	{ "html", htmlHeader, htmlFormat, htmlFooter },
+	{ "irc", ircHeader, ircFormat, NULL },
+};
+
+static const struct Formatter *parseFormatter(const char *name) {
+	for (size_t i = 0; i < ARRAY_LEN(Formatters); ++i) {
+		if (!strcmp(name, Formatters[i].name)) return &Formatters[i];
+	}
+	errx(EX_USAGE, "unknown formatter %s", name);
+}
+
+static char *const OptionKeys[OptionCap + 1] = {
+#define X(option, key) [option] = key,
+	ENUM_OPTION
+#undef X
+	NULL,
+};
+
+int main(int argc, char *argv[]) {
+	bool text = false;
+	const char *name = NULL;
+	const struct Lexer *lexer = NULL;
+	const struct Formatter *formatter = &Formatters[0];
+	const char *opts[OptionCap] = {0};
+
+	for (int opt; 0 < (opt = getopt(argc, argv, "f:l:n:o:t"));) {
+		switch (opt) {
+			break; case 'f': formatter = parseFormatter(optarg);
+			break; case 'l': lexer = parseLexer(optarg);
+			break; case 'n': name = optarg;
+			break; case 'o': {
+				while (*optarg) {
+					char *val;
+					int key = getsubopt(&optarg, OptionKeys, &val);
+					if (key < 0) errx(EX_USAGE, "no such option %s", val);
+					opts[key] = (val ? val : "");
+				}
+			}
+			break; case 't': text = true;
+			break; default:  return EX_USAGE;
+		}
+	}
+
+	const char *path = "(stdin)";
+	FILE *file = stdin;
+	if (optind < argc) {
+		path = argv[optind];
+		file = fopen(path, "r");
+		if (!file) err(EX_NOINPUT, "%s", path);
+		pager = isatty(STDOUT_FILENO);
+	}
+
+#ifdef __OpenBSD__
+	int error;
+	if (formatter->header == ansiHeader && pager) {
+		error = pledge("stdio proc exec", NULL);
+	} else {
+		error = pledge("stdio", NULL);
+	}
+	if (error) err(EX_OSERR, "pledge");
+#endif
+
+	if (!name) {
+		if (NULL != (name = strrchr(path, '/'))) {
+			name++;
+		} else {
+			name = path;
+		}
+	}
+	if (!opts[Title]) opts[Title] = name;
+	if (!lexer) lexer = matchLexer(name, file);
+	if (!lexer && text) lexer = &LexText;
+	if (!lexer) errx(EX_USAGE, "cannot infer lexer for %s", name);
+
+	*lexer->in = file;
+	if (formatter->header) formatter->header(opts);
+	for (enum Class class; None != (class = lexer->lex());) {
+		assert(class < ClassCap);
+		formatter->format(opts, class, *lexer->text);
+	}
+	if (formatter->footer) formatter->footer(opts);
+}
diff --git a/bin/hilex.h b/bin/hilex.h
new file mode 100644
index 00000000..b57fc8cc
--- /dev/null
+++ b/bin/hilex.h
@@ -0,0 +1,50 @@
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <stdio.h>
+
+#define ENUM_CLASS \
+	X(None) \
+	X(Normal) \
+	X(Operator) \
+	X(Number) \
+	X(Keyword) \
+	X(Ident) \
+	X(Macro) \
+	X(Comment) \
+	X(String) \
+	X(Escape) \
+	X(Format) \
+	X(Subst)
+
+enum Class {
+#define X(class) class,
+	ENUM_CLASS
+#undef X
+	ClassCap,
+};
+
+typedef int Lex(void);
+struct Lexer {
+	Lex *lex;
+	FILE **in;
+	char **text;
+};
+
+extern const struct Lexer LexC;
+extern const struct Lexer LexMake;
+extern const struct Lexer LexMdoc;
+extern const struct Lexer LexSh;
diff --git a/bin/htagml.c b/bin/htagml.c
new file mode 100644
index 00000000..1f547be6
--- /dev/null
+++ b/bin/htagml.c
@@ -0,0 +1,223 @@
+/* Copyright (C) 2021  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <ctype.h>
+#include <err.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <unistd.h>
+
+static char *deregex(const char *patt) {
+	char *buf = malloc(strlen(patt) + 1);
+	if (!buf) err(EX_OSERR, "malloc");
+	char *ptr = buf;
+	if (*patt == '^') patt++;
+	for (; *patt; ++patt) {
+		if (patt[0] == '$' && !patt[1]) {
+			*ptr++ = '\n';
+			break;
+		}
+		if (patt[0] == '\\' && patt[1]) patt++;
+		*ptr++ = *patt;
+	}
+	*ptr = '\0';
+	return buf;
+}
+
+static size_t escape(bool esc, const char *ptr, size_t len) {
+	if (!esc) {
+		fwrite(ptr, len, 1, stdout);
+		return len;
+	}
+	for (size_t i = 0; i < len; ++i) {
+		switch (ptr[i]) {
+			break; case '&': printf("&amp;");
+			break; case '<': printf("&lt;");
+			break; case '"': printf("&quot;");
+			break; default:  putchar(ptr[i]);
+		}
+	}
+	return len;
+}
+
+static void id(const char *tag) {
+	for (const char *ch = tag; *ch; ++ch) {
+		if (isalnum(*ch) || strchr("-._", *ch)) {
+			putchar(*ch);
+		} else {
+			putchar('_');
+		}
+	}
+}
+
+static char *hstrstr(const char *haystack, const char *needle) {
+	while (haystack) {
+		char *elem = strchr(haystack, '<');
+		char *match = strstr(haystack, needle);
+		if (!match) return NULL;
+		if (!elem || match < elem) return match;
+		haystack = strchr(elem, '>');
+	}
+	return NULL;
+}
+
+static int isident(int c) {
+	return isalnum(c) || c == '_';
+}
+
+int main(int argc, char *argv[]) {
+	bool pre = false;
+	bool pipe = false;
+	bool main = false;
+	bool index = false;
+	const char *tagsPath = "tags";
+	for (int opt; 0 < (opt = getopt(argc, argv, "f:impx"));) {
+		switch (opt) {
+			break; case 'f': tagsPath = optarg;
+			break; case 'i': pipe = true;
+			break; case 'm': main = true;
+			break; case 'p': pre = true;
+			break; case 'x': index = true;
+			break; default:  return EX_USAGE;
+		}
+	}
+	if (optind == argc) errx(EX_USAGE, "name required");
+	const char *name = argv[optind];
+
+	FILE *file = fopen(name, "r");
+	if (!file) err(EX_NOINPUT, "%s", name);
+
+	FILE *tagsFile = fopen(tagsPath, "r");
+	if (!tagsFile) err(EX_NOINPUT, "%s", tagsPath);
+
+#ifdef __OpenBSD__
+	int error = pledge("stdio", NULL);
+	if (error) err(EX_OSERR, "pledge");
+#endif
+
+	size_t len = 0;
+	size_t cap = 256;
+	struct Tag {
+		char *tag;
+		int num;
+		char *str;
+		size_t len;
+	} *tags = malloc(cap * sizeof(*tags));
+	if (!tags) err(EX_OSERR, "malloc");
+
+	char *buf = NULL;
+	size_t bufCap = 0;
+	while (0 < getline(&buf, &bufCap, tagsFile)) {
+		char *line = buf;
+		char *tag = strsep(&line, "\t");
+		char *file = strsep(&line, "\t");
+		char *def = strsep(&line, "\n");
+		if (!tag || !file || !def) errx(EX_DATAERR, "malformed tags file");
+
+		if (strcmp(file, name)) continue;
+		if (len == cap) {
+			tags = realloc(tags, (cap *= 2) * sizeof(*tags));
+			if (!tags) err(EX_OSERR, "realloc");
+		}
+		tags[len].tag = strdup(tag);
+		if (!tags[len].tag) err(EX_OSERR, "strdup");
+
+		tags[len].num = 0;
+		if (def[0] == '/' || def[0] == '?') {
+			def++;
+			def[strlen(def)-1] = '\0';
+			if (def[0] != '^') {
+				warnx("unanchored regex for tag %s: %s", tag, def);
+			}
+			tags[len].str = deregex(def);
+			tags[len].len = strlen(tags[len].str);
+		} else {
+			tags[len].num = strtol(def, &def, 10);
+			if (*def) {
+				warnx("invalid line number for tag %s: %s", tag, def);
+				continue;
+			}
+		}
+		len++;
+	}
+	fclose(tagsFile);
+
+	int num = 0;
+	printf(pre ? "<pre>" : index ? "<ul class=\"index\">\n" : "");
+	while (0 < getline(&buf, &bufCap, file) && ++num) {
+		char *tag = NULL;
+		for (size_t i = 0; i < len; ++i) {
+			if (tags[i].num) {
+				if (num != tags[i].num) continue;
+			} else {
+				if (strncmp(tags[i].str, buf, tags[i].len)) continue;
+			}
+			tag = tags[i].tag;
+			tags[i] = tags[--len];
+			break;
+		}
+		if (index) {
+			if (!tag) continue;
+			printf("<li><a class=\"tag\" href=\"#");
+			id(tag);
+			printf("\">");
+			escape(true, tag, strlen(tag));
+			printf("</a></li>\n");
+			continue;
+		}
+		if (pipe) {
+			ssize_t len = getline(&buf, &bufCap, stdin);
+			if (len < 0) {
+				errx(EX_DATAERR, "missing line %d on standard input", num);
+			}
+		}
+		if (!tag) {
+			escape(!pipe, buf, strlen(buf));
+			continue;
+		}
+
+		size_t mlen = strlen(tag);
+		char *match = (pipe ? hstrstr : strstr)(buf, tag);
+		while (
+			match &&
+			((match > buf && isident(match[-1])) || isident(match[mlen]))
+		) {
+			match = (pipe ? hstrstr : strstr)(&match[mlen], tag);
+		}
+		if (!match && tag[0] == 'M') {
+			mlen = 4;
+			match = (pipe ? hstrstr : strstr)(buf, "main");
+			if (main) tag = "main";
+		}
+		if (!match) {
+			mlen = strlen(buf) - 1;
+			match = buf;
+		}
+		escape(!pipe, buf, match - buf);
+		printf("<a class=\"tag\" id=\"");
+		id(tag);
+		printf("\" href=\"#");
+		id(tag);
+		printf("\">");
+		match += escape(!pipe, match, mlen);
+		printf("</a>");
+		escape(!pipe, match, strlen(match));
+	}
+	printf(pre ? "</pre>" : index ? "</ul>\n" : "");
+}
diff --git a/bin/html.mk b/bin/html.mk
new file mode 100644
index 00000000..818c6cf5
--- /dev/null
+++ b/bin/html.mk
@@ -0,0 +1,47 @@
+WEBROOT ?= /var/www/causal.agency
+
+HTMLS = index.html png.html
+HTMLS += ${BINS:=.html}
+HTMLS += ${BSD:=.html}
+HTMLS += ${GAMES:=.html}
+HTMLS += ${TLS:=.html}
+
+html: ${HTMLS}
+	@true
+
+install-html: ${HTMLS}
+	install -d ${WEBROOT}/bin
+	install -C -m 644 ${HTMLS} ${WEBROOT}/bin
+
+${HTMLS}: html.sh scheme hilex htagml htmltags
+
+htmltags: *.[chly] mtags Makefile html.mk *.sh
+	rm -f $@
+	for f in *.[chly]; do ctags -aw -f $@ $$f; done
+	./mtags -a -f $@ Makefile html.mk *.sh
+
+index.html: README.7 Makefile html.mk html.sh
+	sh html.sh README.7 Makefile html.mk html.sh > $@
+
+.SUFFIXES: .html
+
+.c.html:
+	sh html.sh man1/${<:.c=.1} $< > $@
+
+.h.html:
+	sh html.sh man3/${<:.h=.3} $< > $@
+
+.l.html:
+	sh html.sh man1/${<:.l=.1} $< > $@
+
+.y.html:
+	sh html.sh man1/${<:.y=.1} $< > $@
+
+.sh.html:
+	sh html.sh man1/${<:.sh=.1} $< > $@
+
+.pl.html:
+	sh html.sh man1/${<:.pl=.1} $< > $@
+
+freecell.html: freecell.c man6/freecell.6
+	sh html.sh man6/freecell.6 freecell.c > $@
diff --git a/bin/html.sh b/bin/html.sh
new file mode 100644
index 00000000..3223120b
--- /dev/null
+++ b/bin/html.sh
@@ -0,0 +1,66 @@
+#!/bin/sh
+set -eu
+
+readonly GitURL='https://git.causal.agency/src/tree/bin'
+
+man=$1
+shift
+title=${man##*/}
+title=${title%.[1-9]}
+
+cat <<EOF
+<!DOCTYPE html>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+<title>${title}</title>
+<style>
+html { line-height: 1.25em; font-family: monospace; }
+body { max-width: 80ch; margin: 1em auto; padding: 0 1ch; }
+
+table.head, table.foot { width: 100%; }
+td.head-rtitle, td.foot-os { text-align: right; }
+td.head-vol { text-align: center; }
+div.Pp { margin: 1ex 0ex; }
+div.Nd, div.Bf, div.Op { display: inline; }
+span.Pa, span.Ad { font-style: italic; }
+span.Ms { font-weight: bold; }
+dl.Bl-diag > dt { font-weight: bold; }
+code.Nm, code.Fl, code.Cm, code.Ic, code.In, code.Fd, code.Fn,
+code.Cd { font-weight: bold; font-family: inherit; }
+
+table { border-collapse: collapse; }
+table.Nm code.Nm { padding-right: 1ch; }
+table.foot { margin-top: 1em; }
+
+ul.index { padding: 0; }
+ul.index li { display: inline; list-style-type: none; }
+pre { -moz-tab-size: 4; tab-size: 4; }
+
+$(./scheme -st)
+html { background-color: var(--ansi16); color: var(--ansi17); }
+a { color: var(--ansi4); }
+a:visited { color: var(--ansi5); }
+a.permalink, a.tag { color: var(--ansi3); text-decoration: none; }
+a.permalink > code:target, *:target > a.permalink,
+a.tag:target { color: var(--ansi11); }
+pre .Ke { color: var(--ansi7); }
+pre .Ma { color: var(--ansi2); }
+pre .Co { color: var(--ansi4); }
+pre .St { color: var(--ansi6); }
+pre .Fo { color: var(--ansi14); }
+pre .Su { color: var(--ansi1); }
+</style>
+EOF
+
+opts=fragment
+[ "${man}" = "README.7" ] && opts=${opts},man=%N.html
+mandoc -T html -O ${opts} "${man}"
+
+for src; do
+	cat <<-EOF
+	<p>
+	<a href="${GitURL}/${src}">${src} in git</a>
+	EOF
+	./htagml -x -f htmltags "${src}"
+	./hilex -t -f html "${src}" | ./htagml -ip -f htmltags "${src}"
+done
diff --git a/bin/make.l b/bin/make.l
new file mode 100644
index 00000000..6296716d
--- /dev/null
+++ b/bin/make.l
@@ -0,0 +1,127 @@
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+%option prefix="make"
+%option noinput nounput noyywrap
+
+%{
+#include "hilex.h"
+%}
+
+%s Assign Preproc
+%x Variable Shell
+
+ident [._[:alnum:]]+
+assign [+?:!]?=
+target [-._/?*\[\][:alnum:]]+
+operator [:!]|::
+
+%%
+	static int pop = INITIAL;
+	static int depth = 0;
+
+^"\t"+ {
+	BEGIN(pop = Shell);
+	return Normal;
+}
+<Shell>{
+	"\n" {
+		BEGIN(pop = INITIAL);
+		return Normal;
+	}
+	"\\\n" { return Normal; }
+	[^\\\n$]+|. { return Normal; }
+}
+
+[[:blank:]]+ { return Normal; }
+
+{operator} { return Operator; }
+
+"."(PHONY|PRECIOUS|SUFFIXES)/{operator}? {
+	return Keyword;
+}
+
+{target}/{operator} { return Ident; }
+
+^"."{ident} |
+^"-"?include {
+	BEGIN(pop = Preproc);
+	return Macro;
+}
+<Preproc>{
+	"\n" {
+		BEGIN(pop = INITIAL);
+		return Normal;
+	}
+	"\\\n""\t"? { return Normal; }
+
+	"\""[^""]*"\"" |
+	"<"[^>]*">" {
+		return String;
+	}
+
+	[!<>=]"="?|"||"|"&&" { return Operator; }
+	[0-9]+|"0x"[[:xdigit:]]+ { return Number; }
+	defined|make|empty|exists|target|commands|in { return Keyword; }
+}
+
+^{ident}/[[:blank:]]*{assign} {
+	return Ident;
+}
+
+{assign} {
+	BEGIN(pop = Assign);
+	return Operator;
+}
+<Assign>{
+	"\n" {
+		BEGIN(pop = INITIAL);
+		return Normal;
+	}
+	"\\\n""\t"? { return Escape; }
+	[^\\$[:space:]]+|. { return String; }
+}
+
+{target} { return Ident; }
+
+"#"([^\\\n]|"\\"[^\n]|"\\\n")* { return Comment; }
+
+<*>{
+	"$"("{"|"(")/[^$] {
+		depth++;
+		BEGIN(Variable);
+		yymore();
+	}
+	"$"("{"|"(") {
+		depth++;
+		BEGIN(Variable);
+		return Subst;
+	}
+	"$". { return Subst; }
+}
+<Variable>{
+	[^${}()]*"}"|")" {
+		if (!--depth) BEGIN(pop);
+		return Subst;
+	}
+	[^${}()]+ { return Subst; }
+}
+
+.|\n { return Normal; }
+
+%%
+
+const struct Lexer LexMake = { yylex, &yyin, &yytext };
diff --git a/bin/man1/beef.1 b/bin/man1/beef.1
new file mode 100644
index 00000000..ea52cfa0
--- /dev/null
+++ b/bin/man1/beef.1
@@ -0,0 +1,91 @@
+.Dd August 28, 2019
+.Dt BEEF 1
+.Os
+.
+.Sh NAME
+.Nm beef
+.Nd Befunge-93 interpreter
+.
+.Sh SYNOPSIS
+.Nm
+.Op Ar file
+.
+.Sh DESCRIPTION
+.Nm
+is a Befunge-93 interpreter.
+If no
+.Ar file
+is provided,
+the program is read from standard input.
+.
+.Ss Befunge-93 Command Summary
+.Bl -tag -width "0-9" -compact
+.It \(dq
+toggle string mode
+.It 0-9
+push value
+.It +
+add
+.It -
+subtract
+.It *
+multiply
+.It /
+divide
+.It %
+modulo
+.It !
+not
+.It `
+greater than
+.It >
+right
+.It <
+left
+.It ^
+up
+.It v
+down
+.It ?
+random
+.It _
+horizontal (left) if
+.It |
+vertical (up) if
+.It :
+duplicate
+.It \e
+swap
+.It $
+drop
+.It .
+output integer
+.It ,
+output ASCII
+.It #
+bridge
+.It g
+get (y, x)
+.It p
+put (y, x) = v
+.It &
+input integer
+.It ~
+input ASCII
+.It @
+exit
+.El
+.
+.Sh EXIT STATUS
+.Nm
+exits with the top value left on the stack,
+or 0 if the stack is left empty.
+.
+.Sh STANDARDS
+.Rs
+.%A Chris Pressey
+.%Q Cat's Eye Technologies
+.%T Befunge-93
+.%D September, 1993
+.%U https://github.com/catseye/Befunge-93/blob/master/doc/Befunge-93.markdown
+.Re
diff --git a/bin/man1/bibsort.1 b/bin/man1/bibsort.1
new file mode 100644
index 00000000..07ed91ef
--- /dev/null
+++ b/bin/man1/bibsort.1
@@ -0,0 +1,40 @@
+.Dd February 16, 2021
+.Dt BIBSORT 1
+.Os
+.
+.Sh NAME
+.Nm bibsort
+.Nd reformat bibliography
+.
+.Sh SYNOPSIS
+.Nm
+.Op Ar file
+.
+.Sh DESCRIPTION
+.Nm
+reformats on standard output
+the
+.Em STANDARDS
+section of the
+.Xr mdoc 7
+manual page
+.Ar file
+or standard input.
+Bibliographic references
+are sorted by author last names,
+and formatted in an item list
+with macro lines appearing
+in the order they are formatted by
+.Xr mandoc 1 .
+Additionally,
+.Ic \&%N
+macros referencing RFC numbers
+are rewritten to
+.Ic \&%R
+macros
+and missing
+.Ic \&%U
+macros are added for RFCs.
+.
+.Sh EXAMPLES
+.Dl :%!bibsort
diff --git a/bin/man1/bit.1 b/bin/man1/bit.1
new file mode 100644
index 00000000..b91a10e1
--- /dev/null
+++ b/bin/man1/bit.1
@@ -0,0 +1,55 @@
+.Dd December 30, 2020
+.Dt BIT 1
+.Os
+.
+.Sh NAME
+.Nm bit
+.Nd a calculator
+.
+.Sh SYNOPSIS
+.Nm
+.
+.Sh DESCRIPTION
+.Nm
+is an integer calculator.
+Its syntax resembles that of C expressions,
+with the following changes:
+.
+.Bl -bullet
+.It
+Underscores are allowed in integer literals.
+.It
+The
+.Sy 0b
+prefix is used for binary literals.
+.It
+The
+.Sy ->
+operator is used for arithmetic shift.
+.It
+The unary
+.Sy &
+operator is equivalent to
+.Sy (1 << x) - 1 .
+.It
+The postfix operators
+.Sy K ,
+.Sy M ,
+.Sy G ,
+.Sy T
+are used as constant multipliers.
+.It
+The postfix operator
+.Sy $
+is of lowest precedence and is equivalent to
+wrapping the preceding expression in parentheses.
+.It
+Single-letter (lower case) variables
+can be assigned.
+The variable
+.Sy _
+stores the previous result.
+.El
+.
+.Sh SEE ALSO
+.Xr operator 7
diff --git a/bin/man1/c.1 b/bin/man1/c.1
new file mode 100644
index 00000000..97384ebe
--- /dev/null
+++ b/bin/man1/c.1
@@ -0,0 +1,45 @@
+.Dd January  9, 2021
+.Dt C 1
+.Os
+.
+.Sh NAME
+.Nm c
+.Nd run C
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl t
+.Op Fl e Ar expr
+.Op Fl i Ar include
+.Op Ar stmts ...
+.
+.Sh DESCRIPTION
+The
+.Nm
+utility compiles and runs
+C statements wrapped in
+.Fn main
+with common includes.
+If no
+.Ar expr
+or
+.Ar stmts
+are provided,
+statements are read from standard input.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl e Ar expr
+Print the result of the C expression
+.Ar expr
+after executing
+.Ar stmts .
+.It Fl i Ar include
+Add the include file
+.Ar include .
+.It Fl t
+With
+.Fl e ,
+print the type of the expression.
+.El
diff --git a/bin/man1/dehtml.1 b/bin/man1/dehtml.1
new file mode 100644
index 00000000..c55c35d4
--- /dev/null
+++ b/bin/man1/dehtml.1
@@ -0,0 +1,38 @@
+.Dd September  7, 2021
+.Dt DEHTML 1
+.Os
+.
+.Sh NAME
+.Nm dehtml
+.Nd extract text from HTML
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl s
+.Op Ar
+.
+.Sh DESCRIPTION
+The
+.Nm
+utility extracts text
+from HTML documents.
+Text inside
+.Sy <title> ,
+.Sy <style>
+and
+.Sy <script>
+tags is discarded.
+Numeric and common named HTML entities
+are converted.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl s
+Collapse whitespace outside of
+.Sy <pre>
+tags.
+.El
+.
+.Sh BUGS
+There is no way to extract image alt text.
diff --git a/bin/man1/downgrade.1 b/bin/man1/downgrade.1
new file mode 100644
index 00000000..e1a594b7
--- /dev/null
+++ b/bin/man1/downgrade.1
@@ -0,0 +1,122 @@
+.Dd September 14, 2021
+.Dt DOWNGRADE 1
+.Os
+.
+.Sh NAME
+.Nm downgrade
+.Nd IRC features for all
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl iv
+.Op Fl c Ar cert
+.Op Fl j Ar join
+.Op Fl k Ar priv
+.Op Fl n Ar nick
+.Op Fl p Ar port
+.Ar host
+.
+.Sh DESCRIPTION
+The
+.Nm
+IRC bot downgrades new IRC
+.Dq features
+so
+.Em everyone
+can see them.
+It supports typing notifications,
+message reactions
+and message replies.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl c Ar cert
+Load the TLS client certificate from
+.Ar cert
+and authenticate using SASL EXTERNAL.
+.It Fl i
+Accept invites to channels.
+.It Fl j Ar join
+Join the channel list
+.Ar join .
+.It Fl k Ar priv
+Load the TLS client private key from
+.Ar priv .
+The default is the same path as
+.Ar cert .
+.It Fl n Ar nick
+Set the nickname and username to
+.Ar nick .
+The default is
+.Nm .
+.It Fl p Ar port
+Connect to
+.Ar port .
+The default is 6697.
+.It Fl v
+Log IRC protocol.
+.It Ar host
+Connect to
+.Ar host .
+.El
+.
+.Sh EXAMPLES
+.Bd -literal
+-downgrade- * guest-n4 is typing...
+<guest-n4> wtf
+-downgrade- * june reacted to guest-n4's message ("wtf") with "\[u1F44D]"
+-downgrade- * guest-n4 is typing...
+-downgrade- * guest-n4 has given up :(
+.Ed
+.Bd -literal
+<june> ,bef
+-downgrade- * tildebot is typing...
+<tildebot> [Ducks] june: There was no duck!
+-downgrade- * tildebot was replying to june's message (",bef")
+.Ed
+.
+.Sh STANDARDS
+.Bl -item
+.It
+.Rs
+.%A Kiyoshi Aman
+.%A Kyle Fuller
+.%A St\('ephan Kochen
+.%A Alexey Sokolov
+.%A James Wheare
+.%T Message Tags
+.%U https://ircv3.net/specs/extensions/message-tags
+.Re
+.It
+.Rs
+.%A MuffinMedic
+.%A James Wheare
+.%T typing client tag
+.%U https://ircv3.net/specs/client-tags/typing
+.Re
+.It
+.Rs
+.%A Daniel Oaks
+.%T Bot Mode
+.%U https://ircv3.net/specs/extensions/bot-mode
+.Re
+.It
+.Rs
+.%A James Wheare
+.%T Message IDs
+.%U https://ircv3.net/specs/extensions/message-ids
+.Re
+.It
+.Rs
+.%A James Wheare
+.%T react client tag
+.%U https://ircv3.net/specs/client-tags/react
+.Re
+.It
+.Rs
+.%A James Wheare
+.%T reply client tag
+.%U https://ircv3.net/specs/client-tags/reply
+.Re
+.El
diff --git a/bin/man1/dtch.1 b/bin/man1/dtch.1
new file mode 100644
index 00000000..e27713e1
--- /dev/null
+++ b/bin/man1/dtch.1
@@ -0,0 +1,67 @@
+.Dd August 12, 2019
+.Dt DTCH 1
+.Os
+.
+.Sh NAME
+.Nm dtch
+.Nd detached sessions
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl s
+.Ar name
+.Op Ar command ...
+.Nm
+.Fl a
+.Ar name
+.
+.Sh DESCRIPTION
+.Nm
+spawns a
+.Ar command
+in a detachable session.
+If no
+.Ar command
+is given,
+the value of
+.Ev SHELL
+is used.
+The
+.Nm
+process
+should be run as a background job
+or with
+.Xr nohup 1 .
+.
+.Pp
+To attach to an existing session,
+pass the
+.Fl a
+flag.
+To detach from the session,
+type
+.Ic ^Q .
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl a
+Attach to an existing session.
+.It Fl s
+Sink the output of
+.Ar command
+while detached.
+.El
+.
+.Sh FILES
+.Bl -tag -width Ds
+.It Pa ~/.dtch
+Directory of UNIX-domain sockets
+for each session.
+.El
+.
+.Sh EXAMPLES
+.Bd -literal -offset indent
+dtch foo vim &
+dtch -a foo
+.Ed
diff --git a/bin/man1/enc.1 b/bin/man1/enc.1
new file mode 100644
index 00000000..32845847
--- /dev/null
+++ b/bin/man1/enc.1
@@ -0,0 +1,55 @@
+.Dd January 30, 2022
+.Dt ENC 1
+.Os
+.
+.Sh NAME
+.Nm enc
+.Nd encrypt and decrypt files
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl acdef
+.Op Ar
+.
+.Sh DESCRIPTION
+.Nm
+encrypts and decrypts files
+using ChaCha20 via
+.Xr openssl 1 .
+When encrypting files,
+the
+.Pa .enc
+extension is added.
+When decrypting files,
+the
+.Pa .enc
+extension is removed,
+if possible.
+Otherwise output is written
+to standard output.
+Input files are not removed.
+If no files are provided,
+standard input is encrypted or decrypted.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl a
+Encrypted data is Base64-encoded.
+.It Fl c
+Always write to standard output.
+.It Fl d
+Decrypt.
+.It Fl e
+Encrypt.
+This is the default.
+.It Fl f
+Do not ask to confirm overwriting files.
+.El
+.
+.Sh EXAMPLES
+.Bd -literal -offset indent
+$ enc secret.txt
+$ rm secret.txt
+$ enc -d secret.txt.enc
+.Ed
diff --git a/bin/man1/ever.1 b/bin/man1/ever.1
new file mode 100644
index 00000000..8cdab99b
--- /dev/null
+++ b/bin/man1/ever.1
@@ -0,0 +1,51 @@
+.Dd February 24, 2021
+.Dt EVER 1
+.Os
+.
+.Sh NAME
+.Nm ever
+.Nd watch files
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl iq
+.Ar
+.Ar command
+.Nm
+.Op Fl i
+.Ar
+.Fl -
+.Ar command
+.Op Ar argument ...
+.
+.Sh DESCRIPTION
+.Nm
+executes the
+.Ar command
+whenever
+.Ar file
+is modified.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl i
+Attach the
+.Ar file
+which was modified
+to the standard input of
+.Ar command .
+.It Fl q
+Suppress exit status output.
+.El
+.
+.Sh EXAMPLES
+.Dl ever ever.c make
+.Dl ever when.y ever.c -- make when ever
+.Dl ever -i ever.1 mandoc
+.
+.Sh CAVEATS
+.Nm
+does not support Linux
+since it uses
+.Xr kqueue 2 .
diff --git a/bin/man1/git-comment.1 b/bin/man1/git-comment.1
new file mode 100644
index 00000000..8e958f30
--- /dev/null
+++ b/bin/man1/git-comment.1
@@ -0,0 +1,117 @@
+.Dd September 10, 2021
+.Dt GIT-COMMENT 1
+.Os
+.
+.Sh NAME
+.Nm git-comment
+.Nd add comments from commit messages
+.
+.Sh SYNOPSIS
+.Nm git comment
+.Op Fl \-all
+.Op Fl \-comment-start Ar string
+.Op Fl \-comment-lead Ar string
+.Op Fl \-comment-end Ar string
+.Op Fl \-min-group Ar lines
+.Op Fl \-min-repeat Ar lines
+.Op Fl \-no-repeat
+.Op Fl \-pretty Ar format
+.Op Ar options ...
+.Op Fl \-
+.Ar file
+.
+.Sh DESCRIPTION
+The
+.Nm
+command
+adds comments to a file
+showing the commit messages
+which last modified
+each group of lines.
+By default only commit messages with bodies
+and which modified groups of at least 2 lines
+are added.
+Each comment contains
+the abbreviated commit hash
+and the commit summary,
+followed by the commit body.
+.
+.Pp
+.Nm
+accepts all the options of
+.Xr git-blame 1
+in addition to the following:
+.Bl -tag -width Ds
+.It Fl \-all
+Include all commit messages.
+The default is to include
+only commit messages with bodies
+(lines after the summary).
+.
+.It Fl \-comment-start Ar string
+Start comments with
+.Ar string .
+The default is the value of
+.Cm comment.start
+or
+.Ql /* .
+.
+.It Fl \-comment-lead Ar string
+Continue comments with the leading
+.Ar string .
+The default is the value of
+.Cm comment.lead
+or
+.Ql " *" .
+.
+.It Fl \-comment-end Ar string
+End comments with
+.Ar string .
+The default is the value of
+.Cm comment.end
+or
+.Ql " */" .
+.
+.It Fl \-min-group Ar lines
+Add comments only for groups of at least
+.Ar lines .
+The default is 2 lines.
+.
+.It Fl \-min-repeat Ar lines
+Avoid repeating a comment
+if it occurred in the last
+.Ar lines .
+The default is 30 lines.
+.
+.It Fl \-no-repeat
+Avoid repeating comments entirely.
+.
+.It Fl \-pretty Ar format
+Set the pretty-print format
+to use for commit messages.
+The default is the value of
+.Cm comment.pretty
+or
+.Ql format:%h\ %s%n%n%-b .
+See
+.Xr git-show 1 .
+.El
+.
+.Sh EXAMPLES
+For files with
+.Ql #
+comments:
+.Bd -literal -offset indent
+git config comment.start '#'
+git config comment.lead '#'
+git config comment.end ''
+.Ed
+.
+.Pp
+Add as many comments as possible:
+.Bd -literal -offset indent
+git comment --all --min-group 1 --min-repeat 1
+.Ed
+.
+.Sh SEE ALSO
+.Xr git-blame 1
diff --git a/bin/man1/glitch.1 b/bin/man1/glitch.1
new file mode 100644
index 00000000..6562c4dc
--- /dev/null
+++ b/bin/man1/glitch.1
@@ -0,0 +1,77 @@
+.Dd September 7, 2018
+.Dt GLITCH 1
+.Os
+.
+.Sh NAME
+.Nm glitch
+.Nd PNG glitcher
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl cfimprxy
+.Op Fl a Ar filters
+.Op Fl d Ar filters
+.Op Fl o Ar file
+.Op Ar
+.
+.Sh DESCRIPTION
+.Nm
+misinterprets PNG files
+according to the options given
+to create natural glitch effects.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl a Ar filters
+Apply a pattern of comma-separated filters.
+Filters are
+.Cm none ,
+.Cm sub ,
+.Cm up ,
+.Cm average ,
+.Cm paeth .
+.
+.It Fl c
+Write to standard output.
+.
+.It Fl d Ar filters
+Declare a pattern of comma-separated filters.
+See
+.Fl a
+for list of filters.
+.
+.It Fl f
+Apply filtering in place of reconstruction.
+.
+.It Fl i
+Invert image data after filtering.
+.
+.It Fl m
+Mirror scanlines after filtering.
+.
+.It Fl o Ar file
+Write to
+.Ar file .
+.
+.It Fl p
+Use a broken Paeth predictor function.
+.
+.It Fl r
+Apply reconstruction in place of filtering.
+.
+.It Fl x
+Zero first pixel of each scanline after filtering.
+.
+.It Fl y
+Zero first scanline after filtering.
+.El
+.
+.Sh EXAMPLES
+.Dl glitch -m -a sub -d sub
+.
+.Sh SEE ALSO
+.Xr pngo 1
+.
+.Sh BUGS
+More wanted.
diff --git a/bin/man1/hilex.1 b/bin/man1/hilex.1
new file mode 100644
index 00000000..80b3155b
--- /dev/null
+++ b/bin/man1/hilex.1
@@ -0,0 +1,218 @@
+.Dd January 20, 2021
+.Dt HILEX 1
+.Os
+.
+.Sh NAME
+.Nm hilex
+.Nd syntax highlighter
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl t
+.Op Fl f Ar format
+.Op Fl l Ar lexer
+.Op Fl n Ar name
+.Op Fl o Ar opts
+.Op Ar file
+.
+.Sh DESCRIPTION
+The
+.Nm
+utility
+syntax highlights
+the contents of
+.Ar file
+or standard input
+and formats it on standard output.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width "-f format"
+.It Fl f Ar format
+Set the output format.
+See
+.Sx Output Formats .
+The default format is
+.Cm ansi .
+.
+.It Fl l Ar lexer
+Set the input lexer.
+See
+.Sx Input Lexers .
+The default input lexer is inferred from
+.Ar name
+or the first line of input.
+.
+.It Fl n Ar name
+Set the name used to infer the input lexer.
+The default is the final component of
+.Ar file .
+.
+.It Fl o Ar opts
+Set output format options.
+.Ar opts
+is a comma-separated list of options.
+Options for each output format are documented in
+.Sx Output Formats .
+.
+.It Fl t
+Default to the
+.Cm text
+input lexer if one cannot be inferred.
+.El
+.
+.Ss Output Formats
+.Bl -tag -width Ds
+.It Cm ansi
+Output ANSI terminal control sequences.
+If standard output is a terminal
+and standard input is not being read,
+output is piped to
+.Ev PAGER
+with
+.Ev LESS=FRX
+if it is not already set.
+.
+.It Cm html
+Output HTML
+.Sy span
+elements
+with the following classes:
+.Pp
+.Bl -hang -width "\&Op" -compact
+.It Sy \&Op
+operators
+.It Sy \&Nu
+numbers
+.It Sy \&Ke
+keywords
+.It Sy \&Id
+identifiers
+.It Sy \&Ma
+macros
+.It Sy \&Co
+comments
+.It Sy \&St
+strings
+.It Sy \&Es
+character escapes
+.It Sy \&Fo
+format strings
+.It Sy \&Su
+variable substitutions
+.El
+.Pp
+The options are as follows:
+.Bl -tag -width "title=..."
+.It Cm document
+Output an HTML document containing a
+.Sy pre
+element.
+.It Cm inline
+Output inline style attributes
+rather than classes.
+.It Cm pre
+Wrap the output in a
+.Sy pre
+element with the class
+.Sy hilex .
+.It Cm style Ns = Ns Ar url
+With
+.Cm document ,
+use the external stylesheet
+.Ar url .
+If unset,
+default styles are included in a
+.Sy style
+element.
+.It Cm tab Ns = Ns Ar n
+With
+.Cm document ,
+.Cm inline
+or
+.Cm pre ,
+set the
+.Sy tab-size
+property to
+.Ar n .
+.It Cm title Ns = Ns Ar ...
+With
+.Cm document ,
+set the
+.Sy title
+element text.
+The default title is the same as
+.Ar name .
+.El
+.
+.It Cm irc
+Output IRC formatting codes.
+The options are as follows:
+.Bl -tag -width "monospace"
+.It Cm monospace
+Use the IRCCloud monospace formatting code.
+.El
+.El
+.
+.Ss Input Lexers
+.Bl -tag -width Ds
+.It Cm c
+The C11 language,
+with minimal support for
+.Xr lex 1 ,
+.Xr yacc 1
+and Objective-C input.
+Inferred for
+.Pa *.[chlmy]
+files.
+.
+.It Cm make
+BSD
+.Xr make 1 .
+Inferred for
+.Pa Makefile ,
+.Pa *.mk
+and
+.Pa *.am
+files.
+.
+.It Cm mdoc
+The
+.Xr mdoc 7
+language.
+Inferred for
+.Pa *.[1-9]
+files
+and files starting with
+.Dq .Dd .
+.
+.It Cm sh
+POSIX
+.Xr sh 1 .
+Since lexical analysis of
+the shell command language
+is effectively impossible,
+this is best-effort only.
+Inferred for
+.Pa *.sh ,
+.Pa .profile ,
+.Pa .shrc
+files
+and files starting with
+.Dq #!/bin/sh .
+.
+.It Cm text
+Plain text.
+Inferred for
+.Pa *.txt
+files.
+.El
+.
+.Sh ENVIRONMENT
+.Bl -tag -width "PAGER"
+.It Ev PAGER
+The command to pipe ANSI output to
+if standard output is a terminal.
+The default is
+.Ev PAGER=less .
+.El
diff --git a/bin/man1/htagml.1 b/bin/man1/htagml.1
new file mode 100644
index 00000000..d8cf6441
--- /dev/null
+++ b/bin/man1/htagml.1
@@ -0,0 +1,75 @@
+.Dd October  1, 2021
+.Dt HTAGML 1
+.Os
+.
+.Sh NAME
+.Nm htagml
+.Nd format tagged file as HTML
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl imp | x
+.Op Fl f Ar tagsfile
+.Ar file
+.
+.Sh DESCRIPTION
+The
+.Nm
+utility formats a file tagged with
+.Xr ctags 1
+as HTML.
+Tags are output as fragment hyperlinks
+with the class
+.Qq tag .
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl f Ar tagsfile
+Read the tag descriptions from a file called
+.Ar tagsfile .
+The default behavior is
+to read them from a file called
+.Pa tags .
+.It Fl i
+Assume
+.Ar file
+has been pre-formatted
+on standard input,
+such as by a syntax highlighter.
+Only tag hyperlinks are added.
+.It Fl m
+Rename the
+.Xr ctags 1
+.Sq M
+tag to
+.Sy main .
+.It Fl p
+Wrap the output in a
+.Sy pre
+element.
+.It Fl x
+Instead produce an index of tags
+ordered by their occurrence in
+.Ar file .
+The index is formatted as a
+.Sy ul
+element with the class
+.Qq index .
+.El
+.
+.Sh FILES
+.Bl -tag -width Ds
+.It Pa tags
+default input tags file
+.El
+.
+.Sh EXAMPLES
+.Bd -literal -offset indent
+ctags htagml.c && htagml htagml.c
+hilex -f html htagml.c | htagml -i htagml.c
+.Ed
+.
+.Sh SEE ALSO
+.Xr ctags 1 ,
+.Xr hilex 1
diff --git a/bin/man1/modem.1 b/bin/man1/modem.1
new file mode 100644
index 00000000..a4bbc3f1
--- /dev/null
+++ b/bin/man1/modem.1
@@ -0,0 +1,31 @@
+.Dd December  8, 2020
+.Dt MODEM 1
+.Os
+.
+.Sh NAME
+.Nm modem
+.Nd fixed baud rate wrapper
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl r Ar rate
+.Ar command ...
+.
+.Sh DESCRIPTION
+.Nm
+runs the
+.Ar command
+in a new PTY
+with a fixed baud rate.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl r Ar rate
+Set the baud rate.
+The default is 19200.
+.El
+.
+.Sh BUGS
+Window size changes are not propagated
+to the child PTY.
diff --git a/bin/man1/mtags.1 b/bin/man1/mtags.1
new file mode 100644
index 00000000..57856ba0
--- /dev/null
+++ b/bin/man1/mtags.1
@@ -0,0 +1,76 @@
+.Dd January 20, 2021
+.Dt MTAGS 1
+.Os
+.
+.Sh NAME
+.Nm mtags
+.Nd miscellaneous tags
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl a
+.Op Fl f Ar tagsfile
+.Ar
+.
+.Sh DESCRIPTION
+The
+.Nm
+utility
+makes a
+.Pa tags
+file for
+.Xr ex 1
+from the specified
+.Xr make 1 ,
+.Xr mdoc 7
+.Xr sh 1
+sources.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl a
+Append to
+.Pa tags
+file.
+.It Fl f Ar tagsfile
+Place the tag descriptions
+in a file called
+.Ar tagsfile .
+The default behaviour is
+to place them in a file called
+.Pa tags .
+.El
+.
+.Pp
+Files whose names are
+.Pa Makefile
+or end in
+.Pa .mk
+are assumed to be
+.Xr make 1
+files.
+Files whose names end in
+.Pa .[1-9]
+are assumed to be
+.Xr mdoc 7
+files.
+Files whose names are
+.Pa .profile ,
+.Pa .shrc
+or end in
+.Pa .sh
+are assumed to be
+.Xr sh 1
+files.
+.
+.Sh FILES
+.Bl -tag -width Ds
+.It Pa tags
+default output tags file
+.El
+.
+.Sh SEE ALSO
+.Xr ctags 1 ,
+.Xr ex 1 ,
+.Xr vi 1
diff --git a/bin/man1/nudge.1 b/bin/man1/nudge.1
new file mode 100644
index 00000000..3ca4294a
--- /dev/null
+++ b/bin/man1/nudge.1
@@ -0,0 +1,44 @@
+.Dd September  4, 2020
+.Dt NUDGE 1
+.Os
+.
+.Sh NAME
+.Nm nudge
+.Nd terminal vibrator
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl f Ar file
+.Op Fl n Ar count
+.Op Fl s Ar shake
+.Op Fl t Ar delay
+.
+.Sh DESCRIPTION
+The
+.Nm
+utility
+nudges the terminal.
+An
+.Xr xterm 1
+compatible terminal is required.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl f Ar file
+Open the terminal named by
+.Ar file .
+The default is
+.Pa /dev/tty .
+.It Fl n Ar count
+Set the number of times
+to nudge the terminal.
+The default is 2.
+.It Fl s Ar shake
+Set the shake intensity in pixels.
+The default is 10.
+.It Fl t Ar delay
+Set the interval between shakes
+in milliseconds.
+The default is 20.
+.El
diff --git a/bin/man1/order.1 b/bin/man1/order.1
new file mode 100644
index 00000000..89fcbda5
--- /dev/null
+++ b/bin/man1/order.1
@@ -0,0 +1,38 @@
+.Dd July 18, 2020
+.Dt ORDER 1
+.Os
+.
+.Sh NAME
+.Nm order
+.Nd operator precedence
+.
+.Sh SYNOPSIS
+.Nm
+.Op Ar expr ...
+.
+.Sh DESCRIPTION
+.Nm
+parses C expressions
+and prints them with parentheses
+according to the precedence rules in
+.Xr operator 7 .
+If no
+.Ar expr
+are given,
+an expression is read
+from standard input.
+.
+.Sh EXAMPLES
+.Bd -literal
+$ order 'a & b << 1'
+(a & (b << 1))
+.Ed
+.
+.Sh SEE ALSO
+.Xr operator 7
+.
+.Sh CAVEATS
+.Nm
+does not support the
+.Sy (type)
+operator.
diff --git a/bin/man1/pbd.1 b/bin/man1/pbd.1
new file mode 100644
index 00000000..f0665891
--- /dev/null
+++ b/bin/man1/pbd.1
@@ -0,0 +1,66 @@
+.Dd February  9, 2021
+.Dt PBD 1
+.Os
+.
+.Sh NAME
+.Nm pbd
+.Nd macOS pasteboard daemon
+.
+.Sh SYNOPSIS
+.Nm Op Fl s | c | p | o Ar url
+.
+.Sh DESCRIPTION
+.Nm
+is a daemon which pipes into macOS
+.Xr pbcopy 1 ,
+from
+.Xr pbpaste 1
+and invokes
+.Xr open 1
+in response to messages
+sent over TCP port 7062.
+.
+.Pp
+The socket can be forwarded through
+.Xr ssh 1
+and the flags can be used remotely
+to access the local pasteboard
+and open URLs.
+.
+.Pp
+Forwarding can be configured with:
+.Pp
+.Dl RemoteForward 7062 127.0.0.1:7062
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl c
+Behave as
+.Xr pbcopy 1 .
+.It Fl o Ar url
+Behave as
+.Xr open 1 .
+.It Fl p
+Behave as
+.Xr pbpaste 1 .
+.It Fl s
+Run the server.
+This is the default.
+.El
+.Pp
+ACAB.
+.
+.Sh EXAMPLES
+.Bd -literal -offset indent
+pbd &
+ssh -R 7062:127.0.0.1:7062 tux.local
+pbd -p
+.Ed
+.
+.Sh SEE ALSO
+.Xr open 1 ,
+.Xr pbcopy 1 ,
+.Xr pbpaste 1 ,
+.Xr ssh 1 ,
+.Xr ssh_config 5
diff --git a/bin/man1/pngo.1 b/bin/man1/pngo.1
new file mode 100644
index 00000000..a235355b
--- /dev/null
+++ b/bin/man1/pngo.1
@@ -0,0 +1,64 @@
+.Dd September 21, 2021
+.Dt PNGO 1
+.Os
+.
+.Sh NAME
+.Nm pngo
+.Nd PNG optimizer
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl acgv
+.Op Fl b Ar depth
+.Op Fl o Ar file
+.Op Ar
+.
+.Sh DESCRIPTION
+.Nm
+optimizes PNG files for size
+by performing the following:
+.Pp
+.Bl -enum -compact
+.It
+Discard ancillary chunks.
+.It
+Discard unnecessary alpha channel.
+.It
+Convert unnecessary truecolor to grayscale.
+.It
+Palletize color if possible.
+.It
+Reduce unnecessary bit depth.
+.It
+Apply a simple filter type heuristic.
+.It
+Apply zlib's best compression.
+.El
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl a
+Always discard the alpha channel.
+.It Fl b Ar depth
+Reduce bit depth to
+.Ar depth
+or lower.
+.It Fl c
+Write to standard output.
+.It Fl g
+Convert to grayscale.
+.It Fl o Ar file
+Write to
+.Ar file .
+.It Fl v
+Print header information and sizes
+to standard error.
+.El
+.
+.Sh SEE ALSO
+.Xr glitch 1
+.
+.Sh BUGS
+.Nm
+does not support interlaced PNGs.
diff --git a/bin/man1/psf2png.1 b/bin/man1/psf2png.1
new file mode 100644
index 00000000..db74c6e2
--- /dev/null
+++ b/bin/man1/psf2png.1
@@ -0,0 +1,53 @@
+.Dd September 28, 2018
+.Dt PSF2PNG 1
+.Os
+.
+.Sh NAME
+.Nm psf2png
+.Nd PSF2 to PNG renderer
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl b Ar bg
+.Op Fl c Ar cols
+.Op Fl f Ar fg
+.Op Fl s Ar str
+.Op Ar file
+.
+.Sh DESCRIPTION
+.Nm
+renders the PSF2 font
+.Ar file
+or standard input
+to PNG
+on standard output.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl b Ar bg
+Use
+.Ar bg
+(hexadecimal RGB)
+as background color.
+The default background color is black.
+.It Fl c Ar cols
+Arrange glyphs in
+.Ar cols
+columns.
+The default number of columns is 32.
+.It Fl f Ar fg
+Use
+.Ar fg
+(hexadecimal RGB)
+as foreground color.
+The default foreground color is white.
+.It Fl s Ar str
+Render glyphs for string
+.Ar str
+rather than all glyphs.
+.El
+.
+.Sh SEE ALSO
+.Xr pngo 1 ,
+.Xr psfed 1
diff --git a/bin/man1/ptee.1 b/bin/man1/ptee.1
new file mode 100644
index 00000000..bb381ecb
--- /dev/null
+++ b/bin/man1/ptee.1
@@ -0,0 +1,51 @@
+.Dd October 18, 2021
+.Dt PTEE 1
+.Os
+.
+.Sh NAME
+.Nm ptee
+.Nd tee for PTYs
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl t Ar ms
+.Ar command ...
+.Cm >
+.Ar file
+.
+.Sh DESCRIPTION
+.Nm
+runs
+.Ar command
+in a new PTY
+which is mirrored to
+the current PTY
+and standard output.
+Standard output must be redirected
+to a file or pipe.
+.
+.Pp
+Type
+.Ic ^S
+to write a media copy sequence
+to standard output.
+Type
+.Ic ^Q
+to toggle writing to standard output.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl t Ar ms
+Write a media copy sequence
+to standard output every
+.Ar ms
+milliseconds.
+.El
+.
+.Sh SEE ALSO
+.Xr script 1
+.
+.Sh BUGS
+Window size changes are not propagated
+to the child PTY.
diff --git a/bin/man1/qf.1 b/bin/man1/qf.1
new file mode 100644
index 00000000..8828d723
--- /dev/null
+++ b/bin/man1/qf.1
@@ -0,0 +1,71 @@
+.Dd June  2, 2022
+.Dt QF 1
+.Os
+.
+.Sh NAME
+.Nm qf
+.Nd grep pager
+.
+.Sh SYNOPSIS
+.Nm Op Ar pattern
+.
+.Sh DESCRIPTION
+.Nm
+is a pager for
+.Xr grep 1 ,
+.Xr ag 1 ,
+.Xr rg 1 ,
+etc.\&
+which allows
+jumping to matches in
+.Ev $EDITOR .
+It parses any input
+prefixed by path
+and line number
+separated by a colon
+.Ql ":"
+followed by either a colon
+or a hyphen
+.Ql "-" .
+It otherwise operates similar to
+.Xr less 1 .
+.
+.Pp
+If
+.Ar pattern
+is given,
+the first match on each line
+will be highlighted.
+The
+.Ar pattern
+is interpreted as
+an extended regular expression
+and is matched case-insensitively
+unless it contains an uppercase letter.
+.
+.Pp
+The keys are as follows:
+.Bl -tag -width Ds
+.It Ic Enter
+Open the currently selected line in
+.Ev $EDITOR .
+When the editor exits,
+.Nm
+resumes.
+.It Ic {}
+Jump between files.
+.It Ic gG
+Jump to first or last line.
+.It Ic jk
+Move to next or previous line.
+.It Ic nN
+Jump to next or previous match line.
+.It Ic q
+Exit.
+.It Ic r
+Refresh the display.
+.El
+.
+.Sh EXAMPLES
+.Dl $ ag -C open | qf
+.Dl $ git grep -n open | qf
diff --git a/bin/man1/quick.1 b/bin/man1/quick.1
new file mode 100644
index 00000000..96f1766a
--- /dev/null
+++ b/bin/man1/quick.1
@@ -0,0 +1,66 @@
+.Dd September 23, 2021
+.Dt QUICK 1
+.Os
+.
+.Sh NAME
+.Nm quick
+.Nd (and dirty) HTTP/CGI server
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl p Ar port
+.Ar script
+.Op Ar args ...
+.
+.Sh DESCRIPTION
+.Nm
+is a barely functional HTTP server
+for running CGI scripts.
+It listens only on localhost,
+on a randomly assigned port,
+unless
+.Fl p
+is used.
+The URL of the server
+is printed to standard output.
+.
+.Sh EXAMPLES
+.Dl quick cgit | xargs -n1 open
+.
+.Sh STANDARDS
+.Nm
+does
+.Em not
+implement the following:
+.Bl -item
+.It
+.Rs
+.%A T. Berners-Lee
+.%A R. Fielding
+.%A H. Frystyk
+.%A J. Gettys
+.%A J. Mogul
+.%T Hypertext Transfer Protocol -- HTTP/1.1
+.%R RFC 2068
+.%U https://tools.ietf.org/html/rfc2068
+.%D January 1997
+.Re
+.It
+.Rs
+.%A K. Coar
+.%A D. Robinson
+.%T The Common Gateway Interface (CGI) Version 1.1
+.%R RFC 3875
+.%U https://tools.ietf.org/html/rfc3875
+.%D October 2004
+.Re
+.El
+.
+.Sh CAVEATS
+Oh, so many.
+No error handling,
+no validation,
+no security.
+This is a local testing tool only.
+.Pp
+Every response is served as a 200 OK.
diff --git a/bin/man1/relay.1 b/bin/man1/relay.1
new file mode 100644
index 00000000..402c4726
--- /dev/null
+++ b/bin/man1/relay.1
@@ -0,0 +1,48 @@
+.Dd April 28, 2019
+.Dt RELAY 1
+.Os
+.
+.Sh NAME
+.Nm relay
+.Nd IRC relay bot
+.
+.Sh SYNOPSIS
+.Nm
+.Ar host
+.Ar port
+.Ar nick
+.Ar chan
+.
+.Sh DESCRIPTION
+.Nm
+is one half of an IRC relay pair.
+It connects to
+.Ar host Ns : Ns Ar port
+over TLS
+as
+.Ar nick
+and joins
+.Ar chan .
+.
+.Pp
+.Nm
+outputs messages from
+.Ar chan
+to standard output
+and sends messages to
+.Ar chan
+from standard input.
+Two
+.Nm
+processes can be connected with
+.Xr mkfifo 1 .
+.
+.Sh EXAMPLES
+.Bd -literal -offset indent
+mkfifo a b
+relay a.example.com 6697 relay '#example' <>a >b
+relay b.example.com 6697 relay '#example' <>b >a
+.Ed
+.
+.Sh SEE ALSO
+.Xr mkfifo 1
diff --git a/bin/man1/scheme.1 b/bin/man1/scheme.1
new file mode 100644
index 00000000..9f72d945
--- /dev/null
+++ b/bin/man1/scheme.1
@@ -0,0 +1,59 @@
+.Dd February  6, 2021
+.Dt SCHEME 1
+.Os
+.
+.Sh NAME
+.Nm scheme
+.Nd color scheme
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl Xacghilmstx
+.Op Fl p Ar n
+.
+.Sh DESCRIPTION
+.Nm
+generates a color scheme
+and outputs it in a number of formats.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl X
+Output Xresources for
+.Xr xterm 1 .
+.It Fl a
+Generate the 16 ANSI colors.
+This is the default.
+.It Fl c
+Output a C enum.
+.It Fl g
+Output a swatch PNG.
+.It Fl h
+Output floating point HSV.
+.It Fl i
+Swap black and white.
+.It Fl l
+Output Linux console OSC sequences.
+.It Fl m
+Output a
+.Xr mintty 1
+theme.
+Use with
+.Fl t .
+.It Fl p Ar n
+Generate only the color
+.Ar n .
+.It Fl s
+Output CSS
+for classes named
+.Sy fg Ns Ar n
+and
+.Sy bg Ns Ar n .
+.It Fl t
+Generate the 16 ANSI colors as well as
+background, foreground, bold, selection and cursor colors.
+.It Fl x
+Output hexadecimal RGB.
+This is the default.
+.El
diff --git a/bin/man1/shotty.1 b/bin/man1/shotty.1
new file mode 100644
index 00000000..0a3bd127
--- /dev/null
+++ b/bin/man1/shotty.1
@@ -0,0 +1,115 @@
+.Dd October 18, 2021
+.Dt SHOTTY 1
+.Os
+.
+.Sh NAME
+.Nm shotty
+.Nd HTML terminal renderer
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl Bdins
+.Op Fl b Ar bg
+.Op Fl f Ar fg
+.Op Fl h Ar rows
+.Op Fl w Ar cols
+.Op Ar file
+.
+.Sh DESCRIPTION
+.Nm
+renders a terminal session
+captured with
+.Xr ptee 1
+or
+.Xr script 1
+from
+.Ar file
+or standard input
+and renders one or more HTML snapshots.
+One snapshot is rendered
+for each media copy sequence,
+or a single snapshot is rendered
+at the end of the session.
+.Nm
+targets compatibility with
+.Ev TERM=xterm
+and
+.Ev TERM=xterm-256color
+as used by
+.Xr ncurses 3 .
+.
+.Pp
+HTML output uses
+.Sy bg Ns Va n
+and
+.Sy fg Ns Va n
+classes for colors,
+and inline styles for
+bold, italic and underline.
+CSS for colors
+can be generated by
+.Xr scheme 1 .
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width "-w cols"
+.It Fl B
+Replace bold with bright colors.
+.
+.It Fl b Ar bg
+Set the default background color.
+The default is 0 (black).
+.
+.It Fl d
+Render a snapshot
+after each control sequence.
+.
+.It Fl f Ar fg
+Set the default foreground color.
+The default is 7 (white).
+.
+.It Fl h Ar rows
+Set the terminal height.
+The default is 24.
+.
+.It Fl i
+Output inline color attributes.
+.
+.It Fl n
+Hide the cursor.
+.
+.It Fl s
+Copy the terminal size
+from the current terminal.
+.
+.It Fl w Ar cols
+Set the terminal width.
+The default is 80.
+.El
+.
+.Sh EXAMPLES
+.Dl $ ptee htop | shotty -Bis >htop.html
+.
+.Sh SEE ALSO
+.Xr ptee 1 ,
+.Xr script 1
+.
+.Sh STANDARDS
+.Bl -item
+.It
+.Rs
+.%A Thomas Dickey
+.%A Stephen Gildea
+.%A Edward Moy
+.%T XTerm Control Sequences
+.%U https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
+.Re
+.It
+.Rs
+.%A F. Yergeau
+.%T UTF-8
+.%R RFC 2044
+.%U https://tools.ietf.org/html/rfc2044
+.%D October 1996
+.Re
+.El
diff --git a/bin/man1/sup.1 b/bin/man1/sup.1
new file mode 100644
index 00000000..bd88ad47
--- /dev/null
+++ b/bin/man1/sup.1
@@ -0,0 +1,51 @@
+.Dd January 12, 2022
+.Dt SUP 1
+.Os
+.
+.Sh NAME
+.Nm sup
+.Nd single-use password
+.
+.Sh SYNOPSIS
+.Nm
+.Ar service
+.Op Ar email
+.
+.Sh DESCRIPTION
+The
+.Nm
+utility
+sets a random single-use password
+for a service using the
+.Dq forgot password
+or
+.Dq password reset
+flow.
+The password is copied to the clipboard
+and the service login page is opened.
+For passwordless services
+with email-based authentication,
+the emailed login link is opened.
+.
+.Pp
+The following services are supported:
+.Cm asciinema ,
+.Cm discogs ,
+.Cm freebsdbugzilla ,
+.Cm liberapay ,
+.Cm lobsters ,
+.Cm lwn ,
+.Cm patreon ,
+.Cm tildegit ,
+.Cm tildenews .
+.
+.Pp
+The
+.Nm
+utility requires
+.Xr curl 1 ,
+.Xr git-fetch-email 1 ,
+.Xr openssl 1 ,
+.Xr pbcopy 1
+and
+.Xr open 1 .
diff --git a/bin/man1/title.1 b/bin/man1/title.1
new file mode 100644
index 00000000..43ecc5e2
--- /dev/null
+++ b/bin/man1/title.1
@@ -0,0 +1,51 @@
+.Dd September 10, 2019
+.Dt TITLE 1
+.Os
+.
+.Sh NAME
+.Nm title
+.Nd page titles
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl v
+.Op Fl x Ar pattern
+.Op Ar url
+.
+.Sh DESCRIPTION
+.Nm
+fetches HTML page titles
+over HTTP and HTTPS.
+.Nm
+scans standard input for URLs
+and writes their titles to standard output.
+If a
+.Ar url
+argument is given,
+.Nm
+exits after fetching its title.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl x Ar pattern
+Exclude URLs matching
+.Ar pattern ,
+which is a modern regular expression.
+See
+.Xr re_format 7 .
+.It Fl v
+Enable
+.Xr libcurl 3
+verbose output.
+.El
+.
+.Sh EXAMPLES
+.Bd -literal -offset indent
+mkfifo snarf titles
+relay irc.example.org 6697 snarf '#example' <>titles >snarf
+title <snarf >titles
+.Ed
+.
+.Sh SEE ALSO
+.Xr relay 1
diff --git a/bin/man1/up.1 b/bin/man1/up.1
new file mode 100644
index 00000000..aece79bd
--- /dev/null
+++ b/bin/man1/up.1
@@ -0,0 +1,77 @@
+.Dd July 26, 2022
+.Dt UP 1
+.Os
+.
+.Sh NAME
+.Nm up
+.Nd upload file
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl c | h | s | t
+.Op Fl w Ar warn
+.Op Ar file | command
+.
+.Sh DESCRIPTION
+.Nm
+uploads a file
+to temp.causal.agency with
+.Xr scp 1 .
+If no
+.Ar file
+is provided,
+standard input is read
+and uploaded as text.
+.
+.Pp
+The destination file name
+is chosen using
+.Xr date 1
+and
+.Xr openssl 1
+.Cm rand .
+The URL of the uploaded file is printed
+and copied to the pasteboard with
+.Xr pbcopy 1
+if available.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl c
+Run a command
+to produce a text file for upload.
+.It Fl h
+Use
+.Xr hilex 1
+to produce an HTML file for upload.
+.It Fl s
+Use
+.Xr screencapture 1
+or
+.Xr scrot 1
+to produce a PNG file for upload.
+The file is optimized by
+.Xr pngo 1 .
+.It Fl t
+Run a command with
+.Xr ptee 1
+and
+.Xr shotty 1
+to produce an HTML file for upload.
+.It Fl w Ar warn
+Create an HTML redirect with
+.Ar warn
+in its title.
+.El
+.
+.Pp
+Any arguments after
+.Ql \-\-
+are passed to
+.Xr hilex 1
+and
+.Xr screencapture 1
+or
+.Xr scrot 1 ,
+respectively.
diff --git a/bin/man1/when.1 b/bin/man1/when.1
new file mode 100644
index 00000000..3f2735f7
--- /dev/null
+++ b/bin/man1/when.1
@@ -0,0 +1,100 @@
+.Dd September 19, 2022
+.Dt WHEN 1
+.Os
+.
+.Sh NAME
+.Nm when
+.Nd date calculator
+.
+.Sh SYNOPSIS
+.Nm
+.Op Ar expr
+.Nm
+.Cm -
+.
+.Sh DESCRIPTION
+.Nm
+is a date calculator.
+If no
+.Ar expr
+is given,
+expressions are read
+from standard input.
+If
+.Cm -
+is given,
+the intervals between each named date
+and today are printed.
+.
+.Pp
+The grammar is as follows:
+.Bl -tag -width Ds
+.It Sy \&.
+Today's date.
+The empty expression is equivalent.
+.
+.It Ar name Op Sy = Ar date
+A named date.
+Names are alphanumeric including underscores.
+.
+.It Ar month Ar date Op Ar year
+A full date,
+or a date in the current year.
+Months can be abbreviated to three letters.
+.
+.It Ar day
+A day of the week
+in the current week.
+Days can be abbreviated to three letters.
+.
+.It Sy < Ar date
+The date one week before.
+.
+.It Sy > Ar date
+The date one week after.
+.
+.It Ar date Sy + Ar interval
+The date after some interval.
+.
+.It Ar date Sy - Ar interval
+The date before some interval.
+.
+.It Ar date Sy - Ar date
+The interval between two dates.
+.
+.It Ar num Sy d
+A number of days.
+.
+.It Ar num Sy w
+A number of weeks.
+.
+.It Ar num Sy m
+A number of months.
+.
+.It Ar num Sy y
+A number of years.
+.El
+.
+.Sh FILES
+The file
+.Pa $XDG_CONFIG_HOME/when/dates
+or
+.Pa ~/.config/when/dates
+is read before any other expressions,
+if it exists.
+.
+.Sh EXAMPLES
+.Bl -tag -width "Dec 25 - ."
+.It Ic Dec 25 - \&.
+How long until Christmas.
+.It Ic >Fri
+The date next Friday.
+.It Ic \&. + 2w
+Your last day at work.
+.El
+.Pp
+Checking a milestone:
+.Bd -literal -offset indent
+$ echo 'hrt = oct 15 2021' >> ~/.config/when/dates
+$ when -hrt
+.Ed
diff --git a/bin/man1/xx.1 b/bin/man1/xx.1
new file mode 100644
index 00000000..d38789a7
--- /dev/null
+++ b/bin/man1/xx.1
@@ -0,0 +1,68 @@
+.Dd September 7, 2018
+.Dt XX 1
+.Os
+.
+.Sh NAME
+.Nm xx
+.Nd hexdump
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl arsz
+.Op Fl c Ar cols
+.Op Fl g Ar group
+.Op Fl p Ar count
+.Op Ar file
+.
+.Sh DESCRIPTION
+.Nm
+dumps the contents of a
+.Ar file
+or standard input
+in hexadecimal format.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl a
+Toggle ASCII output.
+.
+.It Fl c Ar cols
+Output
+.Ar cols
+bytes per line.
+The default
+.Ar cols
+is 16.
+.
+.It Fl g Ar group
+Output extra space after every
+.Ar group
+bytes.
+The default
+.Ar group
+is 8.
+.
+.It Fl p Ar count
+Output a blank line after every
+.Ar count
+bytes.
+.Ar count
+must be a multiple of
+.Ar cols .
+.
+.It Fl r
+Reverse hexdump.
+Read hexadecimal input
+and write byte output.
+.
+.It Fl s
+Toggle offset output.
+.
+.It Fl z
+Skip output of lines containing only zeros.
+.El
+.
+.Sh SEE ALSO
+.Xr hexdump 1 ,
+.Xr xxd 1
diff --git a/bin/man3/png.3 b/bin/man3/png.3
new file mode 100644
index 00000000..accffbd7
--- /dev/null
+++ b/bin/man3/png.3
@@ -0,0 +1,90 @@
+.Dd July 25, 2019
+.Dt PNG 3
+.Os
+.
+.Sh NAME
+.Nm png
+.Nd basic PNG output
+.
+.Sh SYNOPSIS
+.In png.h
+.
+.Ft void
+.Fo pngHead
+.Fa "FILE *file"
+.Fa "uint32_t width"
+.Fa "uint32_t height"
+.Fa "uint8_t depth"
+.Fa "uint8_t color"
+.Fc
+.
+.Ft void
+.Fn pngPalette "FILE *file" "const uint8_t *pal" "uint32_t len"
+.
+.Ft void
+.Fn pngData "FILE *file" "const uint8_t *data" "uint32_t len"
+.
+.Ft void
+.Fn pngTail "FILE *file"
+.
+.Sh DESCRIPTION
+The
+.Fn pngHead
+function
+writes the
+.Sy IHDR
+chunk to
+.Fa file .
+The
+.Fa color
+parameter can be one of
+.Dv PNGGrayscale ,
+.Dv PNGTruecolor
+optionally
+.Em or Ns 'ed
+with
+.Dv PNGAlpha ,
+or
+.Dv PNGIndexed .
+.
+.Pp
+The
+.Fn pngPalette
+function
+writes the
+.Sy PLTE
+chunk to
+.Fa file .
+.
+.Pp
+The
+.Fn pngData
+function
+writes the
+.Sy IDAT
+chunk to
+.Fa file
+without compression.
+The constants
+.Dv PNGNone ,
+.Dv PNGSub ,
+.Dv PNGUp ,
+.Dv PNGAverage ,
+.Dv PNGPaeth
+are defined
+for use in PNG data.
+.
+.Pp
+The
+.Fn pngTail
+function
+writes the
+.Sy IEND
+chunk to
+.Fa file .
+.
+.Sh ERRORS
+Any errors from writing to
+.Fa file
+are handled by calling
+.Xr err 3 .
diff --git a/bin/man6/freecell.6 b/bin/man6/freecell.6
new file mode 100644
index 00000000..0e485a16
--- /dev/null
+++ b/bin/man6/freecell.6
@@ -0,0 +1,50 @@
+.Dd April 17, 2021
+.Dt FREECELL 6
+.Os
+.
+.Sh NAME
+.Nm freecell
+.Nd patience game
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl d Ar delay
+.Op Fl n Ar game
+.
+.Sh DESCRIPTION
+.Nm
+is a terminal FreeCell patience game.
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl d Ar delay
+Set the delay in milliseconds
+between queued moves.
+The default is 50.
+.It Fl n Ar game
+Select the game number to play.
+.El
+.
+.Pp
+Moves are performed
+by typing a sequence of two keys.
+To automatically move a card
+to a free cell,
+type the same key twice.
+The keys are as follows:
+.Bl -tag -width Ds
+.It Ic Escape
+Cancel a pending move.
+.It Ic u , Backspace
+Undo the previous move.
+.It Ic 1 , 2 , 3 , 4
+Select the cells.
+.It Ic q , w , e , r , a , s , d , f
+Select the tableau cascades.
+.It Ic _ , Space
+Manually move
+the selected card
+to the foundations.
+.It Ic Shift
+Move a single card
+to an empty tableau cascade.
+.El
diff --git a/bin/mdoc.l b/bin/mdoc.l
new file mode 100644
index 00000000..b6deacbe
--- /dev/null
+++ b/bin/mdoc.l
@@ -0,0 +1,60 @@
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+%option prefix="mdoc"
+%option noinput nounput noyywrap
+
+%{
+#include "hilex.h"
+%}
+
+%s MacroLine
+
+%%
+
+[[:blank:]]+ { return Normal; }
+
+^"." {
+	BEGIN(MacroLine);
+	return Keyword;
+}
+
+^".\\\"".* { return Comment; }
+
+<MacroLine>{
+	"\n" {
+		BEGIN(0);
+		return Normal;
+	}
+
+	%[ABCDIJNOPQRTUV]|A[cdnopqrt]|B[cdfkloqtx]|Br[coq]|Bsx|C[dm]|D[1bcdloqtvx] |
+	E[cdfklmnorsvx]|F[acdlnortx]|Hf|I[cnt]|L[bikp]|M[st]|N[dmosx]|O[copstx] |
+	P[acfopq]|Q[cloq]|R[esv]|S[chmoqstxy]|T[an]|U[dx]|V[at]|X[cor] {
+		return Keyword;
+	}
+
+	"\""([^""]|"\\\"")*"\"" { return String; }
+}
+
+"\\"(.|"("..|"["[^]]*"]") { return String; }
+
+[^.\\""[:space:]]+ { return Normal; }
+
+.|\n { return Normal; }
+
+%%
+
+const struct Lexer LexMdoc = { yylex, &yyin, &yytext };
diff --git a/bin/modem.c b/bin/modem.c
new file mode 100644
index 00000000..4392e071
--- /dev/null
+++ b/bin/modem.c
@@ -0,0 +1,102 @@
+/* Copyright (C) 2018  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <err.h>
+#include <poll.h>
+#include <stdlib.h>
+#include <sys/ioctl.h>
+#include <sys/wait.h>
+#include <sysexits.h>
+#include <termios.h>
+#include <unistd.h>
+
+#if defined __FreeBSD__
+#include <libutil.h>
+#elif defined __linux__
+#include <pty.h>
+#else
+#include <util.h>
+#endif
+
+typedef unsigned uint;
+typedef unsigned char byte;
+
+static struct termios saveTerm;
+static void restoreTerm(void) {
+	tcsetattr(STDIN_FILENO, TCSADRAIN, &saveTerm);
+}
+
+int main(int argc, char *argv[]) {
+	int error;
+
+	uint baudRate = 19200;
+	for (int opt; 0 < (opt = getopt(argc, argv, "r:"));) {
+		switch (opt) {
+			break; case 'r': baudRate = strtoul(optarg, NULL, 10);
+			break; default:  return EX_USAGE;
+		}
+	}
+	if (argc - optind < 1) return EX_USAGE;
+
+	error = tcgetattr(STDIN_FILENO, &saveTerm);
+	if (error) err(EX_IOERR, "tcgetattr");
+	atexit(restoreTerm);
+
+	struct termios raw = saveTerm;
+	cfmakeraw(&raw);
+	error = tcsetattr(STDIN_FILENO, TCSADRAIN, &raw);
+	if (error) err(EX_IOERR, "tcsetattr");
+
+	struct winsize window;
+	error = ioctl(STDIN_FILENO, TIOCGWINSZ, &window);
+	if (error) err(EX_IOERR, "TIOCGWINSZ");
+
+	int pty;
+	pid_t pid = forkpty(&pty, NULL, NULL, &window);
+	if (pid < 0) err(EX_OSERR, "forkpty");
+
+	if (!pid) {
+		execvp(argv[optind], &argv[optind]);
+		err(EX_NOINPUT, "%s", argv[optind]);
+	}
+
+	byte c;
+	struct pollfd fds[2] = {
+		{ .events = POLLIN, .fd = STDIN_FILENO },
+		{ .events = POLLIN, .fd = pty },
+	};
+	while (usleep(8 * 1000000 / baudRate), 0 < poll(fds, 2, -1)) {
+		if (fds[0].revents) {
+			ssize_t size = read(STDIN_FILENO, &c, 1);
+			if (size < 0) err(EX_IOERR, "read(%d)", STDIN_FILENO);
+			size = write(pty, &c, 1);
+			if (size < 0) err(EX_IOERR, "write(%d)", pty);
+		}
+
+		if (fds[1].revents) {
+			ssize_t size = read(pty, &c, 1);
+			if (size < 0) err(EX_IOERR, "read(%d)", pty);
+			if (!size) break;
+			size = write(STDOUT_FILENO, &c, 1);
+			if (size < 0) err(EX_IOERR, "write(%d)", STDOUT_FILENO);
+		}
+	}
+
+	int status;
+	pid_t dead = waitpid(pid, &status, 0);
+	if (dead < 0) err(EX_OSERR, "waitpid");
+	return WIFEXITED(status) ? WEXITSTATUS(status) : EX_SOFTWARE;
+}
diff --git a/bin/mtags.c b/bin/mtags.c
new file mode 100644
index 00000000..5c1a057e
--- /dev/null
+++ b/bin/mtags.c
@@ -0,0 +1,105 @@
+/* Copyright (C) 2021  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <assert.h>
+#include <err.h>
+#include <regex.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <unistd.h>
+
+static void escape(FILE *file, const char *str, size_t len) {
+	for (size_t i = 0; i < len; ++i) {
+		if (str[i] == '\\' || str[i] == '/') {
+			putc('\\', file);
+		}
+		putc(str[i], file);
+	}
+}
+
+int main(int argc, char *argv[]) {
+	int error;
+	bool append = false;
+	const char *path = "tags";
+	for (int opt; 0 < (opt = getopt(argc, argv, "af:"));) {
+		switch (opt) {
+			break; case 'a': append = true;
+			break; case 'f': path = optarg;
+			break; default:  return EX_USAGE;
+		}
+	}
+
+	FILE *tags = fopen(path, (append ? "a" : "w"));
+	if (!tags) err(EX_CANTCREAT, "%s", path);
+
+#ifdef __OpenBSD__
+	error = pledge("stdio rpath", NULL);
+	if (error) err(EX_OSERR, "pledge");
+#endif
+
+	regex_t makeFile, makeLine;
+	regex_t mdocFile, mdocLine;
+	regex_t shFile, shLine;
+	error = 0
+		|| regcomp(&makeFile, "(^|/)Makefile|[.]mk$", REG_EXTENDED | REG_NOSUB)
+		|| regcomp(
+			&makeLine,
+			"^([.][^:$A-Z][^:$[:space:]]*|[^.:$][^:$[:space:]]*):",
+			REG_EXTENDED
+		)
+		|| regcomp(&mdocFile, "[.][1-9]$", REG_EXTENDED | REG_NOSUB)
+		|| regcomp(&mdocLine, "^[.]S[hs] ([^\t\n]+)", REG_EXTENDED)
+		|| regcomp(
+			&shFile, "(^|/)[.](profile|shrc)|[.]sh$", REG_EXTENDED | REG_NOSUB
+		)
+		|| regcomp(&shLine, "^([_[:alnum:]]+)[[:blank:]]*[(][)]", REG_EXTENDED);
+	assert(!error);
+
+	size_t cap = 0;
+	char *buf = NULL;
+	for (int i = optind; i < argc; ++i) {
+		const regex_t *regex;
+		if (!regexec(&makeFile, argv[i], 0, NULL, 0)) {
+			regex = &makeLine;
+		} else if (!regexec(&mdocFile, argv[i], 0, NULL, 0)) {
+			regex = &mdocLine;
+		} else if (!regexec(&shFile, argv[i], 0, NULL, 0)) {
+			regex = &shLine;
+		} else {
+			warnx("skipping unknown file type %s", argv[i]);
+			continue;
+		}
+
+		FILE *file = fopen(argv[i], "r");
+		if (!file) err(EX_NOINPUT, "%s", argv[i]);
+
+		while (0 < getline(&buf, &cap, file)) {
+			regmatch_t match[2];
+			if (regexec(regex, buf, 2, match, 0)) continue;
+			fprintf(
+				tags, "%.*s\t%s\t/^",
+				(int)(match[1].rm_eo - match[1].rm_so), &buf[match[1].rm_so],
+				argv[i]
+			);
+			escape(tags, buf, match[0].rm_eo);
+			fprintf(tags, "/\n");
+		}
+		fclose(file);
+	}
+}
diff --git a/bin/nudge.c b/bin/nudge.c
new file mode 100644
index 00000000..8ae916eb
--- /dev/null
+++ b/bin/nudge.c
@@ -0,0 +1,78 @@
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <err.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <sysexits.h>
+#include <termios.h>
+#include <unistd.h>
+
+static int shake = 10;
+static int delay = 20000;
+static int count = 2;
+
+static void move(int tty, int x, int y) {
+	dprintf(tty, "\33[3;%d;%dt", x, y);
+	usleep(delay);
+}
+
+int main(int argc, char *argv[]) {
+	const char *path = "/dev/tty";
+	for (int opt; 0 < (opt = getopt(argc, argv, "f:n:s:t:"));) {
+		switch (opt) {
+			break; case 'f': path = optarg;
+			break; case 'n': count = atoi(optarg);
+			break; case 's': shake = atoi(optarg);
+			break; case 't': delay = atoi(optarg) * 1000;
+			break; default:  return EX_USAGE;
+		}
+	}
+
+	int tty = open(path, O_RDWR);
+	if (tty < 0) err(EX_OSFILE, "%s", path);
+
+	struct termios save;
+	int error = tcgetattr(tty, &save);
+	if (error) err(EX_IOERR, "tcgetattr");
+
+	struct termios raw = save;
+	cfmakeraw(&raw);
+	error = tcsetattr(tty, TCSAFLUSH, &raw);
+	if (error) err(EX_IOERR, "tcsetattr");
+
+	char buf[256];
+	dprintf(tty, "\33[13t");
+	ssize_t len = read(tty, buf, sizeof(buf) - 1);
+	buf[(len < 0 ? 0 : len)] = '\0';
+
+	int x, y;
+	int n = sscanf(buf, "\33[3;%d;%dt", &x, &y);
+
+	error = tcsetattr(tty, TCSANOW, &save);
+	if (error) err(EX_IOERR, "tcsetattr");
+	if (n < 2) return EX_CONFIG;
+
+	dprintf(tty, "\33[5t");
+	for (int i = 0; i < count; ++i) {
+		move(tty, x - shake, y);
+		move(tty, x, y + shake);
+		move(tty, x + shake, y);
+		move(tty, x, y - shake);
+		move(tty, x, y);
+	}
+}
diff --git a/bin/order.y b/bin/order.y
new file mode 100644
index 00000000..b3cbf2df
--- /dev/null
+++ b/bin/order.y
@@ -0,0 +1,195 @@
+/* Copyright (C) 2019  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+%{
+
+#include <ctype.h>
+#include <err.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+
+#define YYSTYPE char *
+
+static char *fmt(const char *format, ...) {
+	char *str = NULL;
+	va_list ap;
+	va_start(ap, format);
+	vasprintf(&str, format, ap);
+	va_end(ap);
+	if (!str) err(EX_OSERR, "vasprintf");
+	return str;
+}
+
+static int yylex(void);
+static void yyerror(const char *str);
+
+%}
+
+%token Ident
+
+%left ','
+%right '=' MulAss DivAss ModAss AddAss SubAss ShlAss ShrAss AndAss XorAss OrAss
+%right '?' ':'
+%left Or
+%left And
+%left '|'
+%left '^'
+%left '&'
+%left Eq Ne
+%left '<' Le '>' Ge
+%left Shl Shr
+%left '+' '-'
+%left '*' '/' '%'
+%right '!' '~' Inc Dec Sizeof
+%left '(' ')' '[' ']' Arr '.'
+
+%%
+
+start:
+	expr { printf("%s\n", $1); }
+	;
+
+expr:
+	Ident
+	| '(' expr ')' { $$ = $2; }
+	| expr '[' expr ']' { $$ = fmt("(%s[%s])", $1, $3); }
+	| expr Arr Ident { $$ = fmt("(%s->%s)", $1, $3); }
+	| expr '.' Ident { $$ = fmt("(%s.%s)", $1, $3); }
+	| '!' expr { $$ = fmt("(!%s)", $2); }
+	| '~' expr { $$ = fmt("(~%s)", $2); }
+	| Inc expr { $$ = fmt("(++%s)", $2); }
+	| Dec expr { $$ = fmt("(--%s)", $2); }
+	| expr Inc { $$ = fmt("(%s++)", $1); }
+	| expr Dec { $$ = fmt("(%s--)", $1); }
+	| '+' expr %prec '!' { $$ = fmt("(+%s)", $2); }
+	| '-' expr %prec '!' { $$ = fmt("(-%s)", $2); }
+	| '*' expr %prec '!' { $$ = fmt("(*%s)", $2); }
+	| '&' expr %prec '!' { $$ = fmt("(&%s)", $2); }
+	| Sizeof expr { $$ = fmt("(sizeof %s)", $2); }
+	| expr '*' expr { $$ = fmt("(%s * %s)", $1, $3); }
+	| expr '/' expr { $$ = fmt("(%s / %s)", $1, $3); }
+	| expr '%' expr { $$ = fmt("(%s %% %s)", $1, $3); }
+	| expr '+' expr { $$ = fmt("(%s + %s)", $1, $3); }
+	| expr '-' expr { $$ = fmt("(%s - %s)", $1, $3); }
+	| expr Shl expr { $$ = fmt("(%s << %s)", $1, $3); }
+	| expr Shr expr { $$ = fmt("(%s >> %s)", $1, $3); }
+	| expr '<' expr { $$ = fmt("(%s < %s)", $1, $3); }
+	| expr Le expr { $$ = fmt("(%s <= %s)", $1, $3); }
+	| expr '>' expr { $$ = fmt("(%s > %s)", $1, $3); }
+	| expr Ge expr { $$ = fmt("(%s >= %s)", $1, $3); }
+	| expr Eq expr { $$ = fmt("(%s == %s)", $1, $3); }
+	| expr Ne expr { $$ = fmt("(%s != %s)", $1, $3); }
+	| expr '&' expr { $$ = fmt("(%s & %s)", $1, $3); }
+	| expr '^' expr { $$ = fmt("(%s ^ %s)", $1, $3); }
+	| expr '|' expr { $$ = fmt("(%s | %s)", $1, $3); }
+	| expr And expr { $$ = fmt("(%s && %s)", $1, $3); }
+	| expr Or expr { $$ = fmt("(%s || %s)", $1, $3); }
+	| expr '?' expr ':' expr { $$ = fmt("(%s ? %s : %s)", $1, $3, $5); }
+	| expr ass expr %prec '=' { $$ = fmt("(%s %s %s)", $1, $2, $3); }
+	| expr ',' expr { $$ = fmt("(%s, %s)", $1, $3); }
+	;
+
+ass:
+	'=' { $$ = "="; }
+	| MulAss { $$ = "*="; }
+	| DivAss { $$ = "/="; }
+	| ModAss { $$ = "%="; }
+	| AddAss { $$ = "+="; }
+	| SubAss { $$ = "-="; }
+	| ShlAss { $$ = "<<="; }
+	| ShrAss { $$ = ">>="; }
+	| AndAss { $$ = "&="; }
+	| XorAss { $$ = "^="; }
+	| OrAss { $$ = "|="; }
+	;
+
+%%
+
+#define T(a, b) ((int)(a) << 8 | (int)(b))
+
+static FILE *in;
+
+static int yylex(void) {
+	char ch;
+	while (isspace(ch = getc(in)));
+
+	if (isalnum(ch)) {
+		char ident[64] = { ch, '\0' };
+		for (size_t i = 1; i < sizeof(ident) - 1; ++i) {
+			ch = getc(in);
+			if (!isalnum(ch) && ch != '_') break;
+			ident[i] = ch;
+		}
+		ungetc(ch, in);
+		if (!strcmp(ident, "sizeof")) return Sizeof;
+		yylval = fmt("%s", ident);
+		return Ident;
+	}
+
+	char ne = getc(in);
+	switch (T(ch, ne)) {
+		case T('-', '>'): return Arr;
+		case T('+', '+'): return Inc;
+		case T('-', '-'): return Dec;
+		case T('<', '='): return Le;
+		case T('>', '='): return Ge;
+		case T('=', '='): return Eq;
+		case T('!', '='): return Ne;
+		case T('&', '&'): return And;
+		case T('|', '|'): return Or;
+		case T('*', '='): return MulAss;
+		case T('/', '='): return DivAss;
+		case T('%', '='): return ModAss;
+		case T('+', '='): return AddAss;
+		case T('-', '='): return SubAss;
+		case T('&', '='): return AndAss;
+		case T('^', '='): return XorAss;
+		case T('|', '='): return OrAss;
+		case T('<', '<'): {
+			if ('=' == (ne = getc(in))) return ShlAss;
+			ungetc(ne, in);
+			return Shl;
+		}
+		case T('>', '>'): {
+			if ('=' == (ne = getc(in))) return ShrAss;
+			ungetc(ne, in);
+			return Shr;
+		}
+		default: {
+			ungetc(ne, in);
+			return ch;
+		}
+	}
+}
+
+static void yyerror(const char *str) {
+	errx(EX_DATAERR, "%s", str);
+}
+
+int main(int argc, char *argv[]) {
+	for (int i = 1; i < argc; ++i) {
+		in = fmemopen(argv[i], strlen(argv[i]), "r");
+		if (!in) err(EX_OSERR, "fmemopen");
+		yyparse();
+		fclose(in);
+	}
+	if (argc > 1) return EX_OK;
+	in = stdin;
+	yyparse();
+}
diff --git a/bin/pbd.c b/bin/pbd.c
new file mode 100644
index 00000000..9f47b63e
--- /dev/null
+++ b/bin/pbd.c
@@ -0,0 +1,151 @@
+/* Copyright (C) 2017  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <arpa/inet.h>
+#include <err.h>
+#include <fcntl.h>
+#include <netinet/in.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/wait.h>
+#include <sysexits.h>
+#include <unistd.h>
+
+typedef unsigned char byte;
+
+static void spawn(const char *cmd, const char *arg, int dest, int src) {
+	pid_t pid = fork();
+	if (pid < 0) err(EX_OSERR, "fork");
+
+	if (pid) {
+		int status;
+		pid_t dead = waitpid(pid, &status, 0);
+		if (dead < 0) err(EX_OSERR, "waitpid(%d)", pid);
+		if (status) warnx("%s: status %d", cmd, status);
+
+	} else {
+		int fd = dup2(src, dest);
+		if (fd < 0) err(EX_OSERR, "dup2");
+
+		execlp(cmd, cmd, arg, NULL);
+		err(EX_UNAVAILABLE, "%s", cmd);
+	}
+}
+
+static int pbd(void) {
+	int error;
+
+	int server = socket(PF_INET, SOCK_STREAM, 0);
+	if (server < 0) err(EX_OSERR, "socket");
+
+	error = fcntl(server, F_SETFD, FD_CLOEXEC);
+	if (error) err(EX_IOERR, "fcntl");
+
+	struct sockaddr_in addr = {
+		.sin_family = AF_INET,
+		.sin_port = htons(7062),
+		.sin_addr = { .s_addr = htonl(0x7F000001) },
+	};
+	error = bind(server, (struct sockaddr *)&addr, sizeof(addr));
+	if (error) err(EX_UNAVAILABLE, "bind");
+
+	error = listen(server, 0);
+	if (error) err(EX_UNAVAILABLE, "listen");
+
+	for (;;) {
+		int client = accept(server, NULL, NULL);
+		if (client < 0) err(EX_IOERR, "accept");
+
+		error = fcntl(client, F_SETFD, FD_CLOEXEC);
+		if (error) err(EX_IOERR, "fcntl");
+
+		char c = 0;
+		ssize_t size = read(client, &c, 1);
+		if (size < 0) warn("read");
+
+		switch (c) {
+			break; case 'p': spawn("pbpaste", NULL, STDOUT_FILENO, client);
+			break; case 'c': spawn("pbcopy", NULL, STDIN_FILENO, client);
+			break; case 'o': spawn("xargs", "open", STDIN_FILENO, client);
+		}
+
+		close(client);
+	}
+}
+
+static int pbdClient(char c) {
+	int client = socket(PF_INET, SOCK_STREAM, 0);
+	if (client < 0) err(EX_OSERR, "socket");
+
+	struct sockaddr_in addr = {
+		.sin_family = AF_INET,
+		.sin_port = htons(7062),
+		.sin_addr = { .s_addr = htonl(0x7F000001) },
+	};
+	int error = connect(client, (struct sockaddr *)&addr, sizeof(addr));
+	if (error) err(EX_UNAVAILABLE, "connect");
+
+	ssize_t size = write(client, &c, 1);
+	if (size < 0) err(EX_IOERR, "write");
+
+	return client;
+}
+
+static void copy(int out, int in) {
+	byte buf[4096];
+	ssize_t readSize;
+	while (0 < (readSize = read(in, buf, sizeof(buf)))) {
+		ssize_t writeSize = write(out, buf, readSize);
+		if (writeSize < 0) err(EX_IOERR, "write(%d)", out);
+	}
+	if (readSize < 0) err(EX_IOERR, "read(%d)", in);
+}
+
+static int pbcopy(void) {
+	int client = pbdClient('c');
+	copy(client, STDIN_FILENO);
+	return EX_OK;
+}
+
+static int pbpaste(void) {
+	int client = pbdClient('p');
+	copy(STDOUT_FILENO, client);
+	return EX_OK;
+}
+
+static int open1(const char *url) {
+	if (!url) return EX_USAGE;
+	int client = pbdClient('o');
+	ssize_t size = write(client, url, strlen(url));
+	if (size < 0) err(EX_IOERR, "write");
+	return EX_OK;
+}
+
+int main(int argc, char *argv[]) {
+	for (int opt; 0 < (opt = getopt(argc, argv, "co:ps"));) {
+		switch (opt) {
+			case 'c': return pbcopy();
+			case 'o': return open1(optarg);
+			case 'p': return pbpaste();
+			case 's': return pbd();
+			default:  return EX_USAGE;
+		}
+	}
+	return pbd();
+}
diff --git a/bin/png.h b/bin/png.h
new file mode 100644
index 00000000..0df4699b
--- /dev/null
+++ b/bin/png.h
@@ -0,0 +1,108 @@
+/* Copyright (C) 2018  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <err.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <sysexits.h>
+
+static inline uint32_t pngCRCTable(uint8_t n) {
+	static uint32_t table[256];
+	if (table[1]) return table[n];
+	for (int i = 0; i < 256; ++i) {
+		table[i] = i;
+		for (int j = 0; j < 8; ++j) {
+			table[i] = (table[i] >> 1) ^ (table[i] & 1 ? 0xEDB88320 : 0);
+		}
+	}
+	return table[n];
+}
+
+static uint32_t pngCRC;
+
+static inline void pngWrite(FILE *file, const uint8_t *ptr, uint32_t len) {
+	if (!fwrite(ptr, len, 1, file)) err(EX_IOERR, "pngWrite");
+	for (uint32_t i = 0; i < len; ++i) {
+		pngCRC = pngCRCTable(pngCRC ^ ptr[i]) ^ (pngCRC >> 8);
+	}
+}
+static inline void pngInt32(FILE *file, uint32_t n) {
+	pngWrite(file, (uint8_t []) { n >> 24, n >> 16, n >> 8, n }, 4);
+}
+static inline void pngChunk(FILE *file, char type[static 4], uint32_t len) {
+	pngInt32(file, len);
+	pngCRC = ~0;
+	pngWrite(file, (uint8_t *)type, 4);
+}
+
+enum {
+	PNGGrayscale,
+	PNGTruecolor = 2,
+	PNGIndexed,
+	PNGAlpha,
+};
+
+static inline void pngHead(
+	FILE *file, uint32_t width, uint32_t height, uint8_t depth, uint8_t color
+) {
+	pngWrite(file, (uint8_t *)"\x89PNG\r\n\x1A\n", 8);
+	pngChunk(file, "IHDR", 13);
+	pngInt32(file, width);
+	pngInt32(file, height);
+	pngWrite(file, &depth, 1);
+	pngWrite(file, &color, 1);
+	pngWrite(file, (uint8_t []) { 0, 0, 0 }, 3);
+	pngInt32(file, ~pngCRC);
+}
+
+static inline void pngPalette(FILE *file, const uint8_t *pal, uint32_t len) {
+	pngChunk(file, "PLTE", len);
+	pngWrite(file, pal, len);
+	pngInt32(file, ~pngCRC);
+}
+
+enum {
+	PNGNone,
+	PNGSub,
+	PNGUp,
+	PNGAverage,
+	PNGPaeth,
+};
+
+static inline void pngData(FILE *file, const uint8_t *data, uint32_t len) {
+	uint32_t adler1 = 1, adler2 = 0;
+	for (uint32_t i = 0; i < len; ++i) {
+		adler1 = (adler1 + data[i]) % 65521;
+		adler2 = (adler1 + adler2) % 65521;
+	}
+	uint32_t zlen = 2 + 5 * ((len + 0xFFFE) / 0xFFFF) + len + 4;
+	pngChunk(file, "IDAT", zlen);
+	pngWrite(file, (uint8_t []) { 0x08, 0x1D }, 2);
+	for (; len > 0xFFFF; data += 0xFFFF, len -= 0xFFFF) {
+		pngWrite(file, (uint8_t []) { 0x00, 0xFF, 0xFF, 0x00, 0x00 }, 5);
+		pngWrite(file, data, 0xFFFF);
+	}
+	pngWrite(file, (uint8_t []) { 0x01, len, len >> 8, ~len, ~len >> 8 }, 5);
+	pngWrite(file, data, len);
+	pngInt32(file, adler2 << 16 | adler1);
+	pngInt32(file, ~pngCRC);
+}
+
+static inline void pngTail(FILE *file) {
+	pngChunk(file, "IEND", 0);
+	pngInt32(file, ~pngCRC);
+}
diff --git a/bin/pngo.c b/bin/pngo.c
new file mode 100644
index 00000000..eb51ccc2
--- /dev/null
+++ b/bin/pngo.c
@@ -0,0 +1,941 @@
+/* Copyright (C) 2018, 2021  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <err.h>
+#include <inttypes.h>
+#include <limits.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <unistd.h>
+#include <zlib.h>
+
+#define ARRAY_LEN(a) (sizeof(a) / sizeof(a[0]))
+
+static bool verbose;
+static const char *path;
+static FILE *file;
+static uint32_t crc;
+
+static void pngRead(void *ptr, size_t len, const char *desc) {
+	size_t n = fread(ptr, len, 1, file);
+	if (!n && ferror(file)) err(EX_IOERR, "%s", path);
+	if (!n) errx(EX_DATAERR, "%s: missing %s", path, desc);
+	crc = crc32(crc, ptr, len);
+}
+
+static void pngWrite(const void *ptr, size_t len) {
+	size_t n = fwrite(ptr, len, 1, file);
+	if (!n) err(EX_IOERR, "%s", path);
+	crc = crc32(crc, ptr, len);
+}
+
+static const uint8_t Sig[8] = "\x89PNG\r\n\x1A\n";
+
+static void sigRead(void) {
+	uint8_t sig[sizeof(Sig)];
+	pngRead(sig, sizeof(sig), "signature");
+	if (memcmp(sig, Sig, sizeof(sig))) {
+		errx(EX_DATAERR, "%s: invalid signature", path);
+	}
+}
+
+static void sigWrite(void) {
+	pngWrite(Sig, sizeof(Sig));
+}
+
+static uint32_t u32Read(const char *desc) {
+	uint8_t b[4];
+	pngRead(b, sizeof(b), desc);
+	return (uint32_t)b[0] << 24 | (uint32_t)b[1] << 16
+		| (uint32_t)b[2] << 8 | (uint32_t)b[3];
+}
+
+static void u32Write(uint32_t x) {
+	uint8_t b[4] = { x >> 24 & 0xFF, x >> 16 & 0xFF, x >> 8 & 0xFF, x & 0xFF };
+	pngWrite(b, sizeof(b));
+}
+
+struct Chunk {
+	uint32_t len;
+	char type[5];
+};
+
+static struct Chunk chunkRead(void) {
+	struct Chunk chunk;
+	chunk.len = u32Read("chunk length");
+	crc = crc32(0, Z_NULL, 0);
+	pngRead(chunk.type, 4, "chunk type");
+	chunk.type[4] = 0;
+	return chunk;
+}
+
+static void chunkWrite(struct Chunk chunk) {
+	u32Write(chunk.len);
+	crc = crc32(0, Z_NULL, 0);
+	pngWrite(chunk.type, 4);
+}
+
+static void crcRead(void) {
+	uint32_t expect = crc;
+	uint32_t actual = u32Read("CRC32");
+	if (actual == expect) return;
+	errx(
+		EX_DATAERR, "%s: expected CRC32 %08X, found %08X",
+		path, expect, actual
+	);
+}
+
+static void crcWrite(void) {
+	u32Write(crc);
+}
+
+static void chunkSkip(struct Chunk chunk) {
+	if (!(chunk.type[0] & 0x20)) {
+		errx(EX_CONFIG, "%s: unsupported critical chunk %s", path, chunk.type);
+	}
+	uint8_t buf[4096];
+	while (chunk.len > sizeof(buf)) {
+		pngRead(buf, sizeof(buf), "chunk data");
+		chunk.len -= sizeof(buf);
+	}
+	if (chunk.len) pngRead(buf, chunk.len, "chunk data");
+	crcRead();
+}
+
+enum Color {
+	Grayscale = 0,
+	Truecolor = 2,
+	Indexed = 3,
+	GrayscaleAlpha = 4,
+	TruecolorAlpha = 6,
+};
+enum Compression {
+	Deflate,
+};
+enum FilterMethod {
+	Adaptive,
+};
+enum Interlace {
+	Progressive,
+	Adam7,
+};
+
+enum { HeaderLen = 13 };
+static struct {
+	uint32_t width;
+	uint32_t height;
+	uint8_t depth;
+	uint8_t color;
+	uint8_t compression;
+	uint8_t filter;
+	uint8_t interlace;
+} header;
+
+static size_t pixelLen;
+static size_t lineLen;
+static size_t dataLen;
+
+static void recalc(void) {
+	size_t pixelBits = header.depth;
+	switch (header.color) {
+		break; case GrayscaleAlpha: pixelBits *= 2;
+		break; case Truecolor: pixelBits *= 3;
+		break; case TruecolorAlpha: pixelBits *= 4;
+	}
+	pixelLen = (pixelBits + 7) / 8;
+	lineLen = (header.width * pixelBits + 7) / 8;
+	dataLen = (1 + lineLen) * header.height;
+}
+
+static void headerPrint(void) {
+	static const char *String[] = {
+		[Grayscale] = "grayscale",
+		[Truecolor] = "truecolor",
+		[Indexed] = "indexed",
+		[GrayscaleAlpha] = "grayscale alpha",
+		[TruecolorAlpha] = "truecolor alpha",
+	};
+	fprintf(
+		stderr, "%s: %" PRIu32 "x%" PRIu32 " %" PRIu8 "-bit %s\n",
+		path, header.width, header.height, header.depth, String[header.color]
+	);
+}
+
+static void headerRead(struct Chunk chunk) {
+	if (chunk.len != HeaderLen) {
+		errx(
+			EX_DATAERR, "%s: expected %s length %" PRIu32 ", found %" PRIu32,
+			path, chunk.type, (uint32_t)HeaderLen, chunk.len
+		);
+	}
+	header.width = u32Read("header width");
+	header.height = u32Read("header height");
+	pngRead(&header.depth, 1, "header depth");
+	pngRead(&header.color, 1, "header color");
+	pngRead(&header.compression, 1, "header compression");
+	pngRead(&header.filter, 1, "header filter");
+	pngRead(&header.interlace, 1, "header interlace");
+	crcRead();
+	recalc();
+
+	if (!header.width) errx(EX_DATAERR, "%s: invalid width 0", path);
+	if (!header.height) errx(EX_DATAERR, "%s: invalid height 0", path);
+	static const struct {
+		uint8_t color;
+		uint8_t depth;
+	} Valid[] = {
+		{ Grayscale, 1 },
+		{ Grayscale, 2 },
+		{ Grayscale, 4 },
+		{ Grayscale, 8 },
+		{ Grayscale, 16 },
+		{ Truecolor, 8 },
+		{ Truecolor, 16 },
+		{ Indexed, 1 },
+		{ Indexed, 2 },
+		{ Indexed, 4 },
+		{ Indexed, 8 },
+		{ Indexed, 16 },
+		{ GrayscaleAlpha, 8 },
+		{ GrayscaleAlpha, 16 },
+		{ TruecolorAlpha, 8 },
+		{ TruecolorAlpha, 16 },
+	};
+	bool valid = false;
+	for (size_t i = 0; i < ARRAY_LEN(Valid); ++i) {
+		valid = (
+			header.color == Valid[i].color &&
+			header.depth == Valid[i].depth
+		);
+		if (valid) break;
+	}
+	if (!valid) {
+		errx(
+			EX_DATAERR,
+			"%s: invalid color type %" PRIu8 " and bit depth %" PRIu8,
+			path, header.color, header.depth
+		);
+	}
+	if (header.compression != Deflate) {
+		errx(
+			EX_DATAERR, "%s: invalid compression method %" PRIu8,
+			path, header.compression
+		);
+	}
+	if (header.filter != Adaptive) {
+		errx(
+			EX_DATAERR, "%s: invalid filter method %" PRIu8,
+			path, header.filter
+		);
+	}
+	if (header.interlace > Adam7) {
+		errx(
+			EX_DATAERR, "%s: invalid interlace method %" PRIu8,
+			path, header.interlace
+		);
+	}
+
+	if (verbose) headerPrint();
+}
+
+static void headerWrite(void) {
+	if (verbose) headerPrint();
+
+	struct Chunk ihdr = { HeaderLen, "IHDR" };
+	chunkWrite(ihdr);
+	u32Write(header.width);
+	u32Write(header.height);
+	pngWrite(&header.depth, 1);
+	pngWrite(&header.color, 1);
+	pngWrite(&header.compression, 1);
+	pngWrite(&header.filter, 1);
+	pngWrite(&header.interlace, 1);
+	crcWrite();
+}
+
+static struct {
+	uint32_t len;
+	uint8_t rgb[256][3];
+} pal;
+
+static struct {
+	uint32_t len;
+	uint8_t a[256];
+} trans;
+
+static void palClear(void) {
+	pal.len = 0;
+	trans.len = 0;
+}
+
+static uint32_t palIndex(bool alpha, const uint8_t *rgba) {
+	uint32_t i;
+	for (i = 0; i < pal.len; ++i) {
+		if (alpha && i < trans.len && trans.a[i] != rgba[3]) continue;
+		if (!memcmp(pal.rgb[i], rgba, 3)) break;
+	}
+	return i;
+}
+
+static bool palAdd(bool alpha, const uint8_t *rgba) {
+	uint32_t i = palIndex(alpha, rgba);
+	if (i < pal.len) return true;
+	if (i == 256) return false;
+	memcpy(pal.rgb[i], rgba, 3);
+	pal.len++;
+	if (alpha) {
+		trans.a[i] = rgba[3];
+		trans.len++;
+	}
+	return true;
+}
+
+static void transCompact(void) {
+	uint32_t i;
+	for (i = 0; i < trans.len; ++i) {
+		if (trans.a[i] == 0xFF) break;
+	}
+	if (i == trans.len) return;
+
+	for (uint32_t j = i+1; j < trans.len; ++j) {
+		if (trans.a[j] == 0xFF) continue;
+		uint8_t a = trans.a[i];
+		trans.a[i] = trans.a[j];
+		trans.a[j] = a;
+		uint8_t rgb[3];
+		memcpy(rgb, pal.rgb[i], 3);
+		memcpy(pal.rgb[i], pal.rgb[j], 3);
+		memcpy(pal.rgb[j], rgb, 3);
+		i++;
+	}
+	trans.len = i;
+}
+
+static void palRead(struct Chunk chunk) {
+	if (chunk.len % 3) {
+		errx(
+			EX_DATAERR, "%s: %s length %" PRIu32 " not divisible by 3",
+			path, chunk.type, chunk.len
+		);
+	}
+	pal.len = chunk.len / 3;
+	if (pal.len > 256) {
+		errx(
+			EX_DATAERR, "%s: %s length %" PRIu32 " > 256",
+			path, chunk.type, pal.len
+		);
+	}
+	pngRead(pal.rgb, chunk.len, "palette data");
+	crcRead();
+	if (verbose) {
+		fprintf(stderr, "%s: palette length %" PRIu32 "\n", path, pal.len);
+	}
+}
+
+static void palWrite(void) {
+	if (verbose) {
+		fprintf(stderr, "%s: palette length %" PRIu32 "\n", path, pal.len);
+	}
+	struct Chunk plte = { 3 * pal.len, "PLTE" };
+	chunkWrite(plte);
+	pngWrite(pal.rgb, plte.len);
+	crcWrite();
+}
+
+static void transRead(struct Chunk chunk) {
+	trans.len = chunk.len;
+	if (trans.len > 256) {
+		errx(
+			EX_DATAERR, "%s: %s length %" PRIu32 " > 256",
+			path, chunk.type, trans.len
+		);
+	}
+	pngRead(trans.a, chunk.len, "transparency data");
+	crcRead();
+	if (verbose) {
+		fprintf(stderr, "%s: trans length %" PRIu32 "\n", path, trans.len);
+	}
+}
+
+static void transWrite(void) {
+	if (verbose) {
+		fprintf(stderr, "%s: trans length %" PRIu32 "\n", path, trans.len);
+	}
+	struct Chunk trns = { trans.len, "tRNS" };
+	chunkWrite(trns);
+	pngWrite(trans.a, trns.len);
+	crcWrite();
+}
+
+static uint8_t *data;
+
+static void dataAlloc(void) {
+	data = malloc(dataLen);
+	if (!data) err(EX_OSERR, "malloc");
+}
+
+static const char *humanize(size_t n) {
+	static char buf[64];
+	if (n >> 10) {
+		snprintf(buf, sizeof(buf), "%zuK", n >> 10);
+	} else {
+		snprintf(buf, sizeof(buf), "%zuB", n);
+	}
+	return buf;
+}
+
+static void dataRead(struct Chunk chunk) {
+	if (verbose) {
+		fprintf(stderr, "%s: data size %s\n", path, humanize(dataLen));
+	}
+
+	z_stream stream = { .next_out = data, .avail_out = dataLen };
+	int error = inflateInit(&stream);
+	if (error != Z_OK) errx(EX_SOFTWARE, "inflateInit: %s", stream.msg);
+
+	for (;;) {
+		if (strcmp(chunk.type, "IDAT")) {
+			errx(EX_DATAERR, "%s: missing IDAT chunk", path);
+		}
+
+		uint8_t *idat = malloc(chunk.len);
+		if (!idat) err(EX_OSERR, "malloc");
+
+		pngRead(idat, chunk.len, "image data");
+		crcRead();
+		
+		stream.next_in = idat;
+		stream.avail_in = chunk.len;
+		error = inflate(&stream, Z_SYNC_FLUSH);
+		free(idat);
+
+		if (error == Z_STREAM_END) break;
+		if (error != Z_OK) {
+			errx(EX_DATAERR, "%s: inflate: %s", path, stream.msg);
+		}
+
+		chunk = chunkRead();
+	}
+	inflateEnd(&stream);
+	if ((size_t)stream.total_out != dataLen) {
+		errx(
+			EX_DATAERR, "%s: expected data length %zu, found %zu",
+			path, dataLen, (size_t)stream.total_out
+		);
+	}
+
+	if (verbose) {
+		fprintf(
+			stderr, "%s: deflate size %s\n",
+			path, humanize(stream.total_in)
+		);
+	}
+}
+
+static void dataWrite(void) {
+	if (verbose) {
+		fprintf(stderr, "%s: data size %s\n", path, humanize(dataLen));
+	}
+
+	z_stream stream = {
+		.next_in = data,
+		.avail_in = dataLen,
+	};
+	int error = deflateInit2(
+		&stream, Z_BEST_COMPRESSION, Z_DEFLATED, 15, 8, Z_FILTERED
+	);
+	if (error != Z_OK) errx(EX_SOFTWARE, "deflateInit2: %s", stream.msg);
+
+	uLong bound = deflateBound(&stream, dataLen);
+	uint8_t *buf = malloc(bound);
+	if (!buf) err(EX_OSERR, "malloc");
+
+	stream.next_out = buf;
+	stream.avail_out = bound;
+	deflate(&stream, Z_FINISH);
+	deflateEnd(&stream);
+
+	struct Chunk idat = { stream.total_out, "IDAT" };
+	chunkWrite(idat);
+	pngWrite(buf, stream.total_out);
+	crcWrite();
+	free(buf);
+
+	struct Chunk iend = { 0, "IEND" };
+	chunkWrite(iend);
+	crcWrite();
+
+	if (verbose) {
+		fprintf(
+			stderr, "%s: deflate size %s\n",
+			path, humanize(stream.total_out)
+		);
+	}
+}
+
+enum Filter {
+	None,
+	Sub,
+	Up,
+	Average,
+	Paeth,
+	FilterCap,
+};
+
+struct Bytes {
+	uint8_t x, a, b, c;
+};
+
+static uint8_t paethPredictor(struct Bytes f) {
+	int32_t p = (int32_t)f.a + (int32_t)f.b - (int32_t)f.c;
+	int32_t pa = labs(p - (int32_t)f.a);
+	int32_t pb = labs(p - (int32_t)f.b);
+	int32_t pc = labs(p - (int32_t)f.c);
+	if (pa <= pb && pa <= pc) return f.a;
+	if (pb <= pc) return f.b;
+	return f.c;
+}
+
+static uint8_t recon(enum Filter type, struct Bytes f) {
+	switch (type) {
+		case None:    return f.x;
+		case Sub:     return f.x + f.a;
+		case Up:      return f.x + f.b;
+		case Average: return f.x + ((uint32_t)f.a + (uint32_t)f.b) / 2;
+		case Paeth:   return f.x + paethPredictor(f);
+		default: abort();
+	}
+}
+
+static uint8_t filt(enum Filter type, struct Bytes f) {
+	switch (type) {
+		case None:    return f.x;
+		case Sub:     return f.x - f.a;
+		case Up:      return f.x - f.b;
+		case Average: return f.x - ((uint32_t)f.a + (uint32_t)f.b) / 2;
+		case Paeth:   return f.x - paethPredictor(f);
+		default: abort();
+	}
+}
+
+static uint8_t *lineType(uint32_t y) {
+	return &data[y * (1 + lineLen)];
+}
+static uint8_t *lineData(uint32_t y) {
+	return 1 + lineType(y);
+}
+
+static struct Bytes origBytes(uint32_t y, size_t i) {
+	bool a = (i >= pixelLen), b = (y > 0), c = (a && b);
+	return (struct Bytes) {
+		.x = lineData(y)[i],
+		.a = (a ? lineData(y)[i-pixelLen] : 0),
+		.b = (b ? lineData(y-1)[i] : 0),
+		.c = (c ? lineData(y-1)[i-pixelLen] : 0),
+	};
+}
+
+static void dataRecon(void) {
+	for (uint32_t y = 0; y < header.height; ++y) {
+		for (size_t i = 0; i < lineLen; ++i) {
+			lineData(y)[i] = recon(*lineType(y), origBytes(y, i));
+		}
+		*lineType(y) = None;
+	}
+}
+
+static void dataFilter(void) {
+	if (header.color == Indexed || header.depth < 8) return;
+	uint8_t *filter[FilterCap];
+	for (enum Filter i = None; i < FilterCap; ++i) {
+		filter[i] = malloc(lineLen);
+		if (!filter[i]) err(EX_OSERR, "malloc");
+	}
+	for (uint32_t y = header.height-1; y < header.height; --y) {
+		uint32_t heuristic[FilterCap] = {0};
+		enum Filter minType = None;
+		for (enum Filter type = None; type < FilterCap; ++type) {
+			for (size_t i = 0; i < lineLen; ++i) {
+				filter[type][i] = filt(type, origBytes(y, i));
+				heuristic[type] += abs((int8_t)filter[type][i]);
+			}
+			if (heuristic[type] < heuristic[minType]) minType = type;
+		}
+		*lineType(y) = minType;
+		memcpy(lineData(y), filter[minType], lineLen);
+	}
+	for (enum Filter i = None; i < FilterCap; ++i) {
+		free(filter[i]);
+	}
+}
+
+static bool alphaUnused(void) {
+	if (header.color != GrayscaleAlpha && header.color != TruecolorAlpha) {
+		return false;
+	}
+	size_t sampleLen = header.depth / 8;
+	size_t colorLen = pixelLen - sampleLen;
+	for (uint32_t y = 0; y < header.height; ++y)
+	for (uint32_t x = 0; x < header.width; ++x)
+	for (size_t i = 0; i < sampleLen; ++i) {
+		if (lineData(y)[x * pixelLen + colorLen + i] != 0xFF) return false;
+	}
+	return true;
+}
+
+static void alphaDiscard(void) {
+	if (header.color != GrayscaleAlpha && header.color != TruecolorAlpha) {
+		return;
+	}
+	size_t sampleLen = header.depth / 8;
+	size_t colorLen = pixelLen - sampleLen;
+	uint8_t *ptr = data;
+	for (uint32_t y = 0; y < header.height; ++y) {
+		*ptr++ = *lineType(y);
+		for (uint32_t x = 0; x < header.width; ++x) {
+			memmove(ptr, &lineData(y)[x * pixelLen], colorLen);
+			ptr += colorLen;
+		}
+	}
+	header.color = (header.color == GrayscaleAlpha ? Grayscale : Truecolor);
+	recalc();
+}
+
+static bool depth16Unused(void) {
+	if (header.color != Grayscale && header.color != Truecolor) return false;
+	if (header.depth != 16) return false;
+	for (uint32_t y = 0; y < header.height; ++y)
+	for (size_t i = 0; i < lineLen; i += 2) {
+		if (lineData(y)[i] != lineData(y)[i+1]) return false;
+	}
+	return true;
+}
+
+static void depth16Reduce(void) {
+	if (header.depth != 16) return;
+	uint8_t *ptr = data;
+	for (uint32_t y = 0; y < header.height; ++y) {
+		*ptr++ = *lineType(y);
+		for (size_t i = 0; i < lineLen / 2; ++i) {
+			*ptr++ = lineData(y)[i*2];
+		}
+	}
+	header.depth = 8;
+	recalc();
+}
+
+static bool colorUnused(void) {
+	if (header.color != Truecolor && header.color != TruecolorAlpha) {
+		return false;
+	}
+	if (header.depth != 8) return false;
+	for (uint32_t y = 0; y < header.height; ++y)
+	for (uint32_t x = 0; x < header.width; ++x) {
+		uint8_t r = lineData(y)[x * pixelLen + 0];
+		uint8_t g = lineData(y)[x * pixelLen + 1];
+		uint8_t b = lineData(y)[x * pixelLen + 2];
+		if (r != g || g != b) return false;
+	}
+	return true;
+}
+
+static void colorDiscard(void) {
+	if (header.color != Truecolor && header.color != TruecolorAlpha) return;
+	if (header.depth != 8) return;
+	uint8_t *ptr = data;
+	for (uint32_t y = 0; y < header.height; ++y) {
+		*ptr++ = *lineType(y);
+		for (uint32_t x = 0; x < header.width; ++x) {
+			uint8_t r = lineData(y)[x * pixelLen + 0];
+			uint8_t g = lineData(y)[x * pixelLen + 1];
+			uint8_t b = lineData(y)[x * pixelLen + 2];
+			*ptr++ = ((uint32_t)r + (uint32_t)g + (uint32_t)b) / 3;
+			if (header.color == TruecolorAlpha) {
+				*ptr++ = lineData(y)[x * pixelLen + 3];
+			}
+		}
+	}
+	header.color = (header.color == Truecolor ? Grayscale : GrayscaleAlpha);
+	recalc();
+}
+
+static void colorIndex(void) {
+	if (header.color != Truecolor && header.color != TruecolorAlpha) return;
+	if (header.depth != 8) return;
+	bool alpha = (header.color == TruecolorAlpha);
+	for (uint32_t y = 0; y < header.height; ++y)
+	for (uint32_t x = 0; x < header.width; ++x) {
+		if (!palAdd(alpha, &lineData(y)[x * pixelLen])) return;
+	}
+
+	transCompact();
+	uint8_t *ptr = data;
+	for (uint32_t y = 0; y < header.height; ++y) {
+		*ptr++ = *lineType(y);
+		for (uint32_t x = 0; x < header.width; ++x) {
+			*ptr++ = palIndex(alpha, &lineData(y)[x * pixelLen]);
+		}
+	}
+	header.color = Indexed;
+	recalc();
+}
+
+static bool depth8Unused(void) {
+	if (header.depth != 8) return false;
+	if (header.color == Indexed) return pal.len <= 16;
+	if (header.color != Grayscale) return false;
+	for (uint32_t y = 0; y < header.height; ++y)
+	for (size_t i = 0; i < lineLen; ++i) {
+		if ((lineData(y)[i] >> 4) != (lineData(y)[i] & 0x0F)) return false;
+	}
+	return true;
+}
+
+static void depth8Reduce(void) {
+	if (header.color != Grayscale && header.color != Indexed) return;
+	if (header.depth != 8) return;
+	uint8_t *ptr = data;
+	for (uint32_t y = 0; y < header.height; ++y) {
+		*ptr++ = *lineType(y);
+		for (size_t i = 0; i < lineLen; i += 2) {
+			uint8_t a, b;
+			uint8_t aa = lineData(y)[i];
+			uint8_t bb = (i+1 < lineLen ? lineData(y)[i+1] : 0);
+			if (header.color == Grayscale) {
+				a = aa >> 4;
+				b = bb >> 4;
+			} else {
+				a = aa & 0x0F;
+				b = bb & 0x0F;
+			}
+			*ptr++ = a << 4 | b;
+		}
+	}
+	header.depth = 4;
+	recalc();
+}
+
+static bool depth4Unused(void) {
+	if (header.depth != 4) return false;
+	if (header.color == Indexed) return pal.len <= 4;
+	if (header.color != Grayscale) return false;
+	for (uint32_t y = 0; y < header.height; ++y)
+	for (size_t i = 0; i < lineLen; ++i) {
+		uint8_t a = lineData(y)[i] >> 4;
+		uint8_t b = lineData(y)[i] & 0x0F;
+		if ((a >> 2) != (a & 0x03)) return false;
+		if ((b >> 2) != (b & 0x03)) return false;
+	}
+	return true;
+}
+
+static void depth4Reduce(void) {
+	if (header.color != Grayscale && header.color != Indexed) return;
+	if (header.depth != 4) return;
+	uint8_t *ptr = data;
+	for (uint32_t y = 0; y < header.height; ++y) {
+		*ptr++ = *lineType(y);
+		for (size_t i = 0; i < lineLen; i += 2) {
+			uint8_t a, b, c, d;
+			uint8_t aabb = lineData(y)[i];
+			uint8_t ccdd = (i+1 < lineLen ? lineData(y)[i+1] : 0);
+			if (header.color == Grayscale) {
+				a = aabb >> 6;
+				c = ccdd >> 6;
+				b = aabb >> 2 & 0x03;
+				d = ccdd >> 2 & 0x03;
+			} else {
+				a = aabb >> 4 & 0x03;
+				c = ccdd >> 4 & 0x03;
+				b = aabb & 0x03;
+				d = ccdd & 0x03;
+			}
+			*ptr++ = a << 6 | b << 4 | c << 2 | d;
+		}
+	}
+	header.depth = 2;
+	recalc();
+}
+
+static bool depth2Unused(void) {
+	if (header.depth != 2) return false;
+	if (header.color == Indexed) return pal.len <= 2;
+	if (header.color != Grayscale) return false;
+	for (uint32_t y = 0; y < header.height; ++y)
+	for (size_t i = 0; i < lineLen; ++i) {
+		uint8_t a = lineData(y)[i] >> 6;
+		uint8_t b = lineData(y)[i] >> 4 & 0x03;
+		uint8_t c = lineData(y)[i] >> 2 & 0x03;
+		uint8_t d = lineData(y)[i] & 0x03;
+		if ((a >> 1) != (a & 1)) return false;
+		if ((b >> 1) != (b & 1)) return false;
+		if ((c >> 1) != (c & 1)) return false;
+		if ((d >> 1) != (d & 1)) return false;
+	}
+	return true;
+}
+
+static void depth2Reduce(void) {
+	if (header.color != Grayscale && header.color != Indexed) return;
+	if (header.depth != 2) return;
+	uint8_t *ptr = data;
+	for (uint32_t y = 0; y < header.height; ++y) {
+		*ptr++ = *lineType(y);
+		for (size_t i = 0; i < lineLen; i += 2) {
+			uint8_t a, b, c, d, e, f, g, h;
+			uint8_t aabbccdd = lineData(y)[i];
+			uint8_t eeffgghh = (i+1 < lineLen ? lineData(y)[i+1] : 0);
+			if (header.color == Grayscale) {
+				a = aabbccdd >> 7;
+				b = aabbccdd >> 5 & 1;
+				c = aabbccdd >> 3 & 1;
+				d = aabbccdd >> 1 & 1;
+				e = eeffgghh >> 7;
+				f = eeffgghh >> 5 & 1;
+				g = eeffgghh >> 3 & 1;
+				h = eeffgghh >> 1 & 1;
+			} else {
+				a = aabbccdd >> 6 & 1;
+				b = aabbccdd >> 4 & 1;
+				c = aabbccdd >> 2 & 1;
+				d = aabbccdd & 1;
+				e = eeffgghh >> 6 & 1;
+				f = eeffgghh >> 4 & 1;
+				g = eeffgghh >> 2 & 1;
+				h = eeffgghh & 1;
+			}
+			*ptr++ = 0
+				| a << 7 | b << 6 | c << 5 | d << 4
+				| e << 3 | f << 2 | g << 1 | h;
+		}
+	}
+	header.depth = 1;
+	recalc();
+}
+
+static bool discardAlpha;
+static bool discardColor;
+static uint8_t reduceDepth = 16;
+
+static void optimize(const char *inPath, const char *outPath) {
+	if (inPath) {
+		path = inPath;
+		file = fopen(path, "r");
+		if (!file) err(EX_NOINPUT, "%s", path);
+	} else {
+		path = "stdin";
+		file = stdin;
+	}
+
+	sigRead();
+	struct Chunk ihdr = chunkRead();
+	if (strcmp(ihdr.type, "IHDR")) {
+		errx(EX_DATAERR, "%s: expected IHDR, found %s", path, ihdr.type);
+	}
+	headerRead(ihdr);
+	if (header.interlace != Progressive) {
+		errx(EX_CONFIG, "%s: unsupported interlacing", path);
+	}
+
+	palClear();
+	dataAlloc();
+	for (;;) {
+		struct Chunk chunk = chunkRead();
+		if (!strcmp(chunk.type, "PLTE")) {
+			palRead(chunk);
+		} else if (!strcmp(chunk.type, "tRNS")) {
+			transRead(chunk);
+		} else if (!strcmp(chunk.type, "IDAT")) {
+			dataRead(chunk);
+		} else if (!strcmp(chunk.type, "IEND")) {
+			break;
+		} else {
+			chunkSkip(chunk);
+		}
+	}
+	fclose(file);
+
+	dataRecon();
+	if (discardAlpha || alphaUnused()) alphaDiscard();
+	if (reduceDepth < 16 || depth16Unused()) depth16Reduce();
+	if (discardColor || colorUnused()) colorDiscard();
+	colorIndex();
+	if (reduceDepth < 8 || depth8Unused()) depth8Reduce();
+	if (reduceDepth < 4 || depth4Unused()) depth4Reduce();
+	if (reduceDepth < 2 || depth2Unused()) depth2Reduce();
+	dataFilter();
+
+	char buf[PATH_MAX];
+	if (outPath) {
+		path = outPath;
+		if (outPath == inPath) {
+			snprintf(buf, sizeof(buf), "%so", outPath);
+			file = fopen(buf, "wx");
+			if (!file) err(EX_CANTCREAT, "%s", buf);
+		} else {
+			file = fopen(path, "w");
+			if (!file) err(EX_CANTCREAT, "%s", outPath);
+		}
+	} else {
+		path = "stdout";
+		file = stdout;
+	}
+
+	sigWrite();
+	headerWrite();
+	if (header.color == Indexed) {
+		palWrite();
+		if (trans.len) transWrite();
+	}
+	dataWrite();
+	free(data);
+	int error = fclose(file);
+	if (error) err(EX_IOERR, "%s", path);
+
+	if (outPath && outPath == inPath) {
+		error = rename(buf, outPath);
+		if (error) err(EX_CANTCREAT, "%s", outPath);
+	}
+}
+
+int main(int argc, char *argv[]) {
+	bool stdio = false;
+	char *outPath = NULL;
+
+	for (int opt; 0 < (opt = getopt(argc, argv, "ab:cgo:v"));) {
+		switch (opt) {
+			break; case 'a': discardAlpha = true;
+			break; case 'b': reduceDepth = strtoul(optarg, NULL, 10);
+			break; case 'c': stdio = true;
+			break; case 'g': discardColor = true;
+			break; case 'o': outPath = optarg;
+			break; case 'v': verbose = true;
+			break; default:  return EX_USAGE;
+		}
+	}
+
+	if (optind < argc) {
+		for (int i = optind; i < argc; ++i) {
+			optimize(argv[i], (stdio ? NULL : outPath ? outPath : argv[i]));
+		}
+	} else {
+		optimize(NULL, outPath);
+	}
+}
diff --git a/bin/psf2png.c b/bin/psf2png.c
new file mode 100644
index 00000000..c36238a0
--- /dev/null
+++ b/bin/psf2png.c
@@ -0,0 +1,107 @@
+/* Copyright (C) 2018  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <err.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <unistd.h>
+
+#include "png.h"
+
+int main(int argc, char *argv[]) {
+	uint32_t cols = 32;
+	const char *str = NULL;
+	uint32_t fg = 0xFFFFFF;
+	uint32_t bg = 0x000000;
+
+	int opt;
+	while (0 < (opt = getopt(argc, argv, "b:c:f:s:"))) {
+		switch (opt) {
+			break; case 'b': bg = strtoul(optarg, NULL, 16);
+			break; case 'c': cols = strtoul(optarg, NULL, 0);
+			break; case 'f': fg = strtoul(optarg, NULL, 16);
+			break; case 's': str = optarg;
+			break; default:  return EX_USAGE;
+		}
+	}
+	if (!cols && str) cols = strlen(str);
+	if (!cols) return EX_USAGE;
+
+	const char *path = NULL;
+	if (optind < argc) path = argv[optind];
+	
+	FILE *file = path ? fopen(path, "r") : stdin;
+	if (!file) err(EX_NOINPUT, "%s", path);
+	if (!path) path = "(stdin)";
+
+	struct {
+		uint32_t magic;
+		uint32_t version;
+		uint32_t size;
+		uint32_t flags;
+		struct {
+			uint32_t len;
+			uint32_t size;
+			uint32_t height;
+			uint32_t width;
+		} glyph;
+	} header;
+	size_t len = fread(&header, sizeof(header), 1, file);
+	if (ferror(file)) err(EX_IOERR, "%s", path);
+	if (len < 1) errx(EX_DATAERR, "%s: truncated header", path);
+
+	uint32_t widthBytes = (header.glyph.width + 7) / 8;
+	uint8_t glyphs[header.glyph.len][header.glyph.height][widthBytes];
+	len = fread(glyphs, header.glyph.size, header.glyph.len, file);
+	if (ferror(file)) err(EX_IOERR, "%s", path);
+	if (len < header.glyph.len) {
+		errx(EX_DATAERR, "%s: truncated glyphs", path);
+	}
+	fclose(file);
+
+	uint32_t count = (str ? strlen(str) : header.glyph.len);
+	uint32_t width = header.glyph.width * cols;
+	uint32_t rows = (count + cols - 1) / cols;
+	uint32_t height = header.glyph.height * rows;
+
+	pngHead(stdout, width, height, 8, PNGIndexed);
+	uint8_t pal[] = {
+		bg >> 16, bg >> 8, bg,
+		fg >> 16, fg >> 8, fg,
+	};
+	pngPalette(stdout, pal, sizeof(pal));
+
+	uint8_t data[height][1 + width];
+	memset(data, PNGNone, sizeof(data));
+
+	for (uint32_t i = 0; i < count; ++i) {
+		uint32_t row = header.glyph.height * (i / cols);
+		uint32_t col = 1 + header.glyph.width * (i % cols);
+		uint32_t g = (str ? str[i] : i);
+		for (uint32_t y = 0; y < header.glyph.height; ++y) {
+			for (uint32_t x = 0; x < header.glyph.width; ++x) {
+				uint8_t bit = glyphs[g][y][x / 8] >> (7 - x % 8) & 1;
+				data[row + y][col + x] = bit;
+			}
+		}
+	}
+
+	pngData(stdout, (uint8_t *)data, sizeof(data));
+	pngTail(stdout);
+}
diff --git a/bin/ptee.c b/bin/ptee.c
new file mode 100644
index 00000000..52350a21
--- /dev/null
+++ b/bin/ptee.c
@@ -0,0 +1,151 @@
+/* Copyright (C) 2019  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <err.h>
+#include <errno.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <sys/ioctl.h>
+#include <sys/time.h>
+#include <sys/wait.h>
+#include <sysexits.h>
+#include <termios.h>
+#include <unistd.h>
+
+#if defined __FreeBSD__
+#include <libutil.h>
+#elif defined __linux__
+#include <pty.h>
+#else
+#include <util.h>
+#endif
+
+typedef unsigned char byte;
+
+static struct termios saveTerm;
+static void restoreTerm(void) {
+	tcsetattr(STDIN_FILENO, TCSADRAIN, &saveTerm);
+}
+
+static void handler(int sig) {
+	(void)sig;
+}
+
+int main(int argc, char *argv[]) {
+	int timer = 0;
+	for (int opt; 0 < (opt = getopt(argc, argv, "t:"));) {
+		switch (opt) {
+			break; case 't': timer = atoi(optarg);
+			break; default:  return EX_USAGE;
+		}
+	}
+	argc -= optind;
+	argv += optind;
+
+	if (argc < 1) return EX_USAGE;
+	if (isatty(STDOUT_FILENO)) errx(EX_USAGE, "stdout is not redirected");
+
+	int error = tcgetattr(STDIN_FILENO, &saveTerm);
+	if (error) err(EX_IOERR, "tcgetattr");
+	atexit(restoreTerm);
+
+	struct termios raw = saveTerm;
+	cfmakeraw(&raw);
+	error = tcsetattr(STDIN_FILENO, TCSADRAIN, &raw);
+	if (error) err(EX_IOERR, "tcsetattr");
+
+	struct winsize window;
+	error = ioctl(STDIN_FILENO, TIOCGWINSZ, &window);
+	if (error) err(EX_IOERR, "ioctl");
+
+	int pty;
+	pid_t pid = forkpty(&pty, NULL, NULL, &window);
+	if (pid < 0) err(EX_OSERR, "forkpty");
+
+	if (!pid) {
+		execvp(argv[0], argv);
+		err(EX_NOINPUT, "%s", argv[0]);
+	}
+
+	if (timer) {
+		signal(SIGALRM, handler);
+		struct timeval tv = {
+			.tv_sec = timer / 1000,
+			.tv_usec = timer % 1000 * 1000,
+		};
+		struct itimerval itv = { tv, tv };
+		setitimer(ITIMER_REAL, &itv, NULL);
+	}
+
+	char mc[] = "\x1B[10i";
+	bool stop = false;
+
+	byte buf[4096];
+	struct pollfd fds[2] = {
+		{ .events = POLLIN, .fd = STDIN_FILENO },
+		{ .events = POLLIN, .fd = pty },
+	};
+	for (;;) {
+		int nfds = poll(fds, 2, -1);
+		if (nfds < 0 && errno != EINTR) err(EX_IOERR, "poll");
+
+		if (nfds < 0) {
+			ssize_t wlen = write(STDOUT_FILENO, mc, sizeof(mc) - 1);
+			if (wlen < 0) err(EX_IOERR, "write");
+			continue;
+		}
+
+		if (fds[0].revents & POLLIN) {
+			ssize_t rlen = read(STDIN_FILENO, buf, sizeof(buf));
+			if (rlen < 0) err(EX_IOERR, "read");
+
+			if (rlen == 1 && buf[0] == CTRL('Q')) {
+				stop ^= true;
+				continue;
+			}
+
+			if (rlen == 1 && buf[0] == CTRL('S')) {
+				ssize_t wlen = write(STDOUT_FILENO, mc, sizeof(mc) - 1);
+				if (wlen < 0) err(EX_IOERR, "write");
+				continue;
+			}
+
+			ssize_t wlen = write(pty, buf, rlen);
+			if (wlen < 0) err(EX_IOERR, "write");
+		}
+
+		if (fds[1].revents & POLLIN) {
+			ssize_t rlen = read(pty, buf, sizeof(buf));
+			if (rlen < 0) err(EX_IOERR, "read");
+
+			ssize_t wlen = write(STDIN_FILENO, buf, rlen);
+			if (wlen < 0) err(EX_IOERR, "write");
+
+			if (!stop) {
+				wlen = write(STDOUT_FILENO, buf, rlen);
+				if (wlen < 0) err(EX_IOERR, "write");
+			}
+		}
+
+		int status;
+		pid_t dead = waitpid(pid, &status, WNOHANG);
+		if (dead < 0) err(EX_OSERR, "waitpid");
+		if (dead) return WIFEXITED(status) ? WEXITSTATUS(status) : EX_SOFTWARE;
+	}
+}
diff --git a/bin/qf.c b/bin/qf.c
new file mode 100644
index 00000000..1fbf48b9
--- /dev/null
+++ b/bin/qf.c
@@ -0,0 +1,294 @@
+/* Copyright (C) 2022  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <ctype.h>
+#include <curses.h>
+#include <err.h>
+#include <fcntl.h>
+#include <poll.h>
+#include <regex.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/wait.h>
+#include <sysexits.h>
+#include <unistd.h>
+
+enum Type {
+	File,
+	Match,
+	Context,
+	Text,
+};
+
+struct Line {
+	enum Type type;
+	char *path;
+	unsigned nr;
+	char *text;
+	regmatch_t match;
+};
+
+static struct {
+	struct Line *ptr;
+	size_t len, cap;
+} lines;
+
+static void push(struct Line line) {
+	if (lines.len == lines.cap) {
+		lines.cap = (lines.cap ? lines.cap * 2 : 256);
+		lines.ptr = realloc(lines.ptr, sizeof(*lines.ptr) * lines.cap);
+		if (!lines.ptr) err(EX_OSERR, "realloc");
+	}
+	lines.ptr[lines.len++] = line;
+}
+
+static const char *pattern;
+static regex_t regex;
+
+static void parse(struct Line line) {
+	line.path = strsep(&line.text, ":");
+	if (!line.text) {
+		line.type = Text;
+		line.text = line.path;
+		if (lines.len) line.path = lines.ptr[lines.len-1].path;
+		push(line);
+		return;
+	}
+	char *rest;
+	line.nr = strtoul(line.text, &rest, 10);
+	struct Line prev = {0};
+	if (lines.len) prev = lines.ptr[lines.len-1];
+	if (!prev.path || strcmp(line.path, prev.path)) {
+		if (lines.len) push((struct Line) { .type = Text, .text = " " });
+		line.type = File;
+		push(line);
+	}
+	if (rest > line.text && rest[0] == ':') {
+		line.type = Match;
+		line.text = &rest[1];
+	} else if (rest > line.text && rest[0] == '-') {
+		line.type = Context;
+		line.text = &rest[1];
+	} else {
+		line.type = Text;
+	}
+	if (line.type == Match && pattern) {
+		regexec(&regex, line.text, 1, &line.match, 0);
+	}
+	push(line);
+}
+
+enum {
+	Path = 1,
+	Number = 2,
+	Highlight = 3,
+};
+
+static void curse(void) {
+	set_term(newterm(NULL, stdout, stderr));
+	cbreak();
+	noecho();
+	nodelay(stdscr, true);
+	TABSIZE = 4;
+	curs_set(0);
+	start_color();
+	use_default_colors();
+	init_pair(Path, COLOR_GREEN, -1);
+	init_pair(Number, COLOR_YELLOW, -1);
+	init_pair(Highlight, COLOR_MAGENTA, -1);
+}
+
+static size_t top;
+static size_t cur;
+static bool reading = true;
+
+static void draw(void) {
+	int y = 0, x = 0;
+	for (int i = 0; i < LINES; ++i) {
+		move(i, 0);
+		clrtoeol();
+		if (top + i >= lines.len) {
+			addstr(reading ? "..." : !lines.len ? "No results" : "");
+			break;
+		}
+		struct Line line = lines.ptr[top + i];
+		if (top + i == cur) {
+			getyx(stdscr, y, x);
+			attron(A_REVERSE);
+		} else {
+			attroff(A_REVERSE);
+		}
+		switch (line.type) {
+			break; case File: {
+				color_set(Path, NULL);
+				addstr(line.path);
+				color_set(0, NULL);
+			}
+			break; case Match: {
+				color_set(Number, NULL);
+				printw("%u", line.nr);
+				color_set(0, NULL);
+				addch(':');
+				if (line.match.rm_so == line.match.rm_eo) {
+					addstr(line.text);
+					break;
+				}
+				addnstr(line.text, line.match.rm_so);
+				color_set(Highlight, NULL);
+				addnstr(
+					&line.text[line.match.rm_so],
+					line.match.rm_eo - line.match.rm_so
+				);
+				color_set(0, NULL);
+				addstr(&line.text[line.match.rm_eo]);
+			}
+			break; case Context: {
+				color_set(Number, NULL);
+				printw("%u", line.nr);
+				color_set(0, NULL);
+				addch('-');
+				addstr(line.text);
+			}
+			break; case Text: addstr(line.text);
+		}
+	}
+	move(y, x);
+	refresh();
+}
+
+static void edit(struct Line line) {
+	char cmd[32];
+	snprintf(cmd, sizeof(cmd), "+%u", (line.nr ? line.nr : 1));
+	const char *editor = getenv("EDITOR");
+	if (!editor) editor = "vi";
+	pid_t pid = fork();
+	if (pid < 0) err(EX_OSERR, "fork");
+	if (!pid) {
+		dup2(STDERR_FILENO, STDIN_FILENO);
+		execlp(editor, editor, cmd, line.path, NULL);
+		err(EX_CONFIG, "%s", editor);
+	}
+	int status;
+	pid = waitpid(pid, &status, 0);
+	if (pid < 0) err(EX_OSERR, "waitpid");
+}
+
+static void toPrev(enum Type type) {
+	if (!cur) return;
+	size_t prev = cur - 1;
+	while (prev && lines.ptr[prev].type != type) {
+		prev--;
+	}
+	if (lines.ptr[prev].type == type) {
+		cur = prev;
+	}
+}
+
+static void toNext(enum Type type) {
+	size_t next = cur + 1;
+	while (next < lines.len && lines.ptr[next].type != type) {
+		next++;
+	}
+	if (next < lines.len && lines.ptr[next].type == type) {
+		cur = next;
+	}
+}
+
+static void input(void) {
+	char ch;
+	while (ERR != (ch = getch())) {
+		switch (ch) {
+			break; case '\n': {
+				if (lines.ptr[cur].type == Text) break;
+				endwin();
+				edit(lines.ptr[cur]);
+				refresh();
+			}
+			break; case '{': toPrev(File);
+			break; case '}': toNext(File);
+			break; case 'G': cur = lines.len - 1;
+			break; case 'N': toPrev(Match);
+			break; case 'g': cur = 0;
+			break; case 'j': if (cur + 1 < lines.len) cur++;
+			break; case 'k': if (cur) cur--;
+			break; case 'n': toNext(Match);
+			break; case 'q': {
+				endwin();
+				exit(EX_OK);
+			}
+			break; case 'r': clearok(stdscr, true);
+		}
+	}
+	if (cur < top) top = cur;
+	if (cur >= top + LINES) top = cur - LINES + 1;
+}
+
+int main(int argc, char *argv[]) {
+	if (isatty(STDIN_FILENO)) errx(EX_USAGE, "no input");
+	if (argc > 1) {
+		pattern = argv[1];
+		int flags = REG_EXTENDED | REG_ICASE;
+		for (const char *ch = pattern; *ch; ++ch) {
+			if (isupper(*ch)) {
+				flags &= ~REG_ICASE;
+				break;
+			}
+		}
+		int error = regcomp(&regex, pattern, flags);
+		if (error) errx(EX_USAGE, "invalid pattern");
+	}
+	curse();
+	draw();
+	struct pollfd fds[2] = {
+		{ .fd = STDERR_FILENO, .events = POLLIN },
+		{ .fd = STDIN_FILENO, .events = POLLIN },
+	};
+	size_t len = 0;
+	size_t cap = 4096;
+	char *buf = malloc(cap);
+	if (!buf) err(EX_OSERR, "malloc");
+	while (poll(fds, (reading ? 2 : 1), -1)) {
+		if (fds[0].revents) {
+			input();
+		}
+		if (reading && fds[1].revents) {
+			ssize_t n = read(fds[1].fd, &buf[len], cap - len);
+			if (n < 0) err(EX_IOERR, "read");
+			if (!n) reading = false;
+			len += n;
+			char *ptr = buf;
+			for (
+				char *nl;
+				(nl = memchr(ptr, '\n', &buf[len] - ptr));
+				ptr = &nl[1]
+			) {
+				struct Line line = { .text = strndup(ptr, nl - ptr) };
+				if (!line.text) err(EX_OSERR, "strndup");
+				parse(line);
+			}
+			len -= ptr - buf;
+			memmove(buf, ptr, len);
+			if (len == cap) {
+				cap *= 2;
+				buf = realloc(buf, cap);
+				if (!buf) err(EX_OSERR, "realloc");
+			}
+		}
+		draw();
+	}
+	err(EX_IOERR, "poll");
+}
diff --git a/bin/quick.c b/bin/quick.c
new file mode 100644
index 00000000..d814873d
--- /dev/null
+++ b/bin/quick.c
@@ -0,0 +1,163 @@
+/* Copyright (C) 2021  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <arpa/inet.h>
+#include <err.h>
+#include <fcntl.h>
+#include <netdb.h>
+#include <netinet/in.h>
+#include <poll.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/socket.h>
+#include <sys/wait.h>
+#include <sysexits.h>
+#include <unistd.h>
+
+static void request(int sock, char *argv[]) {
+	struct pollfd pfd = { .fd = sock, .events = POLLIN };
+	int nfds = poll(&pfd, 1, -1);
+	if (nfds < 0) err(EX_OSERR, "poll");
+
+	char buf[4096];
+	ssize_t len = recv(sock, buf, sizeof(buf)-1, MSG_PEEK);
+	if (len < 0) {
+		warn("recv");
+		return;
+	}
+	char *blank = memmem(buf, len, "\r\n\r\n", 4);
+	if (!blank) {
+		warnx("can't find end of request headers in peek");
+		return;
+	}
+	len = recv(sock, buf, &blank[4] - buf, 0);
+	if (len < 0) {
+		warn("recv");
+		return;
+	}
+	buf[len] = '\0';
+
+	char *ptr = buf;
+	char *req = strsep(&ptr, "\r\n");
+	char *method = strsep(&req, " ");
+	char *query = strsep(&req, " ");
+	char *path = strsep(&query, "?");
+	char *proto = strsep(&req, " ");
+	if (!method || !path || !proto) {
+		warnx("invalid request line");
+		return;
+	}
+	setenv("REQUEST_METHOD", method, 1);
+	setenv("PATH_INFO", path, 1);
+	setenv("QUERY_STRING", (query ? query : ""), 1);
+	setenv("SERVER_PROTOCOL", proto, 1);
+
+	unsetenv("CONTENT_TYPE");
+	unsetenv("CONTENT_LENGTH");
+	unsetenv("HTTP_HOST");
+	while (ptr) {
+		char *value = strsep(&ptr, "\r\n");
+		if (!value[0]) continue;
+		char *header = strsep(&value, ":");
+		if (!header || !value++) {
+			warnx("invalid header");
+			return;
+		}
+		if (!strcasecmp(header, "Content-Type")) {
+			setenv("CONTENT_TYPE", value, 1);
+		} else if (!strcasecmp(header, "Content-Length")) {
+			setenv("CONTENT_LENGTH", value, 1);
+		} else if (!strcasecmp(header, "Host")) {
+			setenv("HTTP_HOST", value, 1);
+		}
+	}
+
+	dprintf(sock, "HTTP/1.1 200 OK\nConnection: close\n");
+	pid_t pid = fork();
+	if (pid < 0) err(EX_OSERR, "fork");
+	if (!pid) {
+		dup2(sock, STDIN_FILENO);
+		dup2(sock, STDOUT_FILENO);
+		execv(argv[0], argv);
+		warn("%s", argv[0]);
+		_exit(127);
+	}
+
+	int status;
+	pid = wait(&status);
+	if (pid < 0) err(EX_OSERR, "wait");
+	if (WIFEXITED(status) && WEXITSTATUS(status)) {
+		warnx("%s exited %d", argv[0], WEXITSTATUS(status));
+	} else if (WIFSIGNALED(status)) {
+		warnx("%s killed %d", argv[0], WTERMSIG(status));
+	}
+}
+
+int main(int argc, char *argv[]) {
+	short port = 0;
+	for (int opt; 0 < (opt = getopt(argc, argv, "p:"));) {
+		switch (opt) {
+			break; case 'p': port = atoi(optarg);
+			break; default:  return EX_USAGE;
+		}
+	}
+	if (optind == argc) errx(EX_USAGE, "script required");
+
+	int server = socket(AF_INET, SOCK_STREAM, 0);
+	if (server < 0) err(EX_OSERR, "socket");
+	fcntl(server, F_SETFD, FD_CLOEXEC);
+
+	int on = 1;
+	setsockopt(server, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
+
+	struct sockaddr_in addr = {
+		.sin_family = AF_INET,
+		.sin_port = htons(port),
+		.sin_addr.s_addr = htonl(INADDR_LOOPBACK),
+	};
+	socklen_t addrlen = sizeof(addr);
+	int error = 0
+		|| bind(server, (struct sockaddr *)&addr, addrlen)
+		|| getsockname(server, (struct sockaddr *)&addr, &addrlen)
+		|| listen(server, -1);
+	if (error) err(EX_UNAVAILABLE, "%hd", port);
+
+	char host[NI_MAXHOST], serv[NI_MAXSERV];
+	error = getnameinfo(
+		(struct sockaddr *)&addr, addrlen,
+		host, sizeof(host), serv, sizeof(serv),
+		NI_NOFQDN | NI_NUMERICSERV
+	);
+	if (error) errx(EX_UNAVAILABLE, "getnameinfo: %s", gai_strerror(error));
+	printf("http://%s:%s/\n", host, serv);
+	fflush(stdout);
+
+	setenv("SERVER_SOFTWARE", "quick (and dirty)", 1);
+	setenv("GATEWAY_INTERFACE", "CGI/1.1", 1);
+	setenv("SERVER_NAME", host, 1);
+	setenv("SERVER_PORT", serv, 1);
+	setenv("REMOTE_ADDR", "127.0.0.1", 1);
+	setenv("REMOTE_HOST", host, 1);
+	setenv("SCRIPT_NAME", "/", 1);
+
+	for (int sock; 0 <= (sock = accept(server, NULL, NULL)); close(sock)) {
+		fcntl(sock, F_SETFD, FD_CLOEXEC);
+		request(sock, &argv[optind]);
+	}
+	err(EX_IOERR, "accept");
+}
diff --git a/bin/relay.c b/bin/relay.c
new file mode 100644
index 00000000..fd799462
--- /dev/null
+++ b/bin/relay.c
@@ -0,0 +1,218 @@
+/* Copyright (C) 2019  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with LibreSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of LibreSSL used as well as that of the
+ * covered work.
+ */
+
+#include <err.h>
+#include <netdb.h>
+#include <netinet/in.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sysexits.h>
+#include <tls.h>
+#include <unistd.h>
+
+#ifdef __FreeBSD__
+#include <sys/capsicum.h>
+#endif
+
+static void clientWrite(struct tls *client, const char *ptr, size_t len) {
+	while (len) {
+		ssize_t ret = tls_write(client, ptr, len);
+		if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT) continue;
+		if (ret < 0) errx(EX_IOERR, "tls_write: %s", tls_error(client));
+		ptr += ret;
+		len -= ret;
+	}
+}
+
+static void clientFormat(struct tls *client, const char *format, ...) {
+	char buf[1024];
+	va_list ap;
+	va_start(ap, format);
+	int len = vsnprintf(buf, sizeof(buf), format, ap);
+	va_end(ap);
+	if ((size_t)len > sizeof(buf) - 1) errx(EX_DATAERR, "message too large");
+	clientWrite(client, buf, len);
+}
+
+static void clientHandle(struct tls *client, const char *chan, char *line) {
+	char *prefix = NULL;
+	if (line[0] == ':') {
+		prefix = strsep(&line, " ") + 1;
+		if (!line) errx(EX_PROTOCOL, "unexpected eol");
+	}
+
+	char *command = strsep(&line, " ");
+	if (!strcmp(command, "001") || !strcmp(command, "INVITE")) {
+		clientFormat(client, "JOIN :%s\r\n", chan);
+	} else if (!strcmp(command, "PING")) {
+		clientFormat(client, "PONG %s\r\n", line);
+	}
+	if (strcmp(command, "PRIVMSG") && strcmp(command, "NOTICE")) return;
+
+	if (!prefix) errx(EX_PROTOCOL, "message without prefix");
+	char *nick = strsep(&prefix, "!");
+
+	if (!line) errx(EX_PROTOCOL, "message without destination");
+	char *dest = strsep(&line, " ");
+	if (strcmp(dest, chan)) return;
+
+	if (!line || line[0] != ':') errx(EX_PROTOCOL, "message without message");
+	line = &line[1];
+
+	if (!strncmp(line, "\1ACTION ", 8)) {
+		line = &line[8];
+		size_t len = strcspn(line, "\1");
+		printf("* %c\u200C%s %.*s\n", nick[0], &nick[1], (int)len, line);
+	} else if (command[0] == 'N') {
+		printf("-%c\u200C%s- %s\n", nick[0], &nick[1], line);
+	} else {
+		printf("<%c\u200C%s> %s\n", nick[0], &nick[1], line);
+	}
+}
+
+#ifdef __FreeBSD__
+static void limit(int fd, const cap_rights_t *rights) {
+	int error = cap_rights_limit(fd, rights);
+	if (error) err(EX_OSERR, "cap_rights_limit");
+}
+#endif
+
+int main(int argc, char *argv[]) {
+	int error;
+
+	if (argc < 5) return EX_USAGE;
+	const char *host = argv[1];
+	const char *port = argv[2];
+	const char *nick = argv[3];
+	const char *chan = argv[4];
+
+	setlinebuf(stdout);
+	signal(SIGPIPE, SIG_IGN);
+
+	struct tls_config *config = tls_config_new();
+	if (!config) errx(EX_SOFTWARE, "tls_config_new");
+
+	error = tls_config_set_ciphers(config, "compat");
+	if (error) {
+		errx(EX_SOFTWARE, "tls_config_set_ciphers: %s", tls_config_error(config));
+	}
+
+	struct tls *client = tls_client();
+	if (!client) errx(EX_SOFTWARE, "tls_client");
+
+	error = tls_configure(client, config);
+	if (error) errx(EX_SOFTWARE, "tls_configure: %s", tls_error(client));
+	tls_config_free(config);
+
+	struct addrinfo *head;
+	struct addrinfo hints = {
+		.ai_family = AF_UNSPEC,
+		.ai_socktype = SOCK_STREAM,
+		.ai_protocol = IPPROTO_TCP,
+	};
+	error = getaddrinfo(host, port, &hints, &head);
+	if (error) errx(EX_NOHOST, "getaddrinfo: %s", gai_strerror(error));
+
+	int sock = -1;
+	for (struct addrinfo *ai = head; ai; ai = ai->ai_next) {
+		sock = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
+		if (sock < 0) err(EX_OSERR, "socket");
+
+		error = connect(sock, ai->ai_addr, ai->ai_addrlen);
+		if (!error) break;
+
+		close(sock);
+		sock = -1;
+	}
+	if (sock < 0) err(EX_UNAVAILABLE, "connect");
+	freeaddrinfo(head);
+
+	error = tls_connect_socket(client, sock, host);
+	if (error) errx(EX_PROTOCOL, "tls_connect: %s", tls_error(client));
+
+#ifdef __FreeBSD__
+	error = cap_enter();
+	if (error) err(EX_OSERR, "cap_enter");
+
+	cap_rights_t rights;
+	cap_rights_init(&rights, CAP_WRITE);
+	limit(STDOUT_FILENO, &rights);
+	limit(STDERR_FILENO, &rights);
+
+	cap_rights_init(&rights, CAP_EVENT, CAP_READ);
+	limit(STDIN_FILENO, &rights);
+
+	cap_rights_set(&rights, CAP_WRITE);
+	limit(sock, &rights);
+#endif
+
+	clientFormat(client, "NICK :%s\r\nUSER %s 0 * :%s\r\n", nick, nick, nick);
+
+	char *input = NULL;
+	size_t cap = 0;
+
+	char buf[4096];
+	size_t len = 0;
+
+	struct pollfd fds[2] = {
+		{ .events = POLLIN, .fd = STDIN_FILENO },
+		{ .events = POLLIN, .fd = sock },
+	};
+	while (0 < poll(fds, 2, -1)) {
+		if (fds[0].revents) {
+			ssize_t len = getline(&input, &cap, stdin);
+			if (len < 0) err(EX_IOERR, "getline");
+			input[len - 1] = '\0';
+			clientFormat(client, "NOTICE %s :%s\r\n", chan, input);
+		}
+		if (!fds[1].revents) continue;
+
+		ssize_t read = tls_read(client, &buf[len], sizeof(buf) - len);
+		if (read == TLS_WANT_POLLIN || read == TLS_WANT_POLLOUT) continue;
+		if (read < 0) errx(EX_IOERR, "tls_read: %s", tls_error(client));
+		if (!read) return EX_UNAVAILABLE;
+		len += read;
+
+		char *crlf;
+		char *line = buf;
+		for (;;) {
+			crlf = memmem(line, &buf[len] - line, "\r\n", 2);
+			if (!crlf) break;
+			crlf[0] = '\0';
+			clientHandle(client, chan, line);
+			line = &crlf[2];
+		}
+		len -= line - buf;
+		memmove(buf, line, len);
+	}
+	err(EX_IOERR, "poll");
+}
diff --git a/bin/scheme.c b/bin/scheme.c
new file mode 100644
index 00000000..2bae8f82
--- /dev/null
+++ b/bin/scheme.c
@@ -0,0 +1,278 @@
+/* Copyright (C) 2018, 2019  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <err.h>
+#include <math.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <unistd.h>
+
+#include "png.h"
+
+typedef unsigned uint;
+typedef unsigned char byte;
+
+struct HSV {
+	double h, s, v;
+};
+
+struct RGB {
+	byte r, g, b;
+};
+
+static struct RGB convert(struct HSV o) {
+	double c = o.v * o.s;
+	double h = o.h / 60.0;
+	double x = c * (1.0 - fabs(fmod(h, 2.0) - 1.0));
+	double m = o.v - c;
+	double r = m, g = m, b = m;
+	if      (h <= 1.0) { r += c; g += x; }
+	else if (h <= 2.0) { r += x; g += c; }
+	else if (h <= 3.0) { g += c; b += x; }
+	else if (h <= 4.0) { g += x; b += c; }
+	else if (h <= 5.0) { r += x; b += c; }
+	else if (h <= 6.0) { r += c; b += x; }
+	return (struct RGB) { r * 255.0, g * 255.0, b * 255.0 };
+}
+
+static const struct HSV
+R = {   0.0, 1.0, 1.0 },
+Y = {  60.0, 1.0, 1.0 },
+G = { 120.0, 1.0, 1.0 },
+C = { 180.0, 1.0, 1.0 },
+B = { 240.0, 1.0, 1.0 },
+M = { 300.0, 1.0, 1.0 };
+
+static struct HSV x(struct HSV o, double hd, double sf, double vf) {
+	return (struct HSV) {
+		fmod(o.h + hd, 360.0),
+		fmin(o.s * sf, 1.0),
+		fmin(o.v * vf, 1.0),
+	};
+}
+
+enum {
+	Black, Red, Green, Yellow, Blue, Magenta, Cyan, White,
+	Dark = 0,
+	Light = 8,
+	Background = 16,
+	Foreground,
+	Bold,
+	Selection,
+	Cursor,
+	SchemeLen,
+};
+static struct HSV scheme[SchemeLen];
+static struct HSV *dark = &scheme[Dark];
+static struct HSV *light = &scheme[Light];
+
+static void generate(void) {
+	light[Black]   = x(R, +45.0, 0.3, 0.3);
+	light[Red]     = x(R, +10.0, 0.9, 0.8);
+	light[Green]   = x(G, -55.0, 0.8, 0.6);
+	light[Yellow]  = x(Y, -20.0, 0.8, 0.8);
+	light[Blue]    = x(B, -55.0, 0.4, 0.5);
+	light[Magenta] = x(M, +45.0, 0.4, 0.6);
+	light[Cyan]    = x(C, -60.0, 0.3, 0.6);
+	light[White]   = x(R, +45.0, 0.3, 0.8);
+
+	dark[Black] = x(light[Black], 0.0, 1.0, 0.3);
+	dark[White] = x(light[White], 0.0, 1.0, 0.75);
+	for (uint i = Red; i < White; ++i) {
+		dark[i] = x(light[i], 0.0, 1.0, 0.8);
+	}
+
+	scheme[Background] = x(dark[Black],  0.0, 1.0, 0.9);
+	scheme[Foreground] = x(light[White], 0.0, 1.0, 0.9);
+	scheme[Bold]       = x(light[White], 0.0, 1.0, 1.0);
+	scheme[Selection]  = x(light[Red], +10.0, 1.0, 0.8);
+	scheme[Cursor]     = x(dark[White],  0.0, 1.0, 0.8);
+}
+
+static void swap(struct HSV *a, struct HSV *b) {
+	struct HSV c = *a;
+	*a = *b;
+	*b = c;
+}
+
+static void invert(void) {
+	swap(&dark[Black], &light[White]);
+	swap(&dark[White], &light[Black]);
+}
+
+typedef void OutputFn(const struct HSV *hsv, uint len);
+
+static void outputHSV(const struct HSV *hsv, uint len) {
+	for (uint i = 0; i < len; ++i) {
+		printf("%g,%g,%g\n", hsv[i].h, hsv[i].s, hsv[i].v);
+	}
+}
+
+#define FORMAT_RGB "%02hhX%02hhX%02hhX"
+
+static void outputRGB(const struct HSV *hsv, uint len) {
+	for (uint i = 0; i < len; ++i) {
+		struct RGB rgb = convert(hsv[i]);
+		printf(FORMAT_RGB "\n", rgb.r, rgb.g, rgb.b);
+	}
+}
+
+static void outputLinux(const struct HSV *hsv, uint len) {
+	for (uint i = 0; i < len; ++i) {
+		struct RGB rgb = convert(hsv[i]);
+		printf("\x1B]P%X" FORMAT_RGB, i, rgb.r, rgb.g, rgb.b);
+	}
+}
+
+static const char *Enum[SchemeLen] = {
+	"DarkBlack", "DarkRed", "DarkGreen", "DarkYellow",
+	"DarkBlue", "DarkMagenta", "DarkCyan", "DarkWhite",
+	"LightBlack", "LightRed", "LightGreen", "LightYellow",
+	"LightBlue", "LightMagenta", "LightCyan", "LightWhite",
+	"Background", "Foreground", "Bold", "Selection", "Cursor",
+};
+
+static void outputEnum(const struct HSV *hsv, uint len) {
+	printf("enum {\n");
+	for (uint i = 0; i < len; ++i) {
+		struct RGB rgb = convert(hsv[i]);
+		printf("\t%s = 0x" FORMAT_RGB ",\n", Enum[i], rgb.r, rgb.g, rgb.b);
+	}
+	printf("};\n");
+}
+
+#define FORMAT_X "rgb:%02hhX/%02hhX/%02hhX"
+
+static const char *Resources[SchemeLen] = {
+	[Background] = "background",
+	[Foreground] = "foreground",
+	[Bold] = "colorBD",
+	[Selection] = "highlightColor",
+	[Cursor] = "cursorColor",
+};
+
+static void outputXTerm(const struct HSV *hsv, uint len) {
+	for (uint i = 0; i < len; ++i) {
+		struct RGB rgb = convert(hsv[i]);
+		if (Resources[i]) {
+			printf("XTerm*%s: " FORMAT_X "\n", Resources[i], rgb.r, rgb.g, rgb.b);
+		} else {
+			printf("XTerm*color%u: " FORMAT_X "\n", i, rgb.r, rgb.g, rgb.b);
+		}
+	}
+}
+
+static const char *Mintty[SchemeLen] = {
+	"Black", "Red", "Green", "Yellow",
+	"Blue", "Magenta", "Cyan", "White",
+	"BoldBlack", "BoldRed", "BoldGreen", "BoldYellow",
+	"BoldBlue", "BoldMagenta", "BoldCyan", "BoldWhite",
+	[Background] = "BackgroundColour",
+	[Foreground] = "ForegroundColour",
+	[Cursor]     = "CursorColour",
+};
+
+static void outputMintty(const struct HSV *hsv, uint len) {
+	for (uint i = 0; i < len; ++i) {
+		if (!Mintty[i]) continue;
+		struct RGB rgb = convert(hsv[i]);
+		printf("%s=%hhu,%hhu,%hhu\n", Mintty[i], rgb.r, rgb.g, rgb.b);
+	}
+}
+
+static void outputCSS(const struct HSV *hsv, uint len) {
+	printf(":root {\n");
+	for (uint i = 0; i < len; ++i) {
+		struct RGB rgb = convert(hsv[i]);
+		printf("\t--ansi%u: #" FORMAT_RGB ";\n", i, rgb.r, rgb.g, rgb.b);
+	}
+	printf("}\n");
+	for (uint i = 0; i < len; ++i) {
+		printf(
+			".fg%u { color: var(--ansi%u); }\n"
+			".bg%u { background-color: var(--ansi%u); }\n",
+			i, i, i, i
+		);
+	}
+}
+
+enum {
+	SwatchWidth = 64,
+	SwatchHeight = 64,
+	SwatchCols = 8,
+};
+
+static void outputPNG(const struct HSV *hsv, uint len) {
+	uint rows = (len + SwatchCols - 1) / SwatchCols;
+	uint width = SwatchWidth * SwatchCols;
+	uint height = SwatchHeight * rows;
+	pngHead(stdout, width, height, 8, PNGIndexed);
+
+	struct RGB pal[len];
+	for (uint i = 0; i < len; ++i) {
+		pal[i] = convert(hsv[i]);
+	}
+	pngPalette(stdout, (byte *)pal, sizeof(pal));
+
+	byte data[height][1 + width];
+	memset(data, 0, sizeof(data));
+	for (uint y = 0; y < height; ++y) {
+		data[y][0] = (y % SwatchHeight ? PNGUp : PNGSub);
+	}
+	for (uint i = 0; i < len; ++i) {
+		uint y = SwatchHeight * (i / SwatchCols);
+		uint x = SwatchWidth * (i % SwatchCols);
+		data[y][1 + x] = (x ? 1 : i);
+	}
+	pngData(stdout, (byte *)data, sizeof(data));
+	pngTail(stdout);
+}
+
+int main(int argc, char *argv[]) {
+	generate();
+
+	OutputFn *output = outputRGB;
+	const struct HSV *hsv = scheme;
+	uint len = 16;
+
+	int opt;
+	while (0 < (opt = getopt(argc, argv, "Xacghilmp:stx"))) {
+		switch (opt) {
+			break; case 'X': output = outputXTerm;
+			break; case 'a': len = 16;
+			break; case 'c': output = outputEnum;
+			break; case 'g': output = outputPNG;
+			break; case 'h': output = outputHSV;
+			break; case 'i': invert();
+			break; case 'l': output = outputLinux;
+			break; case 'm': output = outputMintty;
+			break; case 'p': {
+				uint p = strtoul(optarg, NULL, 0);
+				if (p >= SchemeLen) return EX_USAGE;
+				hsv = &scheme[p];
+				len = 1;
+			}
+			break; case 's': output = outputCSS;
+			break; case 't': len = SchemeLen;
+			break; case 'x': output = outputRGB;
+			break; default:  return EX_USAGE;
+		}
+	}
+
+	output(hsv, len);
+}
diff --git a/bin/sh.l b/bin/sh.l
new file mode 100644
index 00000000..8f0f7723
--- /dev/null
+++ b/bin/sh.l
@@ -0,0 +1,181 @@
+/* Copyright (C) 2021  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+%option prefix="sh"
+%option noinput nounput noyywrap
+
+%{
+#include <assert.h>
+#include <stdbool.h>
+#include <string.h>
+#include "hilex.h"
+
+enum { Cap = 64 };
+static int len = 1;
+static int stack[Cap];
+static int push(int val) {
+	if (len < Cap) stack[len++] = val;
+	return val;
+}
+static int pop(void) {
+	if (len > 1) len--;
+	return stack[len-1];
+}
+%}
+
+%s Param Command Arith Backtick Subshell
+%x DQuote HereDocDel HereDoc HereDocLit
+
+word [[:alnum:]_.-]+
+param [^:=?+%#{}-]+
+reserved [!{}]|else|do|elif|for|done|fi|then|until|while|if|case|esac
+
+%%
+	static bool first;
+	static char *delimiter;
+
+[[:blank:]]+ { return Normal; }
+
+"\\". { return Escape; }
+
+<INITIAL,DQuote,HereDoc,Param,Command,Arith,Subshell>{
+	"$"[*@#?$!0-9-] |
+	"$"[_[:alpha:][_[:alnum:]]* |
+	"${"[#]?{param}"}" {
+		return Subst;
+	}
+	"${"{param} {
+		BEGIN(push(Param));
+		return Subst;
+	}
+	"$(" {
+		BEGIN(push(Command));
+		return Subst;
+	}
+	"$((" {
+		BEGIN(push(Arith));
+		return Subst;
+	}
+	"`" {
+		BEGIN(push(Backtick));
+		return Subst;
+	}
+	"(" {
+		BEGIN(push(Subshell));
+		return Normal;
+	}
+}
+<Param>"}" |
+<Command>")" |
+<Arith>"))" |
+<Backtick>"`" {
+	BEGIN(pop());
+	return Subst;
+}
+<Subshell>")" {
+	BEGIN(pop());
+	return Normal;
+}
+
+"\n" {
+	first = true;
+	return Normal;
+}
+[&();|]|"&&"|";;"|"||" {
+	first = true;
+	return Operator;
+}
+[0-9]?([<>]"&"?|">|"|">>"|"<>") {
+	return Operator;
+}
+
+{reserved} {
+	if (first) {
+		first = false;
+		return Keyword;
+	}
+	return Normal;
+}
+
+{word}/[[:blank:]]*"()" { return Ident; }
+
+[0-9]?("<<"|"<<-") {
+	BEGIN(push(HereDocDel));
+	return Operator;
+}
+<HereDocDel>{
+	[[:blank:]]+ { return Normal; }
+	{word} {
+		delimiter = strdup(yytext);
+		assert(delimiter);
+		BEGIN(pop(), push(HereDoc));
+		return Ident;
+	}
+	"'"{word}"'" {
+		delimiter = strndup(&yytext[1], strlen(yytext)-2);
+		assert(delimiter);
+		BEGIN(pop(), push(HereDocLit));
+		return Ident;
+	}
+}
+<HereDoc,HereDocLit>{
+	^"\t"*{word} {
+		if (strcmp(&yytext[strspn(yytext, "\t")], delimiter)) REJECT;
+		free(delimiter);
+		BEGIN(pop());
+		return Ident;
+	}
+}
+<HereDoc>{
+	[^$`\n]+ { return String; }
+	.|\n { return String; }
+}
+<HereDocLit>{
+	.*\n { return String; }
+}
+
+"'"[^'']*"'" { return String; }
+
+"\""/[^$`\\] {
+	BEGIN(push(DQuote));
+	yymore();
+}
+"\"" {
+	BEGIN(push(DQuote));
+	return String;
+}
+
+<DQuote>{
+	[^\\$`""]*"\"" {
+		BEGIN(pop());
+		return String;
+	}
+	"\\"[$`""\\\n] { return Escape; }
+	[^\\$`""]+|. { return String; }
+}
+
+<INITIAL,Command,Backtick,Arith>"#".* { return Comment; }
+
+{word} {
+	first = false;
+	return Normal;
+}
+
+.|\n { return Normal; }
+
+%%
+
+const struct Lexer LexSh = { yylex, &yyin, &yytext };
diff --git a/bin/shotty.l b/bin/shotty.l
new file mode 100644
index 00000000..dcac43ec
--- /dev/null
+++ b/bin/shotty.l
@@ -0,0 +1,597 @@
+/* Copyright (C) 2019, 2021  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+%option noinput nounput noyywrap
+
+%{
+
+#include <assert.h>
+#include <err.h>
+#include <locale.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <sysexits.h>
+#include <unistd.h>
+#include <wchar.h>
+
+#define Q(...) #__VA_ARGS__
+#define BIT(x) x##Bit, x = 1 << x##Bit, x##Bit_ = x##Bit
+
+#define ENUM_CODE \
+	X(BS) \
+	X(CHA) \
+	X(CNL) \
+	X(CPL) \
+	X(CR) \
+	X(CUB) \
+	X(CUD) \
+	X(CUF) \
+	X(CUP) \
+	X(CUU) \
+	X(DCH) \
+	X(DECRC) \
+	X(DECRST) \
+	X(DECSC) \
+	X(DECSET) \
+	X(DECSTBM) \
+	X(DL) \
+	X(ECH) \
+	X(ED) \
+	X(EL) \
+	X(HT) \
+	X(ICH) \
+	X(IL) \
+	X(MC) \
+	X(NL) \
+	X(RI) \
+	X(RM) \
+	X(SD) \
+	X(SGR) \
+	X(SM) \
+	X(SU) \
+	X(VPA)
+
+enum Code {
+	Data = 1,
+#define X(code) code,
+	ENUM_CODE
+#undef X
+};
+
+static enum {
+	USASCII,
+	DECSpecial,
+} g0;
+
+static const wchar_t AltCharset[128] = {
+	['`'] = L'\u25C6', ['a'] = L'\u2592', ['f'] = L'\u00B0', ['g'] = L'\u00B1',
+	['i'] = L'\u240B', ['j'] = L'\u2518', ['k'] = L'\u2510', ['l'] = L'\u250C',
+	['m'] = L'\u2514', ['n'] = L'\u253C', ['o'] = L'\u23BA', ['p'] = L'\u23BB',
+	['q'] = L'\u2500', ['r'] = L'\u23BC', ['s'] = L'\u23BD', ['t'] = L'\u251C',
+	['u'] = L'\u2524', ['v'] = L'\u2534', ['w'] = L'\u252C', ['x'] = L'\u2502',
+	['y'] = L'\u2264', ['z'] = L'\u2265', ['{'] = L'\u03C0', ['|'] = L'\u2260',
+	['}'] = L'\u00A3', ['~'] = L'\u00B7',
+};
+
+static int pn;
+static int ps[16];
+static wchar_t ch;
+
+%}
+
+ESC \x1B
+
+%x CSI CSI_LT CSI_EQ CSI_GT CSI_QM
+%x OSC
+
+%%
+	pn = 0;
+
+{ESC}"["	BEGIN(CSI);
+{ESC}"[<"	BEGIN(CSI_LT);
+{ESC}"[="	BEGIN(CSI_EQ);
+{ESC}"[>"	BEGIN(CSI_GT);
+{ESC}"[?"	BEGIN(CSI_QM);
+{ESC}"]"	BEGIN(OSC);
+
+<CSI,CSI_LT,CSI_EQ,CSI_GT,CSI_QM>{
+	[0-9]+;?	if (pn < 16) ps[pn++] = atoi(yytext);
+	;			if (pn < 16) ps[pn++] = 0;
+}
+
+<OSC>{
+	\x07	BEGIN(0);
+	{ESC}\\	BEGIN(0);
+	.|\n	;
+}
+
+\b	return BS;
+\t	return HT;
+\n	return NL;
+\r	return CR;
+
+{ESC}7	return DECSC;
+{ESC}8	return DECRC;
+{ESC}=	// DECKPAM
+{ESC}>	// DECKPNM
+{ESC}M	return RI;
+
+{ESC}"(0"	g0 = DECSpecial;
+{ESC}"(B"	g0 = USASCII;
+
+<CSI>@	BEGIN(0); return ICH;
+<CSI>A	BEGIN(0); return CUU;
+<CSI>B	BEGIN(0); return CUD;
+<CSI>C	BEGIN(0); return CUF;
+<CSI>D	BEGIN(0); return CUB;
+<CSI>E	BEGIN(0); return CNL;
+<CSI>F	BEGIN(0); return CPL;
+<CSI>G	BEGIN(0); return CHA;
+<CSI>H	BEGIN(0); return CUP;
+<CSI>J	BEGIN(0); return ED;
+<CSI>K	BEGIN(0); return EL;
+<CSI>L	BEGIN(0); return IL;
+<CSI>M	BEGIN(0); return DL;
+<CSI>P	BEGIN(0); return DCH;
+<CSI>S	BEGIN(0); return SU;
+<CSI>T	BEGIN(0); return SD;
+<CSI>X	BEGIN(0); return ECH;
+<CSI>d	BEGIN(0); return VPA;
+<CSI>h	BEGIN(0); return SM;
+<CSI>i	BEGIN(0); return MC;
+<CSI>l	BEGIN(0); return RM;
+<CSI>m	BEGIN(0); return SGR;
+<CSI>r	BEGIN(0); return DECSTBM;
+<CSI>t	BEGIN(0); // XTWINOPS
+
+<CSI_QM>h	BEGIN(0); return DECSET;
+<CSI_QM>l	BEGIN(0); return DECRST;
+
+<CSI>[ -/]*.	BEGIN(0); warnx("unhandled CSI %s", yytext);
+<CSI_LT>[ -/]*.	BEGIN(0); warnx("unhandled CSI < %s", yytext);
+<CSI_EQ>[ -/]*.	BEGIN(0); warnx("unhandled CSI = %s", yytext);
+<CSI_GT>[ -/]*.	BEGIN(0); warnx("unhandled CSI > %s", yytext);
+<CSI_QM>[ -/]*.	BEGIN(0); warnx("unhandled CSI ? %s", yytext);
+
+[\x00-\x7F] {
+	ch = yytext[0];
+	if (g0 == DECSpecial && AltCharset[ch]) {
+		ch = AltCharset[ch];
+	}
+	return Data;
+}
+[\xC0-\xDF][\x80-\xBF] {
+	ch = (wchar_t)(yytext[0] & 0x1F) << 6
+		| (wchar_t)(yytext[1] & 0x3F);
+	return Data;
+}
+[\xE0-\xEF]([\x80-\xBF]{2}) {
+	ch = (wchar_t)(yytext[0] & 0x0F) << 12
+		| (wchar_t)(yytext[1] & 0x3F) << 6
+		| (wchar_t)(yytext[2] & 0x3F);
+	return Data;
+}
+[\xF0-\xF7]([\x80-\xBF]{3}) {
+	ch = (wchar_t)(yytext[0] & 0x07) << 18
+		| (wchar_t)(yytext[1] & 0x3F) << 12
+		| (wchar_t)(yytext[2] & 0x3F) << 6
+		| (wchar_t)(yytext[3] & 0x3F);
+	return Data;
+}
+
+.	ch = yytext[0]; return Data;
+
+%%
+
+static int rows = 24;
+static int cols = 80;
+
+static struct Cell {
+	enum {
+		BIT(Bold),
+		BIT(Italic),
+		BIT(Underline),
+		BIT(Reverse),
+	} attr;
+	int bg, fg;
+	wchar_t ch;
+} *cells;
+
+static int y, x;
+static struct {
+	int y, x;
+} sc;
+static struct {
+	int top, bot;
+} scr;
+
+static enum Mode {
+	BIT(Insert),
+	BIT(Wrap),
+	BIT(Cursor),
+} mode = Wrap | Cursor;
+
+static struct Cell sgr = {
+	.bg = -1,
+	.fg = -1,
+	.ch = L' ',
+};
+
+static struct Cell *cell(int y, int x) {
+	assert(y <= rows);
+	assert(x <= cols);
+	assert(y * cols + x <= rows * cols);
+	return &cells[y * cols + x];
+}
+
+static int p(int i, int d) {
+	return (i < pn ? ps[i] : d);
+}
+
+static int bound(int a, int x, int b) {
+	if (x < a) return a;
+	if (x > b) return b;
+	return x;
+}
+
+static void move(struct Cell *dst, struct Cell *src, size_t len) {
+	memmove(dst, src, sizeof(*dst) * len);
+}
+static void erase(struct Cell *at, struct Cell *to) {
+	for (; at < to; ++at) {
+		*at = sgr;
+	}
+}
+
+static void scrup(int top, int n) {
+	n = bound(0, n, scr.bot - top);
+	move(cell(top, 0), cell(top+n, 0), cols * (scr.bot-top-n));
+	erase(cell(scr.bot-n, 0), cell(scr.bot, 0));
+}
+static void scrdn(int top, int n) {
+	n = bound(0, n, scr.bot - top);
+	move(cell(top+n, 0), cell(top, 0), cols * (scr.bot-top-n));
+	erase(cell(top, 0), cell(top+n, 0));
+}
+
+static enum Mode pmode(void) {
+	enum Mode mode = 0;
+	for (int i = 0; i < pn; ++i) {
+		switch (ps[i]) {
+			break; case 4: mode |= Insert;
+			break; default: warnx("unhandled SM/RM %d", ps[i]);
+		}
+	}
+	return mode;
+}
+static enum Mode pdmode(void) {
+	enum Mode mode = 0;
+	for (int i = 0; i < pn; ++i) {
+		switch (ps[i]) {
+			break; case 1: // DECCKM
+			break; case 7: mode |= Wrap;
+			break; case 12: // "Start Blinking Cursor"
+			break; case 25: mode |= Cursor;
+			break; default: {
+				if (ps[i] < 1000) warnx("unhandled DECSET/DECRST %d", ps[i]);
+			}
+		}
+	}
+	return mode;
+}
+
+static void update(enum Code cc) {
+	switch (cc) {
+		break; case BS: x--;
+		break; case HT: x = x - x % 8 + 8;
+		break; case CR: x = 0;
+		break; case CUU: y -= p(0, 1);
+		break; case CUD: y += p(0, 1);
+		break; case CUF: x += p(0, 1);
+		break; case CUB: x -= p(0, 1);
+		break; case CNL: x = 0; y += p(0, 1);
+		break; case CPL: x = 0; y -= p(0, 1);
+		break; case CHA: x = p(0, 1) - 1;
+		break; case VPA: y = p(0, 1) - 1;
+		break; case CUP: y = p(0, 1) - 1; x = p(1, 1) - 1;
+		break; case DECSC: sc.y = y; sc.x = x;
+		break; case DECRC: y = sc.y; x = sc.x;
+
+		break; case ED: erase(
+			(p(0, 0) == 0 ? cell(y, x) : cell(0, 0)),
+			(p(0, 0) == 1 ? cell(y, x) : cell(rows-1, cols))
+		);
+		break; case EL: erase(
+			(p(0, 0) == 0 ? cell(y, x) : cell(y, 0)),
+			(p(0, 0) == 1 ? cell(y, x) : cell(y, cols))
+		);
+		break; case ECH: erase(
+			cell(y, x), cell(y, bound(0, x + p(0, 1), cols))
+		);
+
+		break; case DCH: {
+			int n = bound(0, p(0, 1), cols-x);
+			move(cell(y, x), cell(y, x+n), cols-x-n);
+			erase(cell(y, cols-n), cell(y, cols));
+		}
+		break; case ICH: {
+			int n = bound(0, p(0, 1), cols-x);
+			move(cell(y, x+n), cell(y, x), cols-x-n);
+			erase(cell(y, x), cell(y, x+n));
+		}
+
+		break; case DECSTBM: {
+			scr.bot = bound(0, p(1, rows), rows);
+			scr.top = bound(0, p(0, 1) - 1, scr.bot);
+		}
+		break; case SU: scrup(scr.top, p(0, 1));
+		break; case SD: scrdn(scr.top, p(0, 1));
+		break; case DL: scrup(bound(0, y, scr.bot), p(0, 1));
+		break; case IL: scrdn(bound(0, y, scr.bot), p(0, 1));
+
+		break; case NL: {
+			if (y+1 == scr.bot) {
+				scrup(scr.top, 1);
+			} else {
+				y++;
+			}
+		}
+		break; case RI: {
+			if (y == scr.top) {
+				scrdn(scr.top, 1);
+			} else {
+				y--;
+			}
+		}
+
+		break; case SM: mode |= pmode();
+		break; case RM: mode &= ~pmode();
+		break; case DECSET: mode |= pdmode();
+		break; case DECRST: mode &= ~pdmode();
+
+		break; case SGR: {
+			if (!pn) ps[pn++] = 0;
+			for (int i = 0; i < pn; ++i) {
+				switch (ps[i]) {
+					break; case 0: sgr.attr = 0; sgr.bg = -1; sgr.fg = -1;
+					break; case 1: sgr.attr |= Bold;
+					break; case 3: sgr.attr |= Italic;
+					break; case 4: sgr.attr |= Underline;
+					break; case 7: sgr.attr |= Reverse;
+					break; case 22: sgr.attr &= ~Bold;
+					break; case 23: sgr.attr &= ~Italic;
+					break; case 24: sgr.attr &= ~Underline;
+					break; case 27: sgr.attr &= ~Reverse;
+					break; case 30 ... 37: sgr.fg = ps[i] - 30;
+					break; case 38: {
+						if (++i < pn && ps[i] == 5) {
+							if (++i < pn) sgr.fg = ps[i];
+						}
+					}
+					break; case 39: sgr.fg = -1;
+					break; case 40 ... 47: sgr.bg = ps[i] - 40;
+					break; case 48: {
+						if (++i < pn && ps[i] == 5) {
+							if (++i < pn) sgr.bg = ps[i];
+						}
+					}
+					break; case 49: sgr.bg = -1;
+					break; case 90 ... 97: sgr.fg = 8 + ps[i] - 90;
+					break; case 100 ... 107: sgr.bg = 8 + ps[i] - 100;
+					break; default: warnx("unhandled SGR %d", ps[i]);
+				}
+			}
+		}
+
+		break; case Data: {
+			int w = wcwidth(ch);
+			if (w < 0) {
+				warnx("unhandled \\u%04X", ch);
+				return;
+			}
+			if (mode & Insert) {
+				int n = bound(0, w, cols-x);
+				move(cell(y, x+n), cell(y, x), cols-x-n);
+			}
+			if (mode & Wrap && x+w > cols) {
+				update(CR);
+				update(NL);
+			}
+			*cell(y, x) = sgr;
+			cell(y, x)->ch = ch;
+			for (int i = 1; i < w && x+i < cols; ++i) {
+				*cell(y, x+i) = sgr;
+				cell(y, x+i)->ch = L'\0';
+			}
+			x = bound(0, x+w, (mode & Wrap ? cols : cols-1));
+			return;
+		}
+		break; case MC:;
+	}
+
+	x = bound(0, x, cols-1);
+	y = bound(0, y, rows-1);
+}
+
+static bool bright;
+static bool colors;
+static int defaultBg = 0;
+static int defaultFg = 7;
+
+static const unsigned Palette[256] = {
+	0x000000, 0xCD0000, 0x00CD00, 0xCDCD00, 0x0000EE, 0xCD00CD, 0x00CDCD,
+	0xE5E5E5, 0x7F7F7F, 0xFF0000, 0x00FF00, 0xFFFF00, 0x5C5CFF, 0xFF00FF,
+	0x00FFFF, 0xFFFFFF, 0x000000, 0x00005F, 0x000087, 0x0000AF, 0x0000D7,
+	0x0000FF, 0x005F00, 0x005F5F, 0x005F87, 0x005FAF, 0x005FD7, 0x005FFF,
+	0x008700, 0x00875F, 0x008787, 0x0087AF, 0x0087D7, 0x0087FF, 0x00AF00,
+	0x00AF5F, 0x00AF87, 0x00AFAF, 0x00AFD7, 0x00AFFF, 0x00D700, 0x00D75F,
+	0x00D787, 0x00D7AF, 0x00D7D7, 0x00D7FF, 0x00FF00, 0x00FF5F, 0x00FF87,
+	0x00FFAF, 0x00FFD7, 0x00FFFF, 0x5F0000, 0x5F005F, 0x5F0087, 0x5F00AF,
+	0x5F00D7, 0x5F00FF, 0x5F5F00, 0x5F5F5F, 0x5F5F87, 0x5F5FAF, 0x5F5FD7,
+	0x5F5FFF, 0x5F8700, 0x5F875F, 0x5F8787, 0x5F87AF, 0x5F87D7, 0x5F87FF,
+	0x5FAF00, 0x5FAF5F, 0x5FAF87, 0x5FAFAF, 0x5FAFD7, 0x5FAFFF, 0x5FD700,
+	0x5FD75F, 0x5FD787, 0x5FD7AF, 0x5FD7D7, 0x5FD7FF, 0x5FFF00, 0x5FFF5F,
+	0x5FFF87, 0x5FFFAF, 0x5FFFD7, 0x5FFFFF, 0x870000, 0x87005F, 0x870087,
+	0x8700AF, 0x8700D7, 0x8700FF, 0x875F00, 0x875F5F, 0x875F87, 0x875FAF,
+	0x875FD7, 0x875FFF, 0x878700, 0x87875F, 0x878787, 0x8787AF, 0x8787D7,
+	0x8787FF, 0x87AF00, 0x87AF5F, 0x87AF87, 0x87AFAF, 0x87AFD7, 0x87AFFF,
+	0x87D700, 0x87D75F, 0x87D787, 0x87D7AF, 0x87D7D7, 0x87D7FF, 0x87FF00,
+	0x87FF5F, 0x87FF87, 0x87FFAF, 0x87FFD7, 0x87FFFF, 0xAF0000, 0xAF005F,
+	0xAF0087, 0xAF00AF, 0xAF00D7, 0xAF00FF, 0xAF5F00, 0xAF5F5F, 0xAF5F87,
+	0xAF5FAF, 0xAF5FD7, 0xAF5FFF, 0xAF8700, 0xAF875F, 0xAF8787, 0xAF87AF,
+	0xAF87D7, 0xAF87FF, 0xAFAF00, 0xAFAF5F, 0xAFAF87, 0xAFAFAF, 0xAFAFD7,
+	0xAFAFFF, 0xAFD700, 0xAFD75F, 0xAFD787, 0xAFD7AF, 0xAFD7D7, 0xAFD7FF,
+	0xAFFF00, 0xAFFF5F, 0xAFFF87, 0xAFFFAF, 0xAFFFD7, 0xAFFFFF, 0xD70000,
+	0xD7005F, 0xD70087, 0xD700AF, 0xD700D7, 0xD700FF, 0xD75F00, 0xD75F5F,
+	0xD75F87, 0xD75FAF, 0xD75FD7, 0xD75FFF, 0xD78700, 0xD7875F, 0xD78787,
+	0xD787AF, 0xD787D7, 0xD787FF, 0xD7AF00, 0xD7AF5F, 0xD7AF87, 0xD7AFAF,
+	0xD7AFD7, 0xD7AFFF, 0xD7D700, 0xD7D75F, 0xD7D787, 0xD7D7AF, 0xD7D7D7,
+	0xD7D7FF, 0xD7FF00, 0xD7FF5F, 0xD7FF87, 0xD7FFAF, 0xD7FFD7, 0xD7FFFF,
+	0xFF0000, 0xFF005F, 0xFF0087, 0xFF00AF, 0xFF00D7, 0xFF00FF, 0xFF5F00,
+	0xFF5F5F, 0xFF5F87, 0xFF5FAF, 0xFF5FD7, 0xFF5FFF, 0xFF8700, 0xFF875F,
+	0xFF8787, 0xFF87AF, 0xFF87D7, 0xFF87FF, 0xFFAF00, 0xFFAF5F, 0xFFAF87,
+	0xFFAFAF, 0xFFAFD7, 0xFFAFFF, 0xFFD700, 0xFFD75F, 0xFFD787, 0xFFD7AF,
+	0xFFD7D7, 0xFFD7FF, 0xFFFF00, 0xFFFF5F, 0xFFFF87, 0xFFFFAF, 0xFFFFD7,
+	0xFFFFFF, 0x080808, 0x121212, 0x1C1C1C, 0x262626, 0x303030, 0x3A3A3A,
+	0x444444, 0x4E4E4E, 0x585858, 0x626262, 0x6C6C6C, 0x767676, 0x808080,
+	0x8A8A8A, 0x949494, 0x9E9E9E, 0xA8A8A8, 0xB2B2B2, 0xBCBCBC, 0xC6C6C6,
+	0xD0D0D0, 0xDADADA, 0xE4E4E4, 0xEEEEEE,
+};
+
+static void span(const struct Cell *prev, const struct Cell *cell) {
+	if (
+		!prev ||
+		cell->attr != prev->attr ||
+		cell->bg != prev->bg ||
+		cell->fg != prev->fg
+	) {
+		if (prev) printf("</span>");
+		int attr = cell->attr;
+		int bg = (attr & Reverse ? cell->fg : cell->bg);
+		int fg = (attr & Reverse ? cell->bg : cell->fg);
+		if (bg < 0) bg = (attr & Reverse ? defaultFg : defaultBg);
+		if (fg < 0) fg = (attr & Reverse ? defaultBg : defaultFg);
+		if (bright && cell->attr & Bold) {
+			if (fg < 8) fg += 8;
+			attr &= ~Bold;
+		}
+		printf(Q(<span class="bg%d fg%d"), bg, fg);
+		if (attr || colors) printf(" style=\"");
+		if (attr & Bold) printf("font-weight:bold;");
+		if (attr & Italic) printf("font-style:italic;");
+		if (attr & Underline) printf("text-decoration:underline;");
+		if (colors && bg < 256 && fg < 256) {
+			printf(
+				"background-color:#%06X;color:#%06X;",
+				Palette[bg], Palette[fg]
+			);
+		}
+		printf("%s>", (attr || colors ? "\"" : ""));
+	}
+	switch (cell->ch) {
+		break; case L'&': printf("&amp;");
+		break; case L'<': printf("&lt;");
+		break; case L'>': printf("&gt;");
+		break; case L'"': printf("&quot;");
+		break; default: printf("%lc", (wint_t)cell->ch);
+	}
+}
+
+static void html(void) {
+	if (mode & Cursor) cell(y, x)->attr ^= Reverse;
+	printf(
+		Q(<pre style="width: %dch;" class="bg%d fg%d">),
+		cols, defaultBg, defaultFg
+	);
+	for (int y = 0; y < rows; ++y) {
+		for (int x = 0; x < cols; ++x) {
+			if (!cell(y, x)->ch) continue;
+			span((x ? cell(y, x-1) : NULL), cell(y, x));
+		}
+		printf("</span>\n");
+	}
+	printf("</pre>\n");
+	if (mode & Cursor) cell(y, x)->attr ^= Reverse;
+}
+
+static const char *Debug[] = {
+#define X(code) [code] = #code,
+	ENUM_CODE
+#undef X
+};
+
+int main(int argc, char *argv[]) {
+	setlocale(LC_CTYPE, "");
+
+	bool debug = false;
+	bool size = false;
+	bool hide = false;
+
+	for (int opt; 0 < (opt = getopt(argc, argv, "Bb:df:h:insw:"));) {
+		switch (opt) {
+			break; case 'B': bright = true;
+			break; case 'b': defaultBg = atoi(optarg);
+			break; case 'd': debug = true;
+			break; case 'f': defaultFg = atoi(optarg);
+			break; case 'h': rows = atoi(optarg);
+			break; case 'i': colors = true;
+			break; case 'n': hide = true;
+			break; case 's': size = true;
+			break; case 'w': cols = atoi(optarg);
+			break; default:  return EX_USAGE;
+		}
+	}
+	if (optind < argc) {
+		yyin = fopen(argv[optind], "r");
+		if (!yyin) err(EX_NOINPUT, "%s", argv[optind]);
+	}
+
+	if (size) {
+		struct winsize win;
+		int error = ioctl(STDERR_FILENO, TIOCGWINSZ, &win);
+		if (error) err(EX_IOERR, "ioctl");
+		cols = win.ws_col;
+		rows = win.ws_row;
+	}
+	scr.bot = rows;
+
+	cells = calloc(cols * rows, sizeof(*cells));
+	if (!cells) err(EX_OSERR, "calloc");
+	erase(cell(0, 0), cell(rows-1, cols));
+
+	bool mc = false;
+	for (int cc; (cc = yylex());) {
+		if (cc == MC) {
+			mc = true;
+			html();
+		} else {
+			update(cc);
+		}
+		if (debug && cc != Data) {
+			printf("%s", Debug[cc]);
+			for (int i = 0; i < pn; ++i) {
+				printf("%s%d", (i ? ", " : " "), ps[i]);
+			}
+			printf("\n");
+			html();
+		}
+	}
+	if (hide) mode &= ~Cursor;
+	if (!mc) html();
+}
diff --git a/bin/sup.sh b/bin/sup.sh
new file mode 100644
index 00000000..32e282d1
--- /dev/null
+++ b/bin/sup.sh
@@ -0,0 +1,283 @@
+#!/bin/sh
+set -eu
+
+service=$1
+email=${2:-$(git config fetchemail.imapUser)}
+
+generate() {
+	openssl rand -base64 33
+}
+copy() {
+	printf '%s' "$1" | pbcopy
+}
+unwrap() {
+	sed '
+		:x
+		/=$/ {
+			N
+			s/=\n//g
+			bx
+		}
+	'
+}
+
+asciinema() {
+	echo 'Fetching CSRF token...'
+	jar=$(mktemp -t sup)
+	trap 'rm "${jar}"' EXIT
+	csrf=$(
+		curl -Ss -c "${jar}" 'https://asciinema.org/login/new' |
+		sed -n 's/.*name="_csrf_token".*value="\([^"]*\)".*/\1/p'
+	)
+	echo 'Submitting form...'
+	curl -Ss -X POST -b "${jar}" \
+		-F "_csrf_token=${csrf}" -F "login[email]=${email}" \
+		'https://asciinema.org/login' \
+		>/dev/null
+	echo 'Waiting for email...'
+	url=$(
+		git fetch-email -i -M Trash \
+			-F 'hello@asciinema.org' -T "${email}" \
+			-S 'Login to asciinema.org' |
+		grep -m 1 '^https://asciinema\.org/session/new'
+	)
+	open "${url}"
+}
+
+bugzilla() {
+	echo 'Fetching CSRF token...'
+	csrf=$(
+		curl -Ss "${bugzillaBase}/" |
+		sed -n '
+			/name="token"/N
+			s/.*name="token"[[:space:]]*value="\([^"]*\)".*/\1/p
+		' | head -n 1
+	)
+	echo 'Submitting form...'
+	curl -Ss -X POST \
+		-F "loginname=${email}" -F "token=${csrf}" -F 'a=reqpw' \
+		"${bugzillaBase}/token.cgi" \
+		>/dev/null
+	echo 'Waiting for email...'
+	token=$(
+		git fetch-email -i -M Trash \
+			-F "${bugzillaFrom}" -T "${email}" \
+			-S 'Bugzilla Change Password Request' |
+		sed -n 's/.*t=3D\([^&]*\).*/\1/p' |
+		head -n 1
+	)
+	password=$(generate)
+	echo 'Setting password...'
+	curl -Ss -X POST \
+		-F "t=${token}" -F 'a=chgpw' \
+		-F "password=${password}" -F "matchpassword=${password}" \
+		"${bugzillaBase}/token.cgi" \
+		>/dev/null
+	copy "${password}"
+	open "${bugzillaBase}/"
+}
+
+freebsdbugzilla() {
+	bugzillaBase='https://bugs.freebsd.org/bugzilla'
+	bugzillaFrom='bugzilla-noreply@freebsd.org'
+	bugzilla
+}
+
+discogs() {
+	echo 'Submitting form...'
+	curl -Ss -X POST \
+		-F "email=${email}" -F 'Action.EmailResetInstructions=submit' \
+		'https://www.discogs.com/users/forgot_password' \
+		>/dev/null
+	echo 'Waiting for email...'
+	url=$(
+		git fetch-email -i -M Trash \
+			-F 'noreply@discogs.com' -T "${email}" \
+			-S 'Discogs Account Password Reset Instructions' |
+		sed -n 's/^To proceed, follow the instructions here: \(.*\)/\1/p'
+	)
+	echo 'Fetching token...'
+	token=$(curl -ISs --url "${url}" | sed -n 's/.*[?]token=\([^&]*\).*/\1/p')
+	password=$(generate)
+	echo 'Setting password...'
+	curl -Ss -X POST \
+		-F "token=${token}" \
+		-F "password0=${password}" -F "password1=${password}" \
+		-F 'Action.ChangePassword=submit' \
+		'https://www.discogs.com/users/forgot_password' \
+		>/dev/null
+	copy "${password}"
+	open 'https://discogs.com/login'
+}
+
+gitea() {
+	echo 'Fetching CSRF token...'
+	csrf=$(
+		curl -Ss "${giteaBase}/user/forgot_password" |
+		sed -n 's/.*name="_csrf" value="\([^"]*\)".*/\1/p'
+	)
+	echo 'Submitting form...'
+	curl -Ss -X POST \
+		-F "email=${email}" -F "_csrf=${csrf}" \
+		"${giteaBase}/user/forgot_password" \
+		>/dev/null
+	echo 'Waiting for email...'
+	code=$(
+		git fetch-email -i -M Trash \
+			-F "${giteaFrom}" -T "${email}" -S 'Recover your account' |
+		unwrap | sed -n 's/.*code=3D\(.*\)/\1/p' | head -n 1
+	)
+	echo 'Fetching CSRF token...'
+	csrf=$(
+		curl -Ss "${giteaBase}/user/recover_account" |
+		sed -n 's/.*name="_csrf" value="\([^"]*\)".*/\1/p'
+	)
+	password=$(generate)
+	echo 'Setting password...'
+	curl -Ss -X POST \
+		-F "_csrf=${csrf}" -F "code=${code}" \
+		-F "password=${password}" \
+		"${giteaBase}/user/recover_account" \
+		>/dev/null
+	copy "${password}"
+	open "${giteaBase}/user/login"
+}
+
+liberapay() {
+	echo 'Fetching CSRF token...'
+	csrf=$(
+		curl -Ss 'https://liberapay.com/sign-in' |
+		sed -n 's/.*name="csrf_token".*value="\([^"]*\)".*/\1/p'
+	)
+	echo 'Submitting form...'
+	curl -Ss -X POST \
+		-b "csrf_token=${csrf}" -F "csrf_token=${csrf}" \
+		-F "log-in.id=${email}" \
+		'https://liberapay.com/sign-in' \
+		>/dev/null
+	echo 'Waiting for email...'
+	url=$(
+		git fetch-email -i -M Trash \
+			-F 'support@liberapay.com' -T "${email}" \
+			-S 'Log in to Liberapay' |
+		grep -m 1 '^https://liberapay\.com/'
+	)
+	open "${url}"
+}
+
+lobsters() {
+	: ${lobstersBase:=https://lobste.rs}
+	: ${lobstersFrom:=nobody@lobste.rs}
+	echo 'Fetching CSRF token...'
+	csrf=$(
+		curl -Ss "${lobstersBase}/login/forgot_password" |
+		sed -n 's/.*name="authenticity_token" value="\([^"]*\)".*/\1/p'
+	)
+	echo 'Submitting form...'
+	curl -Ss -X POST \
+		-F "authenticity_token=${csrf}" \
+		-F "email=${email}" -F 'commit=submit' \
+		"${lobstersBase}/login/reset_password" \
+		>/dev/null
+	echo 'Waiting for email...'
+	token=$(
+		git fetch-email -i -M Trash \
+			-F "${lobstersFrom}" -T "${email}" \
+			-S 'Reset your password' |
+		sed -n 's|^https://.*[?]token=\([^&]*\).*|\1|p'
+	)
+	echo 'Fetching CSRF token...'
+	csrf=$(
+		curl -Ss "${lobstersBase}/login/set_new_password?token=${token}" |
+		sed -n 's/.*name="authenticity_token" value="\([^"]*\)".*/\1/p'
+	)
+	password=$(generate)
+	echo 'Setting password...'
+	curl -Ss -X POST \
+		-F "authenticity_token=${csrf}" -F "token=${token}" \
+		-F "password=${password}" -F "password_confirmation=${password}" \
+		-F 'commit=submit' \
+		"${lobstersBase}/login/set_new_password" \
+		>/dev/null
+	copy "${password}"
+	open "${lobstersBase}/login"
+}
+
+lwn() {
+	username=$email
+	echo 'Submitting form...'
+	curl -Ss -X POST -F "username=${username}" \
+		'https://lwn.net/Login/MailPWLink' \
+		>/dev/null
+	echo 'Waiting for email...'
+	key=$(
+		git fetch-email -i -M Trash \
+			-F 'lwn@lwn.net' -S 'A link to set your LWN.net password' |
+		sed -n 's|.*/Login/SetPassword/.*/\(.*\)|\1|p'
+	)
+	echo 'Retrieving UID...'
+	uid=$(
+		curl -Ss "https://lwn.net/Login/SetPassword/${username}/${key}" |
+		sed -n 's/.*name="uid" value="\([^"]*\)".*/\1/p'
+	)
+	password=$(generate)
+	echo 'Setting password...'
+	curl -Ss -X POST \
+		-F "uid=${uid}" -F "key=${key}" \
+		-F "new1=${password}" -F "new2=${password}" \
+		'https://lwn.net/Login/DoSetPassword' \
+		>/dev/null
+	copy "${password}"
+	open 'https://lwn.net/Login/'
+}
+
+patreon() {
+	readonly patreonAPI='https://www.patreon.com/api'
+	echo 'Submitting form...'
+	curl -Ss -X POST -d @- \
+		-H 'Content-Type: application/vnd.api+json' \
+		"${patreonAPI}/auth/forgot-password?json-api-version=1.0" <<-EOF
+		{"data":{"email":"${email}"}}
+		EOF
+	echo 'Waiting for email...'
+	url=$(
+		git fetch-email -i -M Trash \
+			-F 'password@patreon.com' -T "${email}" \
+			-S 'Patreon Password Reset' |
+		unwrap |
+		grep -o -m 1 'https://email[.]mailgun[.]patreon[.]com/.*'
+	)
+	echo 'Fetching token...'
+	location=$(curl -ISs --url "${url}" | grep -i '^Location: ' | tr -d '\r')
+	u=$(echo "${location}" | sed 's/.*[?&]u=\([^&]*\).*/\1/')
+	sec=$(echo "${location}" | sed 's/.*[?&]sec=\([^&]*\).*/\1/')
+	password=$(generate)
+	echo 'Setting password...'
+	curl -Ss -X POST -d @- \
+		-H 'Content-Type: application/vnd.api+json' \
+		"${patreonAPI}/auth/forgot-password/change?json-api-version=1.0" <<-EOF
+		{
+			"data":{
+				"user_id":"${u}",
+				"security_token":"${sec}",
+				"password":"${password}"
+			}
+		}
+		EOF
+	copy "${password}"
+	open 'https://www.patreon.com/login'
+}
+
+tildegit() {
+	giteaBase='https://tildegit.org'
+	giteaFrom='git@tildegit.org'
+	gitea
+}
+
+tildenews() {
+	lobstersBase='https://tilde.news'
+	lobstersFrom='nobody@tilde.news'
+	lobsters
+}
+
+$service
diff --git a/bin/title.c b/bin/title.c
new file mode 100644
index 00000000..47ff720a
--- /dev/null
+++ b/bin/title.c
@@ -0,0 +1,211 @@
+/* Copyright (C) 2019  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <curl/curl.h>
+#include <err.h>
+#include <locale.h>
+#include <regex.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <unistd.h>
+#include <wchar.h>
+
+static regex_t regex(const char *pattern, int flags) {
+	regex_t regex;
+	int error = regcomp(&regex, pattern, REG_EXTENDED | flags);
+	if (!error) return regex;
+
+	char buf[256];
+	regerror(error, &regex, buf, sizeof(buf));
+	errx(EX_SOFTWARE, "regcomp: %s: %s", buf, pattern);
+}
+
+static const struct Entity {
+	wchar_t ch;
+	const char *name;
+} Entities[] = {
+	{ L'"', "&quot;" },
+	{ L'&', "&amp;" },
+	{ L'<', "&lt;" },
+	{ L'>', "&gt;" },
+	{ L'␤', "&#10;" },
+};
+
+static wchar_t entity(const char *name) {
+	for (size_t i = 0; i < sizeof(Entities) / sizeof(Entities[0]); ++i) {
+		struct Entity entity = Entities[i];
+		if (strncmp(name, entity.name, strlen(entity.name))) continue;
+		return entity.ch;
+	}
+	if (!strncmp(name, "&#x", 3)) return strtoul(&name[3], NULL, 16);
+	if (!strncmp(name, "&#", 2)) return strtoul(&name[2], NULL, 10);
+	return 0;
+}
+
+static const char EntityPattern[] = {
+	"[[:space:]]+|&([[:alpha:]]+|#([[:digit:]]+|x[[:xdigit:]]+));"
+};
+static regex_t EntityRegex;
+
+static void showTitle(const char *title) {
+	regmatch_t match = {0};
+	for (; *title; title += match.rm_eo) {
+		if (regexec(&EntityRegex, title, 1, &match, 0)) break;
+		if (title[match.rm_so] != '&') {
+			printf("%.*s ", (int)match.rm_so, title);
+			continue;
+		}
+		wchar_t ch = entity(&title[match.rm_so]);
+		if (ch) {
+			printf("%.*s%lc", (int)match.rm_so, title, (wint_t)ch);
+		} else {
+			printf("%.*s", (int)match.rm_eo, title);
+		}
+	}
+	printf("%s\n", title);
+}
+
+static CURL *curl;
+static bool title;
+static struct {
+	char buf[64 * 1024];
+	size_t len;
+} body;
+
+// HE COMES
+static const char TitlePattern[] = "<title>([^<]*)</title>";
+static regex_t TitleRegex;
+
+static size_t handleBody(char *buf, size_t size, size_t nitems, void *user) {
+	(void)user;
+	size_t len = size * nitems;
+	size_t cap = sizeof(body.buf) - body.len - 1;
+	size_t new = (len < cap ? len : cap);
+	if (title || !new) return len;
+
+	memcpy(&body.buf[body.len], buf, new);
+	body.len += new;
+	body.buf[body.len] = '\0';
+
+	regmatch_t match[2];
+	if (regexec(&TitleRegex, body.buf, 2, match, 0)) return len;
+	body.buf[match[1].rm_eo] = '\0';
+	showTitle(&body.buf[match[1].rm_so]);
+	title = true;
+
+	return len;
+}
+
+static CURLcode fetchTitle(const char *url) {
+	CURLcode code = curl_easy_setopt(curl, CURLOPT_URL, url);
+	if (code) return code;
+
+	curl_easy_setopt(curl, CURLOPT_NOBODY, 1L);
+	code = curl_easy_perform(curl);
+	if (code) return code;
+
+	char *type;
+	code = curl_easy_getinfo(curl, CURLINFO_CONTENT_TYPE, &type);
+	if (code) return code;
+	if (!type || strncmp(type, "text/html", 9)) return CURLE_OK;
+
+	char *dest;
+	curl_easy_getinfo(curl, CURLINFO_EFFECTIVE_URL, &dest);
+	dest = strdup(dest);
+	if (!dest) err(EX_OSERR, "strdup");
+
+	code = curl_easy_setopt(curl, CURLOPT_URL, dest);
+	if (code) return code;
+	free(dest);
+
+	body.len = 0;
+	title = false;
+	curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
+	code = curl_easy_perform(curl);
+	return code;
+}
+
+int main(int argc, char *argv[]) {
+	EntityRegex = regex(EntityPattern, 0);
+	TitleRegex = regex(TitlePattern, REG_ICASE);
+
+	setlocale(LC_CTYPE, "");
+	setlinebuf(stdout);
+
+	CURLcode code = curl_global_init(CURL_GLOBAL_ALL);
+	if (code) errx(EX_OSERR, "curl_global_init: %s", curl_easy_strerror(code));
+
+	curl = curl_easy_init();
+	if (!curl) errx(EX_SOFTWARE, "curl_easy_init");
+
+	static char error[CURL_ERROR_SIZE];
+	curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, error);
+
+	curl_easy_setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
+	curl_easy_setopt(
+		curl, CURLOPT_USERAGENT,
+		"curl/7.54.0 facebookexternalhit/1.1 Twitterbot/1.0"
+	);
+	curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
+	curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
+	curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
+	curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L);
+
+	curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, handleBody);
+
+	bool exclude = false;
+	regex_t excludeRegex;
+
+	int opt;
+	while (0 < (opt = getopt(argc, argv, "x:v"))) {
+		switch (opt) {
+			break; case 'x': {
+				exclude = true;
+				excludeRegex = regex(optarg, REG_NOSUB);
+			}
+			break; case 'v': curl_easy_setopt(curl, CURLOPT_VERBOSE, 1L);
+			break; default:  return EX_USAGE;
+		}
+	}
+
+	if (optind < argc) {
+		code = fetchTitle(argv[optind]);
+		if (!code) return EX_OK;
+		errx(EX_DATAERR, "curl_easy_perform: %s", error);
+	}
+
+	char *buf = NULL;
+	size_t cap = 0;
+
+	regex_t urlRegex = regex("https?://([^[:space:]>\"()]|[(][^)]*[)])+", 0);
+	while (0 < getline(&buf, &cap, stdin)) {
+		regmatch_t match = {0};
+		for (char *ptr = buf; *ptr; ptr += match.rm_eo) {
+			if (regexec(&urlRegex, ptr, 1, &match, 0)) break;
+			ptr[match.rm_eo] = '\0';
+			const char *url = &ptr[match.rm_so];
+			if (!exclude || regexec(&excludeRegex, url, 0, NULL, 0)) {
+				code = fetchTitle(url);
+				if (code) warnx("curl_easy_perform: %s", error);
+			}
+			ptr[match.rm_eo] = ' ';
+		}
+	}
+	if (ferror(stdin)) err(EX_IOERR, "getline");
+}
diff --git a/bin/up.sh b/bin/up.sh
new file mode 100644
index 00000000..6305b1ee
--- /dev/null
+++ b/bin/up.sh
@@ -0,0 +1,94 @@
+#!/bin/sh
+set -eu
+
+readonly Host='temp.causal.agency'
+readonly Root='/var/www'
+
+temp=
+temp() {
+	temp=$(mktemp -d)
+	trap 'rm -r "$temp"' EXIT
+}
+
+warn=
+upload() {
+	src=$1
+	ext=${src##*.}
+	name=$(printf '%x%s' "$(date +%s)" "$(openssl rand -hex 4)")
+	url="${Host}/${name}.${ext}"
+	scp -q "$src" "${Host}:${Root}/${url}"
+	if test -n "$warn"; then
+		test -n "$temp" || temp
+		cat >"${temp}/warn.html" <<-EOF
+			<!DOCTYPE html>
+			<title>${warn}</title>
+			<meta http-equiv="refresh" content="0;url=${name}.${ext}">
+		EOF
+		url="${Host}/${name}.html"
+		scp -q "${temp}/warn.html" "${Host}:${Root}/${url}"
+	fi
+	echo "https://${url}"
+}
+
+uploadText() {
+	temp
+	cat >"${temp}/input.txt"
+	upload "${temp}/input.txt"
+}
+
+uploadCommand() {
+	temp
+	echo "$ $1" >"${temp}/exec.txt"
+	$SHELL -c "$1" >>"${temp}/exec.txt" 2>&1 || true
+	upload "${temp}/exec.txt"
+}
+
+uploadHilex() {
+	temp
+	hilex -f html -o document,tab=4 "$@" >"${temp}/hilex.html"
+	upload "${temp}/hilex.html"
+}
+
+uploadScreen() {
+	temp
+	if command -v screencapture >/dev/null; then
+		screencapture -i "$@" "${temp}/capture.png"
+	else
+		scrot -s "$@" "${temp}/capture.png"
+	fi
+	pngo "${temp}/capture.png"
+	upload "${temp}/capture.png"
+}
+
+uploadTerminal() {
+	temp
+	cat >"${temp}/term.html" <<-EOF
+	<!DOCTYPE html>
+	<meta charset="utf-8">
+	<title>${1}</title>
+	<style>
+	$(scheme -s)
+	</style>
+	EOF
+	ptee $SHELL -c "$1" >"${temp}/term.pty"
+	shotty -Bs "${temp}/term.pty" >>"${temp}/term.html"
+	upload "${temp}/term.html"
+}
+
+while getopts 'chstw:' opt; do
+	case $opt in
+		(c) fn=uploadCommand;;
+		(h) fn=uploadHilex;;
+		(s) fn=uploadScreen;;
+		(t) fn=uploadTerminal;;
+		(w) warn=$OPTARG;;
+		(?) exit 1;;
+	esac
+done
+shift $((OPTIND - 1))
+[ $# -eq 0 ] && : ${fn:=uploadText}
+: ${fn:=upload}
+
+url=$($fn "$@")
+printf '%s' "$url" | pbcopy || true
+echo "$url"
diff --git a/bin/when.y b/bin/when.y
new file mode 100644
index 00000000..46651ebb
--- /dev/null
+++ b/bin/when.y
@@ -0,0 +1,353 @@
+/* Copyright (C) 2019, 2022  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+%{
+
+#include <ctype.h>
+#include <err.h>
+#include <errno.h>
+#include <limits.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <strings.h>
+#include <sysexits.h>
+#include <time.h>
+
+static void yyerror(const char *str);
+static int yylex(void);
+
+#define YYSTYPE struct tm
+
+static const char *Days[7] = {
+	"Sunday", "Monday", "Tuesday", "Wednesday",
+	"Thursday", "Friday", "Saturday",
+};
+
+static const char *Months[12] = {
+	"January", "February", "March", "April", "May", "June",
+	"July", "August", "September", "October", "November", "December",
+};
+
+static const struct tm Week = { .tm_mday = 7 };
+
+static struct tm normalize(struct tm date) {
+	time_t time = timegm(&date);
+	struct tm *norm = gmtime(&time);
+	if (!norm) err(EX_OSERR, "gmtime");
+	return *norm;
+}
+
+static struct tm today(void) {
+	time_t now = time(NULL);
+	struct tm *local = localtime(&now);
+	if (!local) err(EX_OSERR, "localtime");
+	struct tm date = {
+		.tm_year = local->tm_year,
+		.tm_mon = local->tm_mon,
+		.tm_mday = local->tm_mday,
+	};
+	return normalize(date);
+}
+
+static struct tm monthDay(int month, int day) {
+	struct tm date = today();
+	date.tm_mon = month;
+	date.tm_mday = day;
+	return normalize(date);
+}
+
+static struct tm monthDayYear(int month, int day, int year) {
+	struct tm date = today();
+	date.tm_mon = month;
+	date.tm_mday = day;
+	date.tm_year = year - 1900;
+	return normalize(date);
+}
+
+static struct tm weekDay(int day) {
+	struct tm date = today();
+	date.tm_mday += day - date.tm_wday;
+	return normalize(date);
+}
+
+static struct tm scalarAdd(struct tm a, struct tm b) {
+	a.tm_mday += b.tm_mday;
+	a.tm_mon += b.tm_mon;
+	a.tm_year += b.tm_year;
+	return a;
+}
+
+static struct tm scalarSub(struct tm a, struct tm b) {
+	a.tm_mday -= b.tm_mday;
+	a.tm_mon -= b.tm_mon;
+	a.tm_year -= b.tm_year;
+	return a;
+}
+
+static struct tm dateAdd(struct tm date, struct tm scalar) {
+	return normalize(scalarAdd(date, scalar));
+}
+
+static struct tm dateSub(struct tm date, struct tm scalar) {
+	return normalize(scalarSub(date, scalar));
+}
+
+static struct tm dateDiff(struct tm a, struct tm b) {
+	time_t atime = timegm(&a), btime = timegm(&b);
+	if (atime < btime) {
+		struct tm x = a;
+		a = b;
+		b = x;
+		time_t xtime = atime;
+		atime = btime;
+		btime = xtime;
+	}
+	struct tm diff = {
+		.tm_year = a.tm_year - b.tm_year,
+		.tm_mon = a.tm_mon - b.tm_mon,
+		.tm_mday = a.tm_mday - b.tm_mday,
+	};
+	if (
+		a.tm_mon < b.tm_mon ||
+		(a.tm_mon == b.tm_mon && a.tm_mday < b.tm_mday)
+	) {
+		diff.tm_year--;
+		diff.tm_mon += 12;
+	}
+	if (a.tm_mday < b.tm_mday) {
+		diff.tm_mon--;
+		diff.tm_mday = 0;
+		while (dateAdd(b, diff).tm_mday != a.tm_mday) diff.tm_mday++;
+	}
+	diff.tm_yday = (atime - btime) / 24 / 60 / 60;
+	return diff;
+}
+
+static struct {
+	size_t cap, len;
+	struct tm *ptr;
+} dates;
+
+static struct tm getDate(const char *name) {
+	for (size_t i = 0; i < dates.len; ++i) {
+		if (!strcmp(dates.ptr[i].tm_zone, name)) return dates.ptr[i];
+	}
+	return (struct tm) {0};
+}
+
+static void setDate(const char *name, struct tm date) {
+	for (size_t i = 0; i < dates.len; ++i) {
+		if (strcmp(dates.ptr[i].tm_zone, name)) continue;
+		char *tm_zone = dates.ptr[i].tm_zone;
+		dates.ptr[i] = date;
+		dates.ptr[i].tm_zone = tm_zone;
+		return;
+	}
+	if (dates.len == dates.cap) {
+		dates.cap = (dates.cap ? dates.cap * 2 : 8);
+		dates.ptr = realloc(dates.ptr, sizeof(*dates.ptr) * dates.cap);
+		if (!dates.ptr) err(EX_OSERR, "realloc");
+	}
+	dates.ptr[dates.len] = date;
+	dates.ptr[dates.len].tm_zone = strdup(name);
+	if (!dates.ptr[dates.len].tm_zone) err(EX_OSERR, "strdup");
+	dates.len++;
+}
+
+static bool silent;
+
+static void printDate(struct tm date) {
+	if (silent) return;
+	printf(
+		"%.3s %.3s %d %d\n",
+		Days[date.tm_wday], Months[date.tm_mon],
+		date.tm_mday, 1900 + date.tm_year
+	);
+}
+
+static void printScalar(struct tm scalar) {
+	if (silent) return;
+	if (scalar.tm_year) printf("%dy ", scalar.tm_year);
+	if (scalar.tm_mon) printf("%dm ", scalar.tm_mon);
+	if (scalar.tm_mday % 7) {
+		printf("%dd ", scalar.tm_mday);
+	} else if (scalar.tm_mday) {
+		printf("%dw ", scalar.tm_mday / 7);
+	}
+	if (scalar.tm_yday && scalar.tm_mon) {
+		if (scalar.tm_yday >= 7) {
+			printf("(%dw", scalar.tm_yday / 7);
+			if (scalar.tm_yday % 7) {
+				printf(" %dd", scalar.tm_yday % 7);
+			}
+			printf(") ");
+		}
+		printf("(%dd) ", scalar.tm_yday);
+	}
+	printf("\n");
+}
+
+%}
+
+%token Name Number Month Day
+%left '+' '-'
+%right '=' '<' '>'
+
+%%
+
+expr:
+	date { printDate($1); }
+	| scalar { printScalar($1); }
+	;
+
+date:
+	dateLit
+	| Name { $$ = getDate($1.tm_zone); free($1.tm_zone); }
+	| Name '=' date { setDate($1.tm_zone, $3); free($1.tm_zone); $$ = $3; }
+	| '(' date ')' { $$ = $2; }
+	| '<' date { $$ = dateSub($2, Week); }
+	| '>' date { $$ = dateAdd($2, Week); }
+	| date '+' scalar { $$ = dateAdd($1, $3); }
+	| date '-' scalar { $$ = dateSub($1, $3); }
+	;
+
+scalar:
+	scalarLit
+	| '(' scalar ')' { $$ = $2; }
+	| scalar '+' scalar { $$ = scalarAdd($1, $3); }
+	| scalar '-' scalar { $$ = scalarSub($1, $3); }
+	| date '-' date { $$ = dateDiff($1, $3); }
+	;
+
+dateLit:
+	{ $$ = today(); }
+	| '.' { $$ = today(); }
+	| Month Number { $$ = monthDay($1.tm_mon, $2.tm_sec); }
+	| Month Number Number { $$ = monthDayYear($1.tm_mon, $2.tm_sec, $3.tm_sec); }
+	| Day { $$ = weekDay($1.tm_wday); }
+	;
+
+scalarLit:
+	Number 'd' { $$ = (struct tm) { .tm_mday = $1.tm_sec }; }
+	| Number 'w' { $$ = (struct tm) { .tm_mday = 7 * $1.tm_sec }; }
+	| Number 'm' { $$ = (struct tm) { .tm_mon = $1.tm_sec }; }
+	| Number 'y' { $$ = (struct tm) { .tm_year = $1.tm_sec }; }
+	;
+
+%%
+
+static void yyerror(const char *str) {
+	warnx("%s", str);
+}
+
+static const char *input;
+
+static int yylex(void) {
+	while (isspace(*input)) input++;
+	if (!*input) return EOF;
+
+	if (isdigit(*input)) {
+		char *rest;
+		yylval.tm_sec = strtol(input, &rest, 10);
+		input = rest;
+		return Number;
+	}
+
+	size_t len;
+	for (len = 0; isalnum(input[len]) || input[len] == '_'; ++len);
+
+	if (len >= 3) {
+		for (int i = 0; i < 7; ++i) {
+			if (strncasecmp(input, Days[i], len)) continue;
+			yylval.tm_wday = i;
+			input += len;
+			return Day;
+		}
+
+		for (int i = 0; i < 12; ++i) {
+			if (strncasecmp(input, Months[i], len)) continue;
+			yylval.tm_mon = i;
+			input += len;
+			return Month;
+		}
+	}
+
+	if (len && (len != 1 || !strchr("dwmy", *input))) {
+		yylval.tm_zone = strndup(input, len);
+		if (!yylval.tm_zone) err(EX_OSERR, "strndup");
+		input += len;
+		return Name;
+	}
+
+	return *input++;
+}
+
+int main(int argc, char *argv[]) {
+	size_t cap = 0;
+	char *line = NULL;
+
+	char path[PATH_MAX];
+	const char *configHome = getenv("XDG_CONFIG_HOME");
+	if (configHome) {
+		snprintf(path, sizeof(path), "%s/when/dates", configHome);
+	} else {
+		snprintf(path, sizeof(path), "%s/.config/when/dates", getenv("HOME"));
+	}
+
+	FILE *file = fopen(path, "r");
+	if (file) {
+		silent = true;
+		while (0 < getline(&line, &cap, file)) {
+			input = line;
+			yyparse();
+		}
+		fclose(file);
+		silent = false;
+	} else if (errno != ENOENT) {
+		err(EX_CONFIG, "%s", path);
+	}
+
+	if (argc > 1) {
+		if (strcmp(argv[1], "-")) {
+			input = argv[1];
+			return yyparse();
+		} else {
+			for (size_t i = 0; i < dates.len; ++i) {
+				printf("%s: ", dates.ptr[i].tm_zone);
+				printScalar(dateDiff(today(), dates.ptr[i]));
+			}
+			return EX_OK;
+		}
+	}
+
+	struct tm date = today();
+	printDate(date);
+	printf("\n");
+
+	while (0 < getline(&line, &cap, stdin)) {
+		if (line[0] == '\n') continue;
+
+		if (today().tm_mday != date.tm_mday) {
+			warnx("the date has changed");
+			date = today();
+		}
+
+		input = line;
+		yyparse();
+		printf("\n");
+	}
+}
diff --git a/bin/xx.c b/bin/xx.c
new file mode 100644
index 00000000..39d7ec07
--- /dev/null
+++ b/bin/xx.c
@@ -0,0 +1,142 @@
+/* Copyright (C) 2017  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <ctype.h>
+#include <err.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <sysexits.h>
+#include <unistd.h>
+
+typedef unsigned char byte;
+
+static bool zero(const byte *ptr, size_t size) {
+	for (size_t i = 0; i < size; ++i) {
+		if (ptr[i]) return false;
+	}
+	return true;
+}
+
+static struct {
+	size_t cols;
+	size_t group;
+	size_t blank;
+	bool ascii;
+	bool offset;
+	bool skip;
+} options = { 16, 8, 0, true, true, false };
+
+static void dump(FILE *file) {
+	bool skip = false;
+
+	byte buf[options.cols];
+	size_t offset = 0;
+	for (
+		size_t size;
+		(size = fread(buf, 1, sizeof(buf), file));
+		offset += size
+	) {
+		if (options.skip) {
+			if (zero(buf, size)) {
+				if (!skip) printf("*\n");
+				skip = true;
+				continue;
+			} else {
+				skip = false;
+			}
+		}
+
+		if (options.blank) {
+			if (offset && offset % options.blank == 0) {
+				printf("\n");
+			}
+		}
+
+		if (options.offset) {
+			printf("%08zX:  ", offset);
+		}
+
+		for (size_t i = 0; i < sizeof(buf); ++i) {
+			if (options.group) {
+				if (i && !(i % options.group)) {
+					printf(" ");
+				}
+			}
+			if (i < size) {
+				printf("%02hhX ", buf[i]);
+			} else {
+				printf("   ");
+			}
+		}
+
+		if (options.ascii) {
+			printf(" ");
+			for (size_t i = 0; i < size; ++i) {
+				if (options.group) {
+					if (i && !(i % options.group)) {
+						printf(" ");
+					}
+				}
+				printf("%c", isprint(buf[i]) ? buf[i] : '.');
+			}
+		}
+
+		printf("\n");
+	}
+}
+
+static void undump(FILE *file) {
+	byte c;
+	int match;
+	while (0 < (match = fscanf(file, " %hhx", &c))) {
+		printf("%c", c);
+	}
+	if (!match) errx(EX_DATAERR, "invalid input");
+}
+
+int main(int argc, char *argv[]) {
+	bool reverse = false;
+	const char *path = NULL;
+
+	int opt;
+	while (0 < (opt = getopt(argc, argv, "ac:g:p:rsz"))) {
+		switch (opt) {
+			break; case 'a': options.ascii ^= true;
+			break; case 'c': options.cols = strtoul(optarg, NULL, 0);
+			break; case 'g': options.group = strtoul(optarg, NULL, 0);
+			break; case 'p': options.blank = strtoul(optarg, NULL, 0);
+			break; case 'r': reverse = true;
+			break; case 's': options.offset ^= true;
+			break; case 'z': options.skip ^= true;
+			break; default: return EX_USAGE;
+		}
+	}
+	if (argc > optind) path = argv[optind];
+	if (!options.cols) return EX_USAGE;
+
+	FILE *file = path ? fopen(path, "r") : stdin;
+	if (!file) err(EX_NOINPUT, "%s", path);
+
+	if (reverse) {
+		undump(file);
+	} else {
+		dump(file);
+	}
+	if (ferror(file)) err(EX_IOERR, "%s", path);
+
+	return EX_OK;
+}