Game Boy Wordle clone: How to compress 12972 five-letter words to 17871 bytes
alexanderpruss.blogspot.com
alexanderpruss.blogspot.com
I created a word search game way back in late 1978/early 1979 4KB of RAM. I had about 2KB for storing the word database. And I recall I had about 2,500 words. Which I had to type in by hand.
I used the 26 tables of words trick too, to drop the first letter. I also treated each word being made up of 32 symbols. I called them letters, but they didn't represent individual letters necessarily.
Edit 36 minutes after writing my original comment: Having just checked my notes from December 24th 1978, I need to correct myself and say it was 38 symbols. Though I seem to recall I actually got it down to 32 symbols. I need to keep looking through my notes to refresh my brain on the solution.
A word might end in "ING" so that was considered a single letter/symbol. Words were between four and seven letters long.
Most words took up two bytes, some only took up a single byte, only a few took up three bytes, maybe a few dozen took up four bytes. And I judiciously pruned the database to minimize the words that took up three or four bytes so that I didn't have too many of them. I also packed the bits, so that there was no gaps between words.
And it was all written in 6502 assembly, originally for the CBM PET, and later for the Acorn Atom.
I was 11 years old and so proud of myself for such cleverness. I still have my notes and scribblings, including the digitised versions from all those years back.
Later, when I wrote a much more complex Bookworm type of game I had to take a word list that was about 8MB long, uncompressed, which would just barely fit on the hard drive, and turned it into a trie, which brought it down to just a few dozen kilobytes, which handily fit on a SS/SD 100KB 5+1/4" floppy.
Not sure why the 6 was so popular for many programs then. It was for sure Photoshop 7 era.
https://www.moria.us/blog/2022/01/dictionary-compression
The annoying part is that the NES only gives you four background palettes, and you are constrained to use all four of those to color your words.
Anyway, thinking about the transposing idea some more: this would effectively split the word in to 26² = 625 "buckets" of three-letter suffixes. What we could do to make those still compress decently after transposing is look for shared suffixes in multiple buckets, and ensure they get grouped together in the same order before transposing. This would result in short runs in those suffixes, squeezing some more compression out of it.
... which should also work really well for implicit delta coding.
Hmm... you know, the basic concept here shouldn't be too difficult to implement and try out out, thanks for the ideas! :)
If you have a means of doing RLE that performs otherwise, I'd love to understand how it works.
FYI, turning it into 12972 by 5 and Brotli compressing achieves 15,093 bytes, which is less than if you first turn the data into an ASCII trie then Brotli compress that (14,180 bytes) (Source: https://github.com/adamcw/wordle-trie-packing#all-words).
My only claim in this post is that anything can be pre-sorted if you want to achieve some better DS compression but of course, that means you have to have some map to undo the sorting after you decompress it (and obviously the utility only exceeds the computation time if the documents are longer than the associated dictionaries). The person who wrote the gameboy wordle compression did it by necessity, which is beautiful, and the way people used to do things when you had to fit them into tiny structures like that, so, huzzah! to that person.
But yeah, the observation that you could handle 40% reduction on the first two characters is a good clue.
So much has been missed in lossless image compression, along the same creative lines. We humans can look at an image of a red-black-yellow Cardinal bird sitting on a green-gray stem in the middle of a forest, and basically compress it in our mind in a way you'd have to throw thousands of CPU hours against. If you knew you only had to consider a red bird on a green background, you would have a whole different domain-specific strategy for compression; the amazing thing about our minds is that we can devise that compression strategy from the inputs, remember the strategy for that specific set, and then recall our own compression strategy well enough to decompress the data later.
There was, actually, an attempt in the 90s to do something they labeled "fractal compression" which was more or less an attempt to come up with a lambda function for a particular image; extremely CPU expensive to compress, and might or might not be lossy depending on the goal, but the salient thing was that the compression strategy was unique for each image. That didn't really work out as a commercial concept for a whole host of reasons. But it's one of those corners of extremely clever pre-modern code that might be worth a bundle to revisit now.
So if you wanna code something really, really fun -- consider a fully adaptive compressor that comes up with a specific strategy for each general sub-batch of use cases.
If you want to start a company doing that, let me know because I literally just came up with this idea 12 seconds ago.
This is conceptually similar to what OP does by storing the (numerical) difference between the words. Also, if you have a list of numbers that aren't random, they generally compress better if you turn it into a list of the differences between the numbers.
A simple compression algorithm (miniLZO is apparently 6KB compiled) might be small enough and save enough bytes with compression to make it worth it for OP.
If you want to skim, check out the EXAMPLE section toward the bottom.
29.44% - Original sorted list just compressed with zstd
22.45% - Matching prefix characters from previous word replaced with space
19.38% - Matching prefix characters from previous word removed
Years ago I worked on a J2ME (Java2 Mobile Edition) application that had no business being attempted given the very small archive files allowed. We did it anyway and it actually worked pretty well. We very quickly hit the max file size however, and every feature request meant first shrinking the existing code base to make space. First we had an intern fixing bugs in the code minifier we were using, especially around deleting unused (usually debug) methods. For some reason they rejected on archive size, not payload size, so while I started out doing 'honest' work with shrinking the binary, I had spent a lot of time in college noodling with compression algorithms so my eye was eventually drawn there.
Those were in the days when I could still read JVM assembly code, and shortly after I started thinking about the compression, I realized that the constant pool entries start with the type and then the size of the entry. So while our minifier made the reasonable assumption of sorting the constant pool by type and then alphabetically within it, because most of the constant pool was strings, and strings are variable length, it was hit or miss whether the header would be treated as a run or just Huffman encoded (the fallback). If I suffix sorted, then all but the last string in the pool would be followed immediately by the header for the next string, increasing the average run length.
This ended up knocking almost a kilobyte off of the archive size. Depending on your perspective that sounds like a little or a lot, but in our case each feature cost about 500 bytes, so that change pushed the cliff I was walking toward out almost a month (and slowing the growth rate), just by changing a sort algorithm.
I filed a ticket with Sun about this, but as it turns out they already had the dense archive format in flight, and within a couple months my observation was moot because the dense format can compress constant pools across and entire archive, not just a singe file. That was at least an order of magnitude better than what I had.
It's quite likely a lot of the files we use have similar problems in them. Off the top of my head, JSON compression probably would be much higher if we treated it as unordered, and did more aggressive minification particularly for JSON-at-rest. Sorting sibling keys by value instead of by name for instance.
Similarly, having to contain the decompression code in the measured result size and it being a relevant contribution is something that only applies in some use cases of compression.
That's why people still write for the Z80: it's a fun toy.
For genetic data, HapZipper beats general-purpose compression. https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3488212/
So, yes, actively researched, but you've got to pick a specific task that makes sense. Even small niches are viable; I made a task-specific compressor to strip the essential numbers out of a remote sensor report to make it small enough to squirt to a satellite.
It kinda didn't matter from 2010 on. But one area I've written my own specific "compression" methods in, for the last few years, has been in shipping data in and out of webworkers (in-browser or in Node). This is where there's still enough of a performance penalty on a lot of devices for sending 1MB that in use cases where you're spawning lots of workers to run long tasks, it makes sense to trade time to compression for a smaller transfer size.
Eg. You work for a doorbell company and the boss says "yo, can we make our doorbell have 6 tunes instead of one, because our competitors are doing that. No, we don't want to change microcontroller".
https://blog.transitapp.com/how-we-shrank-our-trip-planner-t...
Working on GPUs, I see many, and work on some task specific compression ideas as part of my job. The compiler has it’s own ways of compressing code & debug info. The hardware has it’s own ways of compressing textures. A recent feature we built on my team is a compressed encoding for adaptively subdividing curves. All of these things have the primary goal of reducing memory bandwidth, which in turn increases the speed of computation because memory is so frequently the main bottleneck.
I had to do this in the early '80s. The alternative was scrapping the boards and redesigning them to allow double the EPROM size but that would have been a lot more costly than writing the decompression routine and manually compressing the strings. It would also have delayed delivery.
That system used some sort of lossy compression that created artifacts like fake words that don't exist but look enough like real words from the dictionary's point of view that they can be generated.
I find it fascinating.
Golf.horse is measuring the payload plus the compressor, which I don't believe the author is doing, and is important when trying to be objective about the relative strength of solutions. Otherwise you can store the entire file out of band in the compressor, emit 1 bit in the output file, and then the compressor just returns the expected value on 1 and throws an error on 0 saying the file was corrupt.
On the gameboy, you have the advantage of being able to use the full 8 bits per byte.
f(-x) = 0
f(0) = 1
f(x) = 0x80 * f(x-1) + 0x780 * f(x-2) + 0xf400 * f(x-3) + 0x100000 * f(x-4)
(Replace 0x80 with 0x7c to account for ES6 template literals.) The characteristic polynomial for this recurrence has a positive root of 144.61 (or 141.12 for literals). This means that you can actually put quite more than 7 bits per byte in a valid JS code, provided that your decoder is negligibly small enough. Indeed, there exists an encoding that allows exactly 7 bits per byte by using two-byte-long UTF-8 sequence as an escape code [1].However, this doesn't beat general Brotli encoding of a ASCII trie representation, which gets down to 14,180 bytes (but needs an experience decoder), but goes to show general purpose compression is still really really good these days.
A lot can be done in 3014 bytes, but what's the difference in code size for the ascii trie vs. a flat list/gzip/brotli?
[1] https://lifthrasiir.github.io/roadroller/ (the exact parameters: golf.horse dataset; input mode text; action write to document; # contexts 12 with 12,15,49,50,70,79,96,97,131,154,292,353; pollute the global scope; max memory usage 150 MB; precision 16; learning rate 1333; model max count 11; model base divisor 14; dynamic model flags -1; # abbreviations 64)
It compresses better with new lines than if you remove them all (given you could just split on every 5 characters later), which is an odd quirk of compression algorithms that my brain will never quite grasp.
My best algorithm attempt + Brotli achieved 12,773 bytes, which is a painfully close 542 bytes away. It is 13,181 bytes raw though, and can technically be used in-memory, which is definitely a perk.
New lines give a usable context (namely the word boundary) to compression algorithms. If I give you an arbitrary unsorted list of 5-letter-long words with no delimiters you need to think harder to figure out that it is indeed a list of 5-letter-long words. Same for the compression algorithm.
> My best algorithm attempt + Brotli achieved 12,773 bytes, which is a painfully close 542 bytes away. It is 13,181 bytes raw though, and can technically be used in-memory, which is definitely a perk.
Yeah, the best solution depends on what you want to do with that. Your estimation is not too far from my experience: Roadroller tends to be on par with or slightly smaller than Brotli. Of course, Roadroller exists because web browsers generally don't provide a way to use Brotli in JS ;-)
The usual application involves letter frequencies without context. Rather than a trie for deterministic context, one could in far less space compute a hidden Markov chain of small but effective dimension, to generate the probabilities for arithmetic coding.
You could run RLE on that as well for a decent storage savings.
Another thought: you could order the list of words such that the first 1622 words are answers. That way you don't need to store the answer list and checking is as fast as comparing the value of a memory address. This would probably hurt the differential coding performance though.
An alternative would be to make 0 mean five zeros (or some other N) and then if you hit a 1, it means the next 5 bits are to be interpreted as-is. This reduces all 5 length 0s to 1 bit, while only adding 1 bit whenever there is a bit. At worst this introduces 1 extra bit per answer. The answer to non-answer ratio is about 5 to 1, so this should definitely save space while also having a trivial decoding algorithm.
Original word list: https://raw.githubusercontent.com/arpruss/gb-fiver/main/comp...
$ <full.txt wc # "word count"
#lines, words, bytes
12973 12972 77833
$ calc 77833-12973 # bytes minus newlines
64860 # matches the article, to confirm I got the right input data
$ <full.txt xz -9 | wc -c
15412
2000 bytes smaller without any optimizations (this even includes the newlines), though now I guess the question is what the size of the xz decompressor is since a few KB actually matter (not like on a regular computer).For some reason bzip2 gets it only to 36K, even worse than gzip (32K) and zstd (29K).
Update: Counter-intuitively, stripping the newlines (... | tr -d \\n | ...) results in a higher compressed size with xz. It's surprisingly hard to find a minimal xz decompressor, it doesn't seem as though anyone bothers with this stuff. The Hutter Prize includes the decompressor so that was my first stop, but none of the contestants submitted that (I assume they predate xz).
Update#2: Found at least one measurement of xz decompressor at 36K, but it seems to me like this is the general-purpose utility and includes the compressor, help output, etc. http://mattmahoney.net/dc/text.html
Neither competes with RoadRoller (which gets down to around 12,200 and includes the code for decoding), but that takes forever to decompress and uses a ton of memory so certainly not applicable for this application.
See my other comments in this thread if you have interest!
https://www.cs.cmu.edu/afs/cs/academic/class/15451-s06/www/l... compresses a 780k word list into 175k. That’s about 22% of the size. This accomplishes 27%.
This list is a lot shorter, so there will be fewer opportunities for savings. On the other hand, all words are five letters, so the ‘is a word’ bit can be taken out.
That sounds like a DAG-shaped FSM to me...? At least I can't spot the difference.
I guess a strategy for compressing a word set could be to compile a regular expression recognizing it using a good regex engine and to then construct a compact representation of the resulting automaton.
I based my approach on http://www.wutka.com/dawg.html and http://stevehanov.ca/blog/?id=115.
I generated a DAWG with 12,822 nodes, which means you need 14 bits for each pointer. A trie representation can be packed much smaller because you don't need to randomly jump around the graph, you can just read it out sequentially.
With huffman coded labels and offsets, I got the size down to approximately:
- 94,761 bits for offsets. - 56,900 bits for labels. - 12,822 bits for indicating when you're at the end of a next chain.
= 164,483 bits + Size of Huffman Table = ~20,560 bytes
I assumed I didn't need any bits for indicating end of word, because all Wordle words are length 5.
Meanwhile, bitpacked trie can get down to 15,599 bytes.
https://github.com/adamcw/wordle-trie-packing#all-words
It's not clear to me a path that will compress the DAWG so much that it could cut another 5000 bytes and whatever the Huffman table size is.
However, I think you can layout the tree so that no pointers point backwards. If so, can you make those offsets smaller by making them relative to the current point in the tree?
Also, since the list only has five-letter words, for the last letter, you don’t even need the letters themselves, just 26 bits for what letters can complete a word. That might be a saving.
Also, the crab source code is available (DEC: http://www.gtoal.com/wordgames/gatekeeper/crab.sh.txt, Mac: http://www.gtoal.com/wordgames/jacobson+appel/mac/Crab_sourc.... Both via http://www.gtoal.com/wordgames/scrabble.html)
Both are nice examples of C the way it is intended to be written, or rather, was intended to be written decades ago.
I don’t remember how that stores the data, but it might do a trick you didn’t think of.
And finally, I just realize that, for fairness, you need to look at (data size + decompressor size). Did you do that?
I also surmise that the short length of the words makes a DAWG just very heavy.
It's not clear to me that relative offsets would be notably smaller to the extent that would be needed. Even a hypothetical and cheated DAWG I came up with is ~33% bigger than alternatives. I've generally explored enough (see the paper in my other comment) that I don't feel that further investigations into a DAWG are likely to outperform other methods.
I can't see anything immediately that jumps out that the Crab game is doing that's special to save space, I think it just achieves better compression because you can compress larger files easier, and the words are longer with more overlapping sections.
I agree that you need to compare including the decompressor size, so I'm not sure which approach is better the Huffman trie or the one in the original article. I'm not familiar enough with GB programming to be able to suggest how much program memory would be needed to decode the Huffman Trie, it looks like it would be somewhat similar in complexity.
With 12,822 nodes, you need 57,387 bits for the labels and the Huffman table (I'm sure you could make the Huffman table more efficient, but it's only 50 bytes, so that's not helping much).
Then, to mimic their edge reordering technique but without having to actually implement all the logic, I ordered the edges by frequency and used variable length integer encoding of size 3 (this performed the best on the data set) which required 95,988 bits.
Variable length integer encoding breaks the number into 3 bit chunks, each prefixed by 1 bit to indicate if there is another 4 bit chunk to read for that number. Since the distribution of offsets is heavily skewed, optimizing the most frequent offsets into a small package is better even if rarer ones suffer from multiple prefix bits.
This is 19,171 bytes total, or substantially worse than both the original article and Huffman tries do. This isn't even counting the flag bits needed for actually traversing the graph. So even cheating, it's not clear I can get a DAWG to be within striking distance of either other approach.
I hypothesize that the reason tries and other methods perform so well here is the relatively shallow depth. All words are only length 5, so the trie doesn't ever get really deep. This also means that suffixes generally don't actually take up that much space given common ones will also pack small with Huffman coding. The size of offsets appears to be just too great relative to how much you can save by removing shared suffixes from 5 letter words.
Would love to know if there is some trick to DAWG that I'm missing that would let me get it even smaller.
Any time you are tasks with crushing the living daylights out of an unordered list, always, always look at suffix sorting as an option. It might not work out as useful, but it's frequently worth the cost of checking.
I will say that given that a delta encoding was settled on, bitpacking the words first is probably a mistake, and multiplication should have been used instead. For instance using multiplication you can store the words in 24 bits without chopping off the first character and using pointers to them. That may seem a small difference but it makes the deltas he's looking at narrower. So instead of choosing 8 words in 8 bytes versus 10 words in 8 bytes, it could be 8 vs 11, possibly 12.
He is encoding 7 bits per byte, so there are about 172 words that spill over into the next byte due to this.
With 5 bits per letter, if the second to last character shifts by more than 4, then it automatically spills over. With 26, it also depends on how much the last letter also varies.
Found a GBC implementation as well: https://github.com/bbbbbr/gb-wordle
The current published release uses a similar compression approach by zeta_two, but in current builds I've switched to the compression by arpruss since total data + decompression code size is now a couple hundred bytes smaller.
I did some profiling and code size measurements before switching over. https://github.com/bbbbbr/gb-wordle/blob/compress_arpruss/wo...
Speed (and code size somewhat) have improved more since then.
Base bitmap is 12972 bits, or 1622 bytes (your file lists 1619, not sure why it's 3 bytes smaller, but all the same). You can "skip encode" (I don't know the formal name for this technique) into 1232 bytes by encoding runs of three [0, 0, 0] as [0], and anything else as [1, X, X, X], saving another 390 bytes.
I tried all combinations of runs between 1 and 7, and 3 is optimal.
With alphabet in order, assembling letters ABCDE: 17345.00 bytes With alphabet in order, assembling letters EDCBA: 16949.00 bytes With alphabet order tweaked, assembling letters EDCBA: 16309.00 bytes
Where tweaked means you build your offset as if each position was ordered like this ([::-1] means reverse if you're unfamiliar with Python).
``` alpha1 = "abcdestfghijklmnopqruvwxyz"[::-1] alpha2 = "eaioustrbcdfghjklmnpqvwxyz" alpha3 = "aeioustrbcdfghjklmnpqvwxyz" alpha4 = "eaiousthrbcdfgjklmnpqvwxyz" alpha5 = "aeioustryhkbcdfgjlmnpqvwxz" ```
You can also use a prefix rather than variable length encoding, this means you can use 2 bits to represent a number bigger than 2^14, rather than 3. This might hurt your ability to decode though, as you'll have bits that cross byte boundaries.
breaksv = [2**7, 2**14, 2**21]
prefixesv = [[0], [1, 0], [1, 1]]
You can get much smaller using length 3 varints rather than 7 (13,110 bytes), but I presume that would perform worse on GB hardware than staying byte aligned.Anybody know what I'm thinking of?
After throwing more words at the Google Wall, it finally allowed that what I'm thinking of is the Shortest Superstring Problem.
This morning without putting a whole lot of effort into this, I was able to winnow it down to 27.32 bits per word without any other trickery like 5bit packing. You need at most 1 bit per word to identify the real words, so that's 27.32+1 bits without the bitpacking. Doing 6 bits per letter drops that by 25%.
Now having put too much effort in, I'm around 21.6 + 1 bits per word, (17 bit packed) just by using SSP.
The linked post reaches 3.6 bits per byte. E.g. [1] uses finite state automata to reach 1.1 bits per byte for a Scrabble word list and 1.5 bits per byte for an English word list. Both word lists are probably more difficult, since they contain words of varying lengths and long words have less sharing in their pre/suffixes.
[1] https://www.cs.put.poznan.pl/dweiss/site/publications/downlo...
Which is terrible but still probably faster than the algorithm that the linked article is using, since finding the offset of the kth worth takes O(k) time, and there are 12948 (I still haven't found the mythical 12972 word list).
NYT source: https://www.nytimes.com/games/wordle/main.4d41d2be.js
NYT removed 6 words from the solutions
agora pupal lynch fibre slave wench
and 19 words from the guessable list
bitch chink coons darky dyked dykes dykey faggy fagot gooks homos kikes lesbo pussy sluts spick spics spiks whore
so that's 12972 - 25 = 12947
I am glad they did that, but I'm not sure I wanted to know that those used to be in the dictionary.
(also you are probably on a list now, but we appreciate your sacrifice)
8 * 17,763/64,860 = 2.19
Also, I attempted to implement this as described in this paper (variable length encoding the letters and the offsets, utilized L, and dropped F entirely because all words are the same length, N didn't make a big difference).
I achieved a naive size of 20,560 bytes, which I didn't have confidence implementing more advanced techniques outlined in the paper would get the size down sufficiently to compete with using a trie+Huffman representation (15,599 bytes, https://github.com/adamcw/wordle-trie-packing#all-words).
8 * 15,599/64,860 = 1.92 bits per byte.
But it’s possible that you could accidentally make duplicates of one word by pairing others. For a single copy you can omit that word. But if it appears multiple times that represents a compression opportunity that a shuffle to avoid it might destroy.
an extension of portmanteau:
https://en.m.wikipedia.org/wiki/Portmanteau
De Bruijn sequence is more restricted: a cyclic portmontout over a "complete" lexicon of fixed sized words, where every possible string is a valid word.
Say you want to store the information that the word ‘algorithm’ occurs in documents 42, 2718 and 3141. That’s a sorted list, so as the author notes, you can just store the differences (42, 2676, 423). Those differences can still be arbitrarily large, but if there are many documents, you can expect most differences to be small.
The trick is to store the numbers not as fixed-bit-size integers, but using a variable-length encoding. The algorithm stipulates using a Golomb code with the parameter b = ceil(N / n * ln(2)), where N is the number of documents in total, n is the number of documents containing our word, and ln is the natural logarithm.
For our example, assuming 5000 documents in total, this gives N = 5000, n = 3, b = 1156, and our index entry is 10000101010001010110110010110100111, for a total of 35 bits.
This sounds like a very wrong approach to optimization. I mean, if you don't know exactly what and how to optimize, or if there's a need for optimization at all, then what are you doing?
I think OP was saying they weren't sure if the original algorithm would be too slow to run under these conditions, and didn't have the ability to test it at the time, so they wrote it in a way which increased the chances of it running quickly considering the system limitations.
https://shkspr.mobi/blog/2021/05/the-74000-numbers-of-barcla...
I tried a similar scheme of sorting the list and storing the delta of the previous number:
Green usually signifies something is correct. You should have used yellow for misplaced letters instead.
I had CRAN? in the second row, but I lost the game, because it was RANCH.
$ grep '^[a-z]\{5\}$' /usr/share/dict/words | python -c '
> from sys import stdin
> from os import write
> N = 26 ** 5
> data = bytearray((N+7)//8)
> for l in stdin:
> b = 0
> for c in l.strip():
> b *= 26
> b += ord(c)-ord("a")
> data[b//8] |= 1<<(b%8)
> write(1, data)
> ' | gzip | wc -c
12126
decompression code costs extra. Though I imagine someone has a small gunzip implementation somewhere. The memory requirements for inflate are (in bytes) 1 << windowBits
that is, 32K for windowBits=15 (default value) plus about 7 kilobytes
for small objects.I'd have to check the version history, but while 15 bits is the default it's also the maximum, and you can go down to 8. Hardware has gotten a lot faster.
only 5 letter words from the dict, not the entire one. check the grep command at the beginning.
And a C64 port: https://twitter.com/roysterini/status/1493540659352985602
A brief quote about compression in the NES port:
https://twitter.com/FG_Software/status/1491044035884371971 "Official #Wordle dictionary implemented, and the game can now select a solution from all those found in the original for the cost of 1 extra bit per word! Uncompressed size (Raw text files): 76060 bytes Compressed size: 26256 bytes"
https://twitter.com/FG_Software/status/1495298243668099073 "Words are stored in 2 bytes: 15 bits data, 1 bit to check if it's a solution. They're all sorted alphabetically, so I can algorithmically determine the first 2 letters with a lookup table, and stick the last 3 letters in 15 bits. Bit more to it but you can't fit it all in a Tweet"
I've been working on a Game Boy Color (and regular GB) fork that in current builds uses the compression by arpruss. https://github.com/bbbbbr/gb-wordle
With 5 bits per letter, you have 6 symbols left over. We can use those to represent alternate pairs like "A or E", so you can encode BANDS and BENDS at the same time. Looks like if you pick the 6 highest frequency replacements for each starting letter, you can reduce the full word list size by ~2k words.
A naive lookup table for the replacements is 26 * 2 * 6 = 312 bytes.
edit: oops double counted the reduction
interestingly, the sweet spot is a mix at 30^4 at 16797B
But more likely one of us has a bug in their logic.
Many times you don't even need to store the individual letters, just the pairings, and if you are permitted to prune out troublesome words from your dictionary, all the better.
[One of] the reasons gzip does worse is it also preserves the order of input.
I'm very curious about this.
I took the central idea of encoding deltas (or actually delta less 1, since the delta is always at least one; I'll just say delta below), but did it on the full five letter word. The largest delta was still less than 2*18 but bigger than 2*17. (I'm not sure why the blog mentions 20 bits as the biggest delta; I used the dataset from https://github.com/alex1770/wordle/commit/62520406365ca58a1a...)
I decided I wanted a variable length code in bits. Manually, I found the best break-points that I could:
breaks = [16, 128, 512, 2**12, 2**18]
bitprefixes = ['0', '10', '110', '1110', '1111']
So deltas 0..15 are encoded as '0' plus a 4-bit number, deltas 16..271(=16+256-1) as '10' plus a 7-bit number, etc.Compressed like that, my dictionary runs to 14840 bytes.
1111000000000001010110 // aahed 4839 = 4839- 0
1110100001101100 // aalii 2813 = 7652- 4839
1110110100010010 // aargh 4003 = 11655- 7652
110011000010 // aarti 339 = 11994- 11655
1111000000001101110001 // abaca 5634 = 17628- 11994
00111 // abaci 8 = 17636- 17628
00001 // aback 2 = 17638- 17636
00111 // abacs 8 = 17646- 17638
100111110 // abaft 79 = 17725- 17646
...
However, a non byte aligned code isn't ideal, especially on the gameboy's CPU which doesn't have variable bit shifts as far as I recall. Still, over 3000 bytes beckoning to be re-used for some other purpose. Did they put in a soundtrack yet?Compressor & decompressor: https://gist.github.com/jepler/d502965b57fd52df0838a6def2d32...
I took this technique and made a few changes.
Firstly, I effectively did variable length integer encoding in chunks of 3, this mildly outperformed your hand crafted prefixes.
self.breaksv = [2**3, 2**6, 2**9, 2**12, 2**15, 2**18, 2**21]
self.prefixesv = [
['0'],
['1', '0'],
['1', '1', '0'],
['1', '1', '1', '0'],
['1', '1', '1', '1', '0'],
['1', '1', '1', '1', '1', '0'],
['1', '1', '1', '1', '1', '1', '0']]
Secondly, I made my offsets relative to the overall solution space of 26*5, ordering the words sorting from their ends, and with a little bit of twiddling the order of the alphabet to put common letters near the start: alpha1 = "aeioustrbcdfghjklmnpqvwxyz"
alpha2 = "aeioustrbcdfghjklmnpqvwxyz"
alpha3 = "aeioustrbcdfghjklmnpqvwxyz"
alpha4 = "aeioustrbcdfghjklmnpqvwxyz"
alpha5 = "aeioustrybcdfghjklmnpqvwxz"
bitmap = []
for ia, a in enumerate(alpha1):
for ib, b in enumerate(alpha2):
for ic, c in enumerate(alpha3):
for id, d in enumerate(alpha4):
for ie, e in enumerate(alpha5):
bitmap.append(e+d+c+b+a in words)
Doing this, and then doing the variable length encoding I got the file down to 13,181 bytes (it was 13,180 bytes, but I needed to add a 7 bit termination string so that you can properly decode the file after you write it to disk, otherwise when the file rounds to the nearest byte you have random 0s that get decoded).I'm sure with some twiddling of the alphabets some more you could save a few more bytes, but this does better than both Brotli on a ASCII trie and the Huffman Trie by almost 1KB (https://github.com/adamcw/wordle-trie-packing#all-words), so I'm very happy.
These days GADDAG are used which are faster, but usually much less space efficient: https://en.wikipedia.org/wiki/GADDAG
Neither seem to work well in my attempts on this data as the words all being short and the same length work against it in these schemes.
Works perfectly on the 3DS (mine is a New 3DS XL) with the NSUI! https://i.imgur.com/TjGYsKC.jpeg
Might be a perfect hash waiting in there somewhere.
> Step 2: Each four letter “word” (or tail of a word) can be stored with 5 bits per letter, thereby yielding a 20 bit unsigned integer.