The following exchange took place on March 18, 2019 in a conversation about domain hacks on discord:
slerpy I just realized if I do that with my handle, it looks a python script
yx :D
yx write a demo in python
yx I want to see the beamslide for sler.py by slerpy
Yeah, so that's how that happened.
The Entry
This is the 4k exe-gfx entry sler.py by slerpy, which renders a picture of slerpy. It was released at Evoke 2019 in the freestyle graphics compo (there was no exe-gfx compo at the time) and got 7th place.
[pouët]
Since the challenge I (and yx) have given myself was specifically to make something in Python, I wanted to make sure the entry was as close to "100% Python" as possible. I could have used a short Python script to create an OpenGL context and do the rendering in GLSL, or I could have used Python's built-in gzip library for compression, but neither option felt like it was "100% Python", so I did it the manual way.
Compressing the Entry
To compress the entry, I wrote a little script packer called crispy, which uses dictionary compression to factor out duplicate substrings in the code. The way the compression works is heavily inspired by the javascript packer RegPack.
Let's look at a small example to illustrate how it works.
print("Hello, hello!")
Here, the script packer realizes that the substring ello
appears twice in the code.
To remove this redundancy, it replaced all occurrences of ello
with a marker character ~
and appended ~ello
to the end of the script to remember what substitution it did.
The code generated by the packer looks like this.
c='print("H~, h~!")~ello'
for i in'~':c=c.split(i);c=c.pop().join(c)
exec(c)
Here, the split
cuts the code into ['print("H', ', h', '!")', 'ello']
, the pop
removes the last string in the list ('ello'
), and the join
concatenates the strings in the list, using 'ello'
to fill the gaps, so we get 'print("H' + 'ello' + ', h' + 'ello' + '!")'
, which is the original string.
In this example, the packer ended up increasing the size of the script, as the input was already very small and had few redundancies. Because the packer comes with some overhead, it is only effective when compressing files in the range of a few kilobytes.
Also, every time the packer wants to deduplicate a substring, it needs some marker character that is not used anywhere in the input file, so the packer works best on scripts that use a smaller set of characters.
The packer does make use of the latin-1
encoding so it can use the 80-ff
range for markers, but the more markers it has the better.
Encoding the Image
To encode the image, I came up with a tiny language, which I will refer to as "+code", that encodes images as quadtrees.
The code consists of hexadecimal digits 0-f
and the +
operator.
When a digit is read, the decoder colors the canvas with the color corresponding to that digit, where 0
is black, f
is white and the other digits are some shade in-between.
The +
operator instructs the decoder to subdivide the canvas into four smaller pieces (you might be able to see why I chose +
for this operation) and to color those with the four +codes that follow the operator.
For example, the +code +01++234567+89ab+cdef
parses like this
(+ 0 1 (+ (+ 2 3 4 5) 6 7 (+ 8 9 a b)) (+ c d e f))
+
┌─┬──────┴───┬─────────────┐
0 1 + +
┌────┬┴┬────┐ ┌─┬┴┬─┐
+ 6 7 + c d e f
┌─┬┴┬─┐ ┌─┬┴┬─┐
2 3 4 5 8 9 a b
and produces this image the following image (digits added for clarity).
The encoding was designed to use a small set of characters to make sure the script packer has plenty of characters it can use as markers. It also allows us to encode different parts of the image at different levels of detail to make sure we're not wasting too much space on large blobs of color (of which there are many in this image specifically).
Rendering the Image
The image is rendered using Python's built-in turtle library. If you're unfamiliar, this is a library where you can draw simple lines and shapes by moving a "turtle" around a canvas using commands like "start drawing", "move forward 20 pixels", "turn right 30 degrees" and similar. The library was made to help children learn the basics of programming, but that doesn't mean adult demosceners can't use it too.
At a high level, the decoder parses the +code for the image recursively and draws a square every time it encounters a digit. In an attempt to hide how large some of the boxes in the image are, the decode also adds some random noise by subdividing squares further until a recursion limit is reached.
Due to the nature of the turtle library, the way it draws the squares at the end is hilariously verbose. At least code like this is easy for crispy to compress.
goto(x, y) # move the turtle to the bottom left corder of the square
begin_fill() # start the shape
fd(s) # move forward `s` pixels
lt(90) # turn left 90 degrees
fd(s) # move forward `s` pixels
lt(90) # turn left 90 degrees
fd(s) # move forward `s` pixels
lt(90) # turn left 90 degrees
fd(s) # move forward `s` pixels
lt(90) # turn left 90 degrees
end_fill() # finish the shape
Conclusion
I definitely spent way too much time on this one silly joke, but it was fun and I learned a lot, so it was worth it.