<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Langur Monkey - Toni Sagristà Sellés</title><link>https://tonisagrista.com/</link><description>Recent content on Langur Monkey - Toni Sagristà Sellés</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><managingEditor>me@tonisagrista.com (Toni Sagristà Sellés)</managingEditor><webMaster>me@tonisagrista.com (Toni Sagristà Sellés)</webMaster><lastBuildDate>Sat, 07 Nov 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tonisagrista.com/index.xml" rel="self" type="application/rss+xml"/><item><title>A maximally tiny language model</title><link>https://tonisagrista.com/blog/2026/tinylm/</link><pubDate>Sat, 07 Nov 2026 00:00:00 +0000</pubDate><author>Toni Sagrista Selles</author><guid>https://tonisagrista.com/blog/2026/tinylm/</guid><description>&lt;p&gt;In this post I document a little experiment I have been playing around with for a while that explores an absolutely minimal language model. I&amp;rsquo;m specifically interested in how small a language model can be made while still doing &lt;em&gt;something&lt;/em&gt;. I got the idea while I was reading a post about GPT-2. A pre-COVID model, GPT-2 now seems minuscule in comparison to current frontier models. Its largest incarnation was trained on about 40 GB of text, had 1.5B parameters and used an embedding dimension of 1600. Nowadays, a GPT-2-like model can easily be trained in a consumer GPU with ~16-24 GB of VRAM.&lt;/p&gt;
&lt;p&gt;If the GPT-2 family of models pales in comparison to current model dimensions, the model I present here today pales in comparison to GPT-2. Since it is extremely minimal, it can be trained on a single CPU core in a few seconds and needs very little RAM. It can have single-digit embedding dimensions, does not follow the transformer architecture, has no attention mechanism, and may not even use any hidden layers! It is a purely academic model, in the sense that it is absolutely simple but does nothing &lt;em&gt;useful&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ll call the language model in this post &lt;strong&gt;TinyLM&lt;/strong&gt;. Below is a summary of some of the features and concepts that this post touches on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A closed world.&lt;/strong&gt; 92 words, a few hard rules, 2560 sentences. That is all the language there is.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Learned embeddings.&lt;/strong&gt; Nobody tells the model that &amp;ldquo;wine&amp;rdquo; and &amp;ldquo;beer&amp;rdquo; belong together. It works it out from the sentences alone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A bag of words.&lt;/strong&gt; Words go in unordered, so &amp;ldquo;the chef met the clerk&amp;rdquo; and &amp;ldquo;the clerk met the chef&amp;rdquo; look exactly the same to the model.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A ceiling of about 48.4%.&lt;/strong&gt; Throwing the order away caps accuracy, no matter how big the model gets.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A few hundred to a few thousand parameters&lt;/strong&gt;, with the embedding size and the hidden layer size as free knobs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A sweep over those two knobs&lt;/strong&gt;, from the baseline all the way up to the ceiling.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Since it is so easy to train and test, the model is fluid. It can have between a few hundred and a few thousand parameters, depending on the dimensionality of the embeddings and the size of the hidden layer. But let&amp;rsquo;s start at the beginning.&lt;/p&gt;
&lt;h2 id="the-world"&gt;The world&lt;/h2&gt;
&lt;p&gt;Such a small model wouldn&amp;rsquo;t fare very well trying to reproduce human language, so I reduced it to a set of predefined sentences. I call this set our closed world. This world is composed of only 92 words: 8 proper names, 12 actors, 6 prepositions, 28 places, one determiner, 24 objects, and 13 verbs. Everything that can be expressed is done through those 92 words. I combined them using some rules and got the 2560 possible sentences that constitute our world. This is the whole world. This is the training dataset. This is everything TinyLM will ever be able to learn and recognize.&lt;/p&gt;
&lt;p&gt;Every sentence in the world has the same shape: a subject, a verb, an object, and sometimes a place. Subjects are always people. There are 20 of them, either a bare name (&amp;ldquo;john&amp;rdquo;, &amp;ldquo;mary&amp;rdquo;, &amp;ldquo;lucia&amp;rdquo;) or &amp;ldquo;the&amp;rdquo; plus an occupation (&amp;ldquo;the chef&amp;rdquo;, &amp;ldquo;the pilot&amp;rdquo;, &amp;ldquo;the farmer&amp;rdquo;). The verbs come in two families. Four of them (&amp;ldquo;helped&amp;rdquo;, &amp;ldquo;met&amp;rdquo;, &amp;ldquo;thanked&amp;rdquo;, &amp;ldquo;invited&amp;rdquo;) are things you do to a person, so the object is another person and never the subject itself: &amp;ldquo;lucia invited mary&amp;rdquo; is in the world, &amp;ldquo;lucia invited lucia&amp;rdquo; is not. The other nine are things you do to stuff, and each one only takes objects of the right kind. You drink drinks, you eat food, you repair tools and homeware, you pack clothes and drinks. So &amp;ldquo;the chef ate soup&amp;rdquo; exists, &amp;ldquo;the farmer repaired the soup&amp;rdquo; does not.&lt;/p&gt;
&lt;p&gt;Finally, about half of the sentences end with a place: &amp;ldquo;near the bridge&amp;rdquo;, &amp;ldquo;inside the cellar&amp;rdquo;, &amp;ldquo;under the tower&amp;rdquo;. That&amp;rsquo;s the whole grammar. No questions, no negation, no conjunctions, no subordinate clauses, no adjectives. Just subject, verb, object, and sometimes where.&lt;/p&gt;
&lt;p&gt;A few real sentences out of the 2560, so you can see the shape:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;the pilot met john near the cellar&lt;/code&gt; &amp;ndash; person verb, subject and object both people, place at the end.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lucia invited mary&lt;/code&gt; &amp;ndash; the same, without the place.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;john drank wine behind the hut&lt;/code&gt; &amp;ndash; thing verb, and the object is a drink because &amp;ldquo;drank&amp;rdquo; only takes drinks.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;the clerk organized books under the store&lt;/code&gt; &amp;ndash; thing verb, object from the paper class.&lt;/li&gt;
&lt;li&gt;And two that are &lt;em&gt;not&lt;/em&gt; in the world: &lt;code&gt;lucia invited lucia&lt;/code&gt;, because you cannot invite yourself, and &lt;code&gt;the farmer repaired the soup&lt;/code&gt;, because soup is not a tool.&lt;/li&gt;
&lt;/ul&gt;
&lt;details class="collapsedcode"&gt;
&lt;summary&gt;
&lt;span class="code"&gt; &lt;/span&gt;&lt;span class="summary"&gt;TinlyLM world description&lt;/span&gt;
&lt;/summary&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;➜ uv run python -m tinylm.world
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;2560&lt;/span&gt; sentences, &lt;span style="color:#a6be9d"&gt;92&lt;/span&gt; distinct words
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;8&lt;/span&gt; names, &lt;span style="color:#a6be9d"&gt;12&lt;/span&gt; occupations, &lt;span style="color:#a6be9d"&gt;9&lt;/span&gt; thing verbs, &lt;span style="color:#a6be9d"&gt;4&lt;/span&gt; person verbs, &lt;span style="color:#a6be9d"&gt;24&lt;/span&gt; things in &lt;span style="color:#a6be9d"&gt;6&lt;/span&gt; classes, &lt;span style="color:#a6be9d"&gt;6&lt;/span&gt; preps, &lt;span style="color:#a6be9d"&gt;28&lt;/span&gt; places
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;verb -&amp;gt; object classes:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; drank drinks &lt;span style="color:#ff636f"&gt;[&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;beer&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;soda&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;water&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;wine&amp;#39;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; sipped drinks &lt;span style="color:#ff636f"&gt;[&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;beer&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;soda&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;water&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;wine&amp;#39;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ate food &lt;span style="color:#ff636f"&gt;[&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;apples&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;bread&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;soup&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;sweets&amp;#39;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; devoured food &lt;span style="color:#ff636f"&gt;[&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;apples&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;bread&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;soup&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;sweets&amp;#39;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; repaired tools + homeware &lt;span style="color:#ff636f"&gt;[&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;brushes&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;cups&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;jars&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;lamps&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;nails&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;plates&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;ropes&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;tools&amp;#39;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; cleaned clothes + homeware &lt;span style="color:#ff636f"&gt;[&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;boots&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;coats&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;cups&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;gloves&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;hats&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;jars&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;lamps&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;plates&amp;#39;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; packed clothes + drinks &lt;span style="color:#ff636f"&gt;[&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;beer&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;boots&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;coats&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;gloves&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;hats&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;soda&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;water&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;wine&amp;#39;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; bought food + paper &lt;span style="color:#ff636f"&gt;[&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;apples&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;books&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;bread&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;letters&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;maps&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;soup&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;stamps&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;sweets&amp;#39;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; organized paper &lt;span style="color:#ff636f"&gt;[&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;books&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;letters&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;maps&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;stamps&amp;#39;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; helped, met, thanked, invited -&amp;gt; any of the &lt;span style="color:#a6be9d"&gt;20&lt;/span&gt; people except the subject
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;things per class:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; drinks &lt;span style="color:#ff636f"&gt;[&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;wine&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;water&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;soda&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;beer&amp;#39;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; food &lt;span style="color:#ff636f"&gt;[&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;bread&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;soup&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;apples&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;sweets&amp;#39;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; tools &lt;span style="color:#ff636f"&gt;[&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;tools&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;brushes&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;nails&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;ropes&amp;#39;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; clothes &lt;span style="color:#ff636f"&gt;[&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;boots&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;gloves&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;hats&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;coats&amp;#39;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; paper &lt;span style="color:#ff636f"&gt;[&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;books&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;letters&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;maps&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;stamps&amp;#39;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; homeware &lt;span style="color:#ff636f"&gt;[&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;cups&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;plates&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;jars&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;lamps&amp;#39;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;samples:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; john helped mary in the store
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; john helped peter
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; john helped lucia inside the factory
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; john drank wine behind the hut
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; john drank water
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; john drank soda behind the bridge
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; the singer organized letters in the harbour
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; the singer organized maps
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; the singer organized stamps beside the train
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/details&gt;
&lt;h2 id="model-structure"&gt;Model structure&lt;/h2&gt;
&lt;p&gt;The whole model is two small tables of numbers. The first one gives every word a handful of coordinates: three numbers per word in the default model, so \(92 \times 3 = 276\). The second one, the readout, says how much each word likes each of those three directions, another 276. That&amp;rsquo;s it, 552 numbers in total. There is no lookup table of sentences and no rules inside, just those numbers. All of them are learned from the world above.&lt;/p&gt;
&lt;p&gt;The pipeline for guessing a word is almost embarrassingly simple:&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/2026/10/tinylm-architecture-linear.svg"
alt="92 counters go in, the \(92 \times 3\) coordinate table turns them into 3 numbers, the readout scores all 92 words, and softmax turns the scores into percentages. That is the whole default model: 552 numbers and no hidden layer." width="100%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption style="margin: 0 auto; width:100%;"&gt;
&lt;h4&gt;The architecture&lt;/h4&gt;&lt;p class="fig-attribution"&gt;92 counters go in, the \(92 \times 3\) coordinate table turns them into 3 numbers, the readout scores all 92 words, and softmax turns the scores into percentages. That is the whole default model: 552 numbers and no hidden layer.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The same three steps, written as matrix operations. Count the words of the sentence into a vector \(\mathbf{c}\) with one entry per word in the world, and let \(\mathbf{E}\) be the table of coordinates, 92 rows of 3 numbers. The sentence collapses into a single point by adding the rows of the words that appear, the readout \(\mathbf{W}\) scores that point against every word, and a softmax turns the scores into percentages:&lt;/p&gt;
&lt;p&gt;$$
\mathbf{h} = \mathbf{c}^{\top} \mathbf{E}
\qquad
\mathbf{z} = \mathbf{W} \mathbf{h}
\qquad
\mathbf{p} = \operatorname{softmax}(\mathbf{z})
$$&lt;/p&gt;
&lt;p&gt;with \(\mathbf{E} \in \mathbb{R}^{92 \times 3}\) and \(\mathbf{W} \in \mathbb{R}^{3 \times 92}\), so \(\mathbf{h}\) is a column of three numbers, \(\mathbf{z}\) (logits) is a column of 92 scores and \(\mathbf{p}\) is the column of 92 probabilities that this post keeps calling percentages. Each row of \(\mathbf{W}\) belongs to one word and says how much that word likes each of the three directions, which is the readout table above read along the other axis. The code keeps it transposed, as a \(3 \times 92\), which changes nothing except the order of the indices.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/2026/10/tinylm-architecture.svg"
alt="The whole thing with real numbers for &amp;ldquo;john drank&amp;rdquo;. A row of 92 counters, the (92 times 3) table of coordinates, the two rows that matter added into one point, the readout scoring all 92 words, and the softmax at the end. The bottom half adds up the coordinates of &amp;ldquo;john&amp;rdquo; and &amp;ldquo;drank&amp;rdquo; by hand and shows why the four drinks end up within a tenth of a point of each other." width="100%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption style="margin: 0 auto; width:100%;"&gt;
&lt;h4&gt;The model in one picture&lt;/h4&gt;&lt;p class="fig-attribution"&gt;The whole thing with real numbers for &amp;ldquo;john drank&amp;rdquo;. A row of 92 counters, the (92 times 3) table of coordinates, the two rows that matter added into one point, the readout scoring all 92 words, and the softmax at the end. The bottom half adds up the coordinates of &amp;ldquo;john&amp;rdquo; and &amp;ldquo;drank&amp;rdquo; by hand and shows why the four drinks end up within a tenth of a point of each other.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The middle step is worth a second look. The sentence is turned into a row of 92 counters, one per word, saying how many times each appears. Two words show up, so two counters are 1 and the other ninety are 0. Looking up the coordinates of each present word and adding them gives a single point, and that point is the model&amp;rsquo;s &lt;em&gt;entire&lt;/em&gt; idea of what the sentence is about so far. Then each of the 92 words gets a score for how well it lines up with that point, and the highest score wins. Three multiplications and two additions per candidate word, and that is the whole network.&lt;/p&gt;
&lt;p&gt;This is where the &amp;ldquo;bag of words&amp;rdquo; in the summary comes from. The input is a bag of counters, and counting does not care about order. Adding numbers does not care either: 3 + 5 is the same as 5 + 3. So &amp;ldquo;the teacher drank&amp;rdquo; and &amp;ldquo;the drank teacher&amp;rdquo; land on exactly the same point, get exactly the same scores, and the model happily finishes the nonsense one.&lt;/p&gt;
&lt;p&gt;The optional hidden layer is the one part that is not just adding. It sits between the point and the scores and turns the single &amp;ldquo;how well does this word line up&amp;rdquo; question into a set of small yes/no questions about the context, something like &amp;ldquo;was john seen?&amp;rdquo; and &amp;ldquo;was cleaned seen?&amp;rdquo;. Because each of those answers can be silent when it does not apply, the model can score a word high only when &lt;em&gt;both&lt;/em&gt; conditions hold, which plain adding cannot express. In symbols, the readout becomes&lt;/p&gt;
&lt;p&gt;$$
\mathbf{z} = \mathbf{W}_2 \operatorname{ReLU}(\mathbf{W}_1 \mathbf{h} + \mathbf{b}_1) + \mathbf{b}_2
$$&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/2026/10/tinylm-architecture-hidden.svg"
alt="The same model with 16 numbers per word and a hidden layer of 128 units: the point becomes 16 numbers, W1 turns it into 128, the ReLU keeps the positive ones, W2 scores the 92 words, and softmax does the rest. 15516 numbers in total, and this is the version that reaches the ceiling." width="100%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption style="margin: 0 auto; width:100%;"&gt;
&lt;h4&gt;The same picture with one ReLU layer&lt;/h4&gt;&lt;p class="fig-attribution"&gt;The same model with 16 numbers per word and a hidden layer of 128 units: the point becomes 16 numbers, W1 turns it into 128, the ReLU keeps the positive ones, W2 scores the 92 words, and softmax does the rest. 15516 numbers in total, and this is the version that reaches the ceiling.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;with \(\mathbf{W}_1\) of size \(3 \times k\) and \(\mathbf{W}_2\) of size \(k \times 92\), plus one bias per hidden unit and one per word. A narrower point also limits how much the hidden layer can help, which is why the interesting knob turns out to be the number of coordinates per word rather than the size of the hidden layer. As we&amp;rsquo;ll see shortly, at 16 coordinates per word and 128 hidden units the model has 15516 parameters and reaches the ceiling. The default is still the 552-number version (because the point of the experiment is the smallest thing that works!).&lt;/p&gt;
&lt;h3 id="accuracy-ceiling"&gt;Accuracy ceiling&lt;/h3&gt;
&lt;p&gt;Nobody can score 100% here, and the reason is that the world is non-deterministic by design. After &amp;ldquo;john drank&amp;rdquo; any of the four drinks fits, and nothing in the sentence says which one you meant. On average a bag of words has 2.72 reasonable continuations, so a perfect player that always answers with the most likely one gets 48.4%. That is the ceiling.&lt;/p&gt;
&lt;p&gt;Our 552-number model gets 29.2%, roughly three fifths of what is knowable. The same ceiling applies whether or not you can see word order, because no two sentences in the world share the same set of words, so order carries no extra information here.&lt;/p&gt;
&lt;h2 id="embeddings"&gt;Embeddings&lt;/h2&gt;
&lt;p&gt;The 92 rows of the coordinate table start out as plain random numbers, drawn from a bell curve. There is no meaning in them, and nothing in the world tells the model what any word is. The only feedback training ever gives it is &amp;ldquo;given the words so far, the next word was this one&amp;rdquo;. That is the entire supervision signal. The model is never informed that &amp;ldquo;wine&amp;rdquo; and &amp;ldquo;beer&amp;rdquo; are both drinks, or that &amp;ldquo;the chef&amp;rdquo; and &amp;ldquo;the pilot&amp;rdquo; are both people. Those facts are not in the input, they are in the structure of which sentences exist and which do not, and the model kind of picks that up indirectly.&lt;/p&gt;
&lt;p&gt;After 3000 small steps of nudging those numbers a little to the left or to the right to make the right next word a bit more likely, here is what the table looks like. All 92 words, three numbers each, plotted as points in space:&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/2026/10/tinylm-embeddings-3d.png"
alt="The three coordinates of all 92 words after training, one colour per kind. Labels are only drawn on things and verbs, for legibility. Nothing told the model which words belong together." width="100%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption style="margin: 0 auto; width:100%;"&gt;
&lt;h4&gt;The learned embeddings&lt;/h4&gt;&lt;p class="fig-attribution"&gt;The three coordinates of all 92 words after training, one colour per kind. Labels are only drawn on things and verbs, for legibility. Nothing told the model which words belong together.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The words sort themselves out by the job they do in a sentence. The nine thing-verbs (red) fly off to the top, far from everything and from each other, because a verb only ever follows a person phrase and never follows an object. The four person-verbs (orange) stay near the middle, next to the names and occupations (blues), which crowd around the centre since those show up in every slot. The 24 things flatten into a disc at the bottom, and inside that disc the drinks sit with the drinks, the tools with the tools, the paper with the paper. Even the article &amp;ldquo;the&amp;rdquo; ends up alone, because it is unlike any other thing in our world.&lt;/p&gt;
&lt;p&gt;This clustering is not just something the eye wants to see. Running k-means with six clusters over the 24 thing embeddings recovers the true classes &lt;strong&gt;70.8% of the time&lt;/strong&gt;, against 16.7% for chance. The drinks come back perfect, with &amp;ldquo;wine&amp;rdquo;, &amp;ldquo;water&amp;rdquo;, &amp;ldquo;soda&amp;rdquo; and &amp;ldquo;beer&amp;rdquo; in one cluster. Tools and clothes merge into one, which is fair enough, since both get cleaned and packed by the same verbs. The 3-dimensionality of the embeddings just happens to be small enough so that we can draw them in a simple 3D scatterplot. Moving to higher dimensions to improve accuracy destroys this nice property.&lt;/p&gt;
&lt;h2 id="training"&gt;Training&lt;/h2&gt;
&lt;p&gt;But how do we fill in \(\mathbf{W}\) and \(\mathbf{E}\)? The model is initially populated with random values. Training is the part that fills these, and it uses the whole world: all 2560 sentences, every prefix of each one, 11408 next-word questions of the form &amp;ldquo;john drank&amp;rdquo; -&amp;gt; water. There is no split and nothing held back. The question here is how much of the world we can fit into those 552 numbers. The model will never generalize to unseen text, as there is no unseen text at all! In a sense, the model is a compressor for our world.&lt;/p&gt;
&lt;p&gt;The loop does one thing at a time. Show the model a real prefix and the word that actually came next, compare its 92 percentages with the truth using cross-entropy, ask backpropagation which way to nudge each of the 552 numbers to make the loss smaller, nudge them a little with Adam, repeat. In code that is four lines, because the whole model is one multiplication, another multiplication and a softmax, so the effect of every number on the loss can be worked out exactly and all at once. Nothing is ever written down as a rule. &amp;ldquo;Drinks follow drank&amp;rdquo; is not in the code, it is only in the numbers the training ends up choosing.&lt;/p&gt;
&lt;p&gt;3000 turns of that take 18 seconds on one CPU core. The first hundred carry almost everything: the loss falls from 6.56, which is guessing, to 2.46 and 26% accuracy. Step 300 has it at 2.04 and 28.6%, and the remaining 2700 steps buy six tenths of a point, ending at 1.99 and 29.2%. From there on the model has taken what a bag of words has to give, and the nudges only reshuffle answers that were already near-tied.&lt;/p&gt;
&lt;p&gt;Where the accuracy sits is worth a look, because it points straight at the difficulty. The word &amp;ldquo;the&amp;rdquo; is predicted 100% of the time, and rightly so: it is the only word that ever follows a preposition, so when a place phrase starts there is nothing to get wrong. Verbs come out at 14.8% and the things at 9.1%, and those are exactly the slots where several words fit equally well. That is the ceiling again, seen from the other side: the model is not failing at those, the world is ambiguous there. Make it wider, give it 16 numbers per word and a hidden layer, and it climbs from these 29.2% to the ceiling itself, which is what the next section is about.&lt;/p&gt;
&lt;h2 id="results"&gt;Results&lt;/h2&gt;
&lt;p&gt;TinyLM is implemented in PyTorch. The whole experiment, logs and all, is at &lt;a href="https://codeberg.org/langurmonkey/tinylm"&gt;https://codeberg.org/langurmonkey/tinylm&lt;/a&gt;. First, we show how the model completes sentences. All these results are captured with the base 552-parameter model (smallest). First, we ask it to complete &amp;ldquo;john drank&amp;rdquo;, with a temperature of 1. We ask it to produce 4 samples.&lt;/p&gt;
&lt;details class="collapsedcode"&gt;
&lt;summary&gt;
&lt;span class="code"&gt;Result: &lt;/span&gt;&lt;span class="summary"&gt;&amp;#34;john drank&amp;#34;&lt;/span&gt;
&lt;/summary&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;➜ uv run python -m tinylm.predict &lt;span style="color:#a6be9d"&gt;&amp;#34;john drank&amp;#34;&lt;/span&gt; --topk &lt;span style="color:#a6be9d"&gt;6&lt;/span&gt; --sample &lt;span style="color:#a6be9d"&gt;4&lt;/span&gt; --temperature 1.0 --length &lt;span style="color:#a6be9d"&gt;4&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;checkpoint: /home/tsagrista/Projects/tinylm/model/tinygpt.pt &lt;span style="color:#a6be9d"&gt;552&lt;/span&gt; params &lt;span style="color:#a6be9d"&gt;3&lt;/span&gt; numbers/word train acc 29.2% &lt;span style="color:#58a1dd"&gt;device&lt;/span&gt;&lt;span style="color:#ff636f"&gt;=&lt;/span&gt;cpu
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;context: john drank &lt;span style="color:#58a1dd"&gt;T&lt;/span&gt;&lt;span style="color:#ff636f"&gt;=&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;1&lt;/span&gt; entropy 2.04 bits
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; water 25.0%
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; beer 25.0%
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; wine 25.0%
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; soda 24.6%
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; hats 0.1%
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; coats 0.1%
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;sampling &lt;span style="color:#a6be9d"&gt;4&lt;/span&gt; continuations of &lt;span style="color:#a6be9d"&gt;4&lt;/span&gt; words at &lt;span style="color:#58a1dd"&gt;T&lt;/span&gt;&lt;span style="color:#ff636f"&gt;=&lt;/span&gt;1:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; 1. john drank beer inside the garden
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; 2. john drank soda inside the park
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; 3. john drank water inside the garage
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; 4. john drank water under the station
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/details&gt;
&lt;p&gt;Next, we ask it to complete &amp;ldquo;the conductor&amp;rdquo;, and we increase the temperature to 4.&lt;/p&gt;
&lt;details class="collapsedcode"&gt;
&lt;summary&gt;
&lt;span class="code"&gt;Result: &lt;/span&gt;&lt;span class="summary"&gt;&amp;#34;the conductor&amp;#34;&lt;/span&gt;
&lt;/summary&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;➜ uv run python -m tinylm.predict &lt;span style="color:#a6be9d"&gt;&amp;#34;the conductor&amp;#34;&lt;/span&gt; --temperature &lt;span style="color:#a6be9d"&gt;2&lt;/span&gt; --sample &lt;span style="color:#a6be9d"&gt;5&lt;/span&gt; --length &lt;span style="color:#a6be9d"&gt;6&lt;/span&gt; --topk &lt;span style="color:#a6be9d"&gt;8&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;checkpoint: /home/tsagrista/Projects/tinylm/model/tinygpt.pt &lt;span style="color:#a6be9d"&gt;552&lt;/span&gt; params &lt;span style="color:#a6be9d"&gt;3&lt;/span&gt; numbers/word train acc 29.2% &lt;span style="color:#58a1dd"&gt;device&lt;/span&gt;&lt;span style="color:#ff636f"&gt;=&lt;/span&gt;cpu
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;context: the conductor &lt;span style="color:#58a1dd"&gt;T&lt;/span&gt;&lt;span style="color:#ff636f"&gt;=&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;2&lt;/span&gt; entropy 3.69 bits
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; invited 11.2%
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; helped 11.2%
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; met 11.2%
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; thanked 11.2%
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; repaired 7.2%
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; packed 7.2%
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; bought 7.2%
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; cleaned 7.2%
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;sampling &lt;span style="color:#a6be9d"&gt;5&lt;/span&gt; continuations of &lt;span style="color:#a6be9d"&gt;6&lt;/span&gt; words at &lt;span style="color:#58a1dd"&gt;T&lt;/span&gt;&lt;span style="color:#ff636f"&gt;=&lt;/span&gt;2:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; 1. the conductor packed soda near the tower conductor
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; 2. the conductor sipped soda beside the school garage
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; 3. the conductor helped sara under the factory factory
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; 4. the conductor bought books beside the hut office
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; 5. the conductor packed hats mary under the hut
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/details&gt;
&lt;p&gt;An interesting observation is that the model does not know when to stop (there are no stopping tokens in our world!), so it keeps producing tokens until it reaches the target &lt;code&gt;--length&lt;/code&gt;, even when the sentence is complete. That&amp;rsquo;s why we get things like &amp;ldquo;the conductor helped sara under the factory &lt;em&gt;factory&lt;/em&gt;&amp;rdquo;. This is fine, it is by design, a trade-off we have to live with.&lt;/p&gt;
&lt;p&gt;Next, we look at how the model performs when we grow the number of hidden units and the embedding dimensions. Every number in this section comes from a run in &lt;code&gt;logs/&lt;/code&gt;, all with the same learning rate (0.03), the same 3000 steps and seed 0.&lt;/p&gt;
&lt;p&gt;The baseline is the 552-number model from above: 29.2% next-word accuracy, against a ceiling of 48.4%. There are two knobs to turn, so let me turn them one at a time.&lt;/p&gt;
&lt;h3 id="more-hidden-units"&gt;More hidden units&lt;/h3&gt;
&lt;p&gt;First, fix the embeddings at 3 numbers per word and grow the hidden layer from nothing to 128:&lt;/p&gt;
&lt;div class="sidebyside"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hidden units&lt;/th&gt;
&lt;th&gt;Parameters&lt;/th&gt;
&lt;th&gt;Loss&lt;/th&gt;
&lt;th&gt;Accuracy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;552&lt;/td&gt;
&lt;td&gt;1.991&lt;/td&gt;
&lt;td&gt;29.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;560&lt;/td&gt;
&lt;td&gt;1.993&lt;/td&gt;
&lt;td&gt;29.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;752&lt;/td&gt;
&lt;td&gt;1.979&lt;/td&gt;
&lt;td&gt;29.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1136&lt;/td&gt;
&lt;td&gt;1.968&lt;/td&gt;
&lt;td&gt;29.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;1904&lt;/td&gt;
&lt;td&gt;1.948&lt;/td&gt;
&lt;td&gt;30.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;3440&lt;/td&gt;
&lt;td&gt;1.928&lt;/td&gt;
&lt;td&gt;31.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;6512&lt;/td&gt;
&lt;td&gt;1.878&lt;/td&gt;
&lt;td&gt;32.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;12656&lt;/td&gt;
&lt;td&gt;1.826&lt;/td&gt;
&lt;td&gt;34.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;figure&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/2026/10/tinylm-accuracy-hidden.svg"
alt="Accuracy against hidden layer size, with 3 numbers per word: flat at about 29% up to 16 hidden units, then rising to 34.9% at 128. The loss on the right axis falls from 1.99 to 1.83 over the same range. A dashed line marks the 48.4% ceiling." width="100%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;
&lt;/figure&gt;
&lt;/div&gt;
&lt;p&gt;The first thing to notice is how flat the left half is. Going from 0 to 2 hidden units does nothing at all, in fact it loses a tenth of a point, and 4 and 8 buy so little that they are inside the noise. The hidden layer only starts to pay somewhere around 16, and even then the whole sweep, a model 23 times bigger, gets from 29.2% to 34.9%.&lt;/p&gt;
&lt;p&gt;That is not the hidden layer&amp;rsquo;s fault but what (little) it has to work with. There is only a point of three numbers going in, so the yes/no questions the hidden units can ask are questions about three numbers. Everything the model knows about a word is already squeezed into those three numbers before the layer gets a chance to help.&lt;/p&gt;
&lt;!-- The breakdown by kind of word says the same thing. Verbs stay at 14.8% at every single row of that table, because more capacity does not create information that the world never had. What does move is the content words, 9.1% to 16.2%, and positions, 21.2% to 42.3%, since those are the slots with many equally good answers where a slightly sharper decision boundary is worth something. --&gt;
&lt;h3 id="higher-embedding-dimensionality"&gt;Higher embedding dimensionality&lt;/h3&gt;
&lt;p&gt;Now fix the hidden layer at 128 and grow the embeddings instead:&lt;/p&gt;
&lt;div class="sidebyside"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Numbers per word&lt;/th&gt;
&lt;th&gt;Parameters&lt;/th&gt;
&lt;th&gt;Loss&lt;/th&gt;
&lt;th&gt;Accuracy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;12656&lt;/td&gt;
&lt;td&gt;1.826&lt;/td&gt;
&lt;td&gt;34.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;12876&lt;/td&gt;
&lt;td&gt;1.711&lt;/td&gt;
&lt;td&gt;38.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;13756&lt;/td&gt;
&lt;td&gt;1.476&lt;/td&gt;
&lt;td&gt;47.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;15516&lt;/td&gt;
&lt;td&gt;1.425&lt;/td&gt;
&lt;td&gt;48.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;19036&lt;/td&gt;
&lt;td&gt;1.424&lt;/td&gt;
&lt;td&gt;48.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;26076&lt;/td&gt;
&lt;td&gt;1.424&lt;/td&gt;
&lt;td&gt;48.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;92&lt;/td&gt;
&lt;td&gt;32236&lt;/td&gt;
&lt;td&gt;1.533&lt;/td&gt;
&lt;td&gt;44.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;figure&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/2026/10/tinylm-accuracy-dim.svg"
alt="Accuracy against numbers per word, with 128 hidden units: 34.9% at 3 coordinates, rising steeply to 47.4% at 8 and 48.4% from 16 to 64, then falling back to 44.5% at 92. The loss mirrors it, flat at 1.42 across the plateau. A dashed line marks the 48.4% ceiling." width="100%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;
&lt;/figure&gt;
&lt;/div&gt;
&lt;p&gt;This is a different picture. Four extra coordinates, 220 more numbers, are worth four points, and at 8 numbers per word the model is at 47.4%, basically at the ceiling. At 16 it sits exactly on it, 48.4%, and then nothing happens: 32 and 64 coordinates give the identical number, with the same loss to four decimals. All the extra numbers do is cost time. The model has run out of things to learn, because 48.4% is a property of the world.&lt;/p&gt;
&lt;p&gt;Two rows caught my eye: embedding dimensions 16 and 92. At 16 coordinates the positions go to 100%: every preposition that starts a place phrase is now predicted perfectly, whereas at 3 coordinates it was 42.3%. And the last row, 92 coordinates, which is one number per word with no sharing between words, falls back to 44.5%. With a coordinate per word each word is just itself and nothing has to be shared, so the model stops learning that &amp;ldquo;wine&amp;rdquo; and &amp;ldquo;beer&amp;rdquo; behave alike and learns 92 independent words instead. Fewer is genuinely better here, up to a point.&lt;/p&gt;
&lt;p&gt;So the answer to the opening question is 15516 numbers, of which 552 were enough for a quarter of the world and none of them buy anything past the entry at 16. The smallest thing that works is still the 552-number model. The smallest thing that hits the ceiling is the one with 16 coordinates per word.&lt;/p&gt;
&lt;h3 id="how-well-does-it-compress-the-world"&gt;How well does it compress the world?&lt;/h3&gt;
&lt;p&gt;Accuracy tells you how often the model is right, but the model &lt;em&gt;is&lt;/em&gt; a description of the world, so we can ask the compressor&amp;rsquo;s question: how many bits does it take to send the whole world using this model? That is the model itself plus the residual, the part the model cannot predict, which the loss measures directly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Model cost: parameters \(\times\) 32 bits (unquantized float32, an upper bound).&lt;/li&gt;
&lt;li&gt;Residual: \(N \times loss / ln(2)\) bits, with N = 11408 next-word questions.&lt;/li&gt;
&lt;li&gt;Raw world: \(N \times log_{2}(92)\) ≈ 74421 bits (9.1 KiB), i.e. naming one of 92 words per question with no model at all.&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Params&lt;/th&gt;
&lt;th&gt;Bits&lt;/th&gt;
&lt;th&gt;Residual&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;th&gt;× raw&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3 coords, no hidden&lt;/td&gt;
&lt;td&gt;552&lt;/td&gt;
&lt;td&gt;17664&lt;/td&gt;
&lt;td&gt;32762&lt;/td&gt;
&lt;td&gt;50426&lt;/td&gt;
&lt;td&gt;0.68&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 coords, hidden 128&lt;/td&gt;
&lt;td&gt;12656&lt;/td&gt;
&lt;td&gt;404992&lt;/td&gt;
&lt;td&gt;30051&lt;/td&gt;
&lt;td&gt;435043&lt;/td&gt;
&lt;td&gt;5.85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 coords, hidden 128&lt;/td&gt;
&lt;td&gt;13756&lt;/td&gt;
&lt;td&gt;440192&lt;/td&gt;
&lt;td&gt;24292&lt;/td&gt;
&lt;td&gt;464484&lt;/td&gt;
&lt;td&gt;6.24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 coords, hidden 128&lt;/td&gt;
&lt;td&gt;15516&lt;/td&gt;
&lt;td&gt;496512&lt;/td&gt;
&lt;td&gt;23451&lt;/td&gt;
&lt;td&gt;519963&lt;/td&gt;
&lt;td&gt;6.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;92 coords, hidden 128&lt;/td&gt;
&lt;td&gt;32236&lt;/td&gt;
&lt;td&gt;1031552&lt;/td&gt;
&lt;td&gt;25222&lt;/td&gt;
&lt;td&gt;1056774&lt;/td&gt;
&lt;td&gt;14.20&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Only the first row compresses: 0.68× raw. Every row past it spends far more bits on the model than it ever saves on the residual, which bottoms out around 23-24 kbit once the ceiling is reached. I kind of expected this: the smallest thing that &lt;em&gt;compresses&lt;/em&gt; is the 552-number baseline, while the smallest thing that &lt;em&gt;predicts at the ceiling&lt;/em&gt; is the 15516-number model. Past 16 coordinates even the residual grows again, so the 92-coordinate case is totally hopeless.&lt;/p&gt;
&lt;h2 id="conclusions"&gt;Conclusions&lt;/h2&gt;
&lt;p&gt;TinyLM works because the world is tiny and closed: 92 words, one sentence shape, 11408 questions with nothing held back. To get from here to something GPT-2-like, which predicts arbitrary text, roughly four things have to change, all of them about giving up that closedness.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The first is obvious: order. The bag of counters has to go, replaced by a sequence with positions and a mechanism that looks at who came before whom: causal self-attention, stacked into a transformer. That alone removes the ceiling.&lt;/li&gt;
&lt;li&gt;The second is also rather self-evident: vocabulary. 92 whole words become ~50k subword pieces (BPE), so the model can spell anything, at the cost of a readout 500 times wider.&lt;/li&gt;
&lt;li&gt;The third is data. Training on everything there is has to become training on gigabytes with text held back, so the score measures generalization instead of memorization.&lt;/li&gt;
&lt;li&gt;Finally, we have scale. One hidden layer becomes 12 layers of 768 numbers (GPT-2 small, 124M parameters), and seconds on a CPU core become hours on a GPU.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Nothing else changes. The objective is still &amp;quot;guess the next word&amp;quot;, the loss still means nats, word embeddings are still there, and the compressor&amp;rsquo;s accounting from the previous section still applies. What changes is only what the model is asked to compress.&lt;/p&gt;</description></item><item><title>Volumetric galaxy rendering</title><link>https://tonisagrista.com/blog/2026/galaxy-rendering/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate><author>Toni Sagrista Selles</author><guid>https://tonisagrista.com/blog/2026/galaxy-rendering/</guid><description>&lt;p&gt;The Milky Way is an essential component in any 3D Universe software. Rendering a realistic (i.e. science-based) and visually appealing galaxy is paramount to the user experience, and to the cohesion of the simulated universe as a whole. Galaxies are made of gas and dust clouds, so they are volumetric in nature. In Gaia Sky we have been using particle-based representations for a long time, even though we have had the volumetric rendering infrastructure in place also for a while. This post describes the rendering technique we use in Gaia Sky 3.9.0 to render the volumetric Milky Way, and how we have constructed the dataset that enables it. Interested? Keep reading.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;a href="https://tonisagrista.com/img/2026/10/galaxy/gal1300-close.jpg"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/gal1300-close-crop.avif" width="100%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;/a&gt;&lt;figcaption style="margin: 0 auto; width:100%;"&gt;
&lt;h4&gt;The Milky Way galaxy in Gaia Sky&lt;/h4&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id="a-little-bit-of-history"&gt;A little bit of history&lt;/h2&gt;
&lt;p&gt;The rendering of our and other galaxies has always been a topic I dreaded. I never felt that the solutions we found were good enough. I always knew that the right way to do it was with volume rendering, but the main issue was that we did not have the volumetric data.&lt;/p&gt;
&lt;p&gt;So, for a long time, we have used approximations to render the Milky Way, some more gracious than others.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Enhanced flat image&lt;/strong&gt; &amp;mdash; Initially, we used a single two-faced billboard with the artist&amp;rsquo;s impression by Nick Risinger, coupled with a generated star field. This was featured in the September 26, 2016 Astronomy Picture of the Day, titled &amp;ldquo;Here comes the Sun&amp;rdquo;&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;. This is a very primitive galaxy, but it looks awesome from above and in still pictures due to Risinger&amp;rsquo;s image.
&lt;figure class="fig-center"&gt;
&lt;div class="imgsw"&gt;
&lt;div class="imgsw-frame"&gt;
&lt;a class="imgsw-item is-active" data-imgsw-index="0"&gt;
&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/gal01-top.jpg" alt="The very first Milky Way in Gaia Sky. (Top)"
loading="lazy" decoding="async" /&gt;
&lt;/a&gt;
&lt;a class="imgsw-item" data-imgsw-index="1" aria-hidden="true"&gt;
&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/gal01-side.jpg" alt="The very first Milky Way in Gaia Sky. (Side)"
loading="lazy" decoding="async" /&gt;
&lt;/a&gt;
&lt;a class="imgsw-item" data-imgsw-index="2" aria-hidden="true"&gt;
&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/gal01-wide.jpg" alt="The very first Milky Way in Gaia Sky. (Wide)"
loading="lazy" decoding="async" /&gt;
&lt;/a&gt;
&lt;/div&gt;
&lt;div class="imgsw-tabs" role="group" aria-label="Image comparison"&gt;
&lt;button type="button" class="imgsw-btn is-active" data-imgsw-index="0"
aria-pressed="true"&gt;Top&lt;/button&gt;
&lt;button type="button" class="imgsw-btn" data-imgsw-index="1"
aria-pressed="false"&gt;Side&lt;/button&gt;
&lt;button type="button" class="imgsw-btn" data-imgsw-index="2"
aria-pressed="false"&gt;Wide&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Textured billboard approach&lt;/strong&gt; &amp;mdash; After Gaia DR3 landed in 2022, the catalog data were used by Stefan Payne-Wardenaar&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt; to produce a high-quality representation of the whole Galaxy&lt;sup id="fnref:3"&gt;&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref"&gt;3&lt;/a&gt;&lt;/sup&gt; in Blender. At that time, I worked with Stefan to produce a particle-based version that would fit in the existing billboard systems in Gaia Sky. The first version was based on textured additive billboards and dithered depth buffer occlusion. The depth was correct, but the look of the galaxy was rather cartoonish and unrealistic. Additive blending was used for all channels except the dust, which was purely occlusive in a binary sense (no sorting, so no transparency), making use of dithering to simulate translucency. This version is the one that looks the worst of all. Using textured billboards is a bad move to simulate a volume of gas and dust. Also, the brightness blows out when seen edge-on.
&lt;figure class="fig-center"&gt;
&lt;div class="imgsw"&gt;
&lt;div class="imgsw-frame"&gt;
&lt;a class="imgsw-item is-active" data-imgsw-index="0"&gt;
&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/gal02-top.jpg" alt="Textured billboard Milky Way in Gaia Sky. (Top)"
loading="lazy" decoding="async" /&gt;
&lt;/a&gt;
&lt;a class="imgsw-item" data-imgsw-index="1" aria-hidden="true"&gt;
&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/gal02-side.jpg" alt="Textured billboard Milky Way in Gaia Sky. (Side)"
loading="lazy" decoding="async" /&gt;
&lt;/a&gt;
&lt;a class="imgsw-item" data-imgsw-index="2" aria-hidden="true"&gt;
&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/gal02-wide.jpg" alt="Textured billboard Milky Way in Gaia Sky. (Wide)"
loading="lazy" decoding="async" /&gt;
&lt;/a&gt;
&lt;/div&gt;
&lt;div class="imgsw-tabs" role="group" aria-label="Image comparison"&gt;
&lt;button type="button" class="imgsw-btn is-active" data-imgsw-index="0"
aria-pressed="true"&gt;Top&lt;/button&gt;
&lt;button type="button" class="imgsw-btn" data-imgsw-index="1"
aria-pressed="false"&gt;Side&lt;/button&gt;
&lt;button type="button" class="imgsw-btn" data-imgsw-index="2"
aria-pressed="false"&gt;Wide&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Billboards with additive and subtractive blending&lt;/strong&gt; &amp;mdash; Later on, inspired by &lt;a href="https://dexyfex.com/2016/09/09/galaxy-rendering-revisited/"&gt;this article&lt;/a&gt;, the textured billboards were updated to rely on blobs and additive/subtractive blending. The main issue was that the emissive parts blended additively in a very unrealistic way, and the occlusion did not respect the depth order in the scene. Several attempts were made afterwards to implement Weighted Blended Order-Independent Transparency (WBOIT)&lt;sup id="fnref:4"&gt;&lt;a href="#fn:4" class="footnote-ref" role="doc-noteref"&gt;4&lt;/a&gt;&lt;/sup&gt;, as suggested by the article, but this was very difficult to tune and produced underwhelming results. Looks better than the previous textured-billboards version, but the behavior of light is unrealistic. The brightness is mostly gone when seen from the top, but blows out edge-on due to the additive blending.
&lt;figure class="fig-center"&gt;
&lt;div class="imgsw"&gt;
&lt;div class="imgsw-frame"&gt;
&lt;a class="imgsw-item is-active" data-imgsw-index="0"&gt;
&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/gal03-top.jpg" alt="Additive/subtractive billboards Milky Way in Gaia Sky. (Top)"
loading="lazy" decoding="async" /&gt;
&lt;/a&gt;
&lt;a class="imgsw-item" data-imgsw-index="1" aria-hidden="true"&gt;
&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/gal03-side.jpg" alt="Additive/subtractive billboards Milky Way in Gaia Sky. (Side)"
loading="lazy" decoding="async" /&gt;
&lt;/a&gt;
&lt;a class="imgsw-item" data-imgsw-index="2" aria-hidden="true"&gt;
&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/gal03-wide.jpg" alt="Additive/subtractive billboards Milky Way in Gaia Sky. (Wide)"
loading="lazy" decoding="async" /&gt;
&lt;/a&gt;
&lt;/div&gt;
&lt;div class="imgsw-tabs" role="group" aria-label="Image comparison"&gt;
&lt;button type="button" class="imgsw-btn is-active" data-imgsw-index="0"
aria-pressed="true"&gt;Top&lt;/button&gt;
&lt;button type="button" class="imgsw-btn" data-imgsw-index="1"
aria-pressed="false"&gt;Side&lt;/button&gt;
&lt;button type="button" class="imgsw-btn" data-imgsw-index="2"
aria-pressed="false"&gt;Wide&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All these issues finally tipped me into seriously consider approaching the project of converting the particle data to a volume and implementing a proper volume renderer with emission and occlusion.&lt;/p&gt;
&lt;h2 id="particles-to-volume"&gt;Particles to volume&lt;/h2&gt;
&lt;p&gt;The first step is to convert the particle data to a volume in the form of a voxel grid. This step is crucial, as it can make or break the whole project. Having a good dataset is the difference between a bland-looking visual and an awesome one.&lt;/p&gt;
&lt;h3 id="the-source-data"&gt;The source data&lt;/h3&gt;
&lt;p&gt;The source is the same particle model we were already rendering. It is split into channels, each a gzipped text file with one particle per line:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;x y z size r g b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;where the position is in galactic coordinates, &lt;code&gt;size&lt;/code&gt; is the particle&amp;rsquo;s weight and &lt;code&gt;r g b&lt;/code&gt; is the emission coefficient of the particle, in arbitrary units. The Milky Way is made of three of them: &lt;strong&gt;Gas&lt;/strong&gt; (~15,000 particles), &lt;strong&gt;Bulge&lt;/strong&gt; (~900) and &lt;strong&gt;ArmDust&lt;/strong&gt; (~93,000). The stars and the HII regions of the model are not part of the volume: they are genuinely point-like, so they keep their billboards and a voxel grid would only blur them.&lt;/p&gt;
&lt;p&gt;Reading a few hundred thousand lines and painting them into a grid is not hard. What is hard is doing it in a way that does not depend on the resolution of the grid, so that a \(200^{3}\) volume and an \(800^{3}\) volume describe &lt;em&gt;the same galaxy&lt;/em&gt; and differ only in how much detail they can hold. Most of the work below exists to enforce that.&lt;/p&gt;
&lt;h3 id="splatting"&gt;Splatting&lt;/h3&gt;
&lt;p&gt;Every particle is a point with no extent, so the first operation is a scatter into the grid: each particle adds its weight to the voxel it falls in. That is the simplest possible splat, and it is what the shipped volumes use. It is also the one that keeps the property above, because a nearest-voxel scatter is the identity of the sampling: the same data always lands in the voxel the same ray will visit.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/galaxy-rendering-splatting.svg"
alt="Each particle adds its weight to the voxel it falls in, so a voxel ends up with the summed weight ρ and the weight-averaged color c; here its opacity is drawn proportional to ρ. The magnification shows the arithmetic for a voxel that receives two particles." width="100%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption style="margin: 0 auto; width:100%;"&gt;
&lt;h4&gt;Splatting&lt;/h4&gt;&lt;p class="fig-attribution"&gt;Each particle adds its weight to the voxel it falls in, so a voxel ends up with the summed weight ρ and the weight-averaged color c; here its opacity is drawn proportional to ρ. The magnification shows the arithmetic for a voxel that receives two particles.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;There are two details here worth taking into account:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The color accumulates with the same weight as the density&lt;/strong&gt;, so dividing the accumulated color by the accumulated density yields a size-weighted mean color per voxel. Getting the weights inconsistent here is the classic way to end up with a volume whose large particles dominate the hue.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The grid is &lt;em&gt;galactic&lt;/em&gt; but the file is not.&lt;/strong&gt; Voxels are dumped in C order, like this:&lt;/p&gt;
&lt;p&gt;$$
(W, H, D) = gal(X, Z, -Y)
$$&lt;/p&gt;
&lt;p&gt;This is because of the axis order the shader indexes a 3D texture with. The permutation is written into the metadata, in both orders, precisely so nobody has to infer it.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="smoothing-without-inventing-detail"&gt;Smoothing without inventing detail&lt;/h3&gt;
&lt;p&gt;A raw scatter of 15,000 points into a \(400^{3}\) grid is a swarm of dimensionless points, but we need a volume, so every channel is blurred with a Gaussian. The tempting choice, a fixed sigma in voxels, is exactly the one that breaks resolution independence: it smooths &lt;em&gt;more&lt;/em&gt; of the galaxy at 200 than at 800, and the same data produces two different objects.&lt;/p&gt;
&lt;p&gt;Instead, the blur width is a length on the particles, in world units of the box:&lt;/p&gt;
&lt;p&gt;$$
\sigma = 0.85 \times \ell
$$&lt;/p&gt;
&lt;p&gt;where \(\ell\) is the median distance from a particle to its 4th nearest neighbor &amp;mdash; the local inter-particle spacing, not the volume divided by the count. Note that this measure is the same for all particles in a channel, as \(\ell\) is derived from the median. We do not compute a different smoothing for each particle. That value measures the gaps that actually need filling, and it is nearly independent of resolution by construction, since it comes from the particle positions alone. A channel that is denser gets a narrower kernel for free, and the same particles written at 200, 400 and 800 get 4×, 2× and 1× that sigma &lt;em&gt;in voxels&lt;/em&gt;, which is the correct behavior.&lt;/p&gt;
&lt;p&gt;The 0.85 is empirical. A blur width given by hand in world units, per channel, never generalised; this little proxy held up across channels and resolutions.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/galaxy-rendering-smoothing.svg"
alt="The same Gaussian kernel drawn over two particle fields on grids of different resolution: the blur is a fixed length in the world, so it covers twice as many voxels on the finer grid." width="100%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption style="margin: 0 auto; width:100%;"&gt;
&lt;h4&gt;Smoothing&lt;/h4&gt;&lt;p class="fig-attribution"&gt;The kernel width is a length measured on the particles, σ = 0.85 ℓ with ℓ the median distance to the 4th neighbor, so the same blur is described by twice as many voxels on a grid of twice the resolution. The result is one continuous density field; a fixed sigma in voxels would instead give a \(200^{3}\) volume and an \(800^{3}\) volume two different galaxies.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Two guards keep this sensible:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A cap at twice the channel&amp;rsquo;s own half-mass thickness&lt;/strong&gt;, along galactic Z. Without it, a sparse channel (a few hundred particles whose &amp;ldquo;local spacing&amp;rdquo; is an artifact of sparsity) would be blurred into a slab.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A smoothing scale per channel.&lt;/strong&gt; Dust uses 0.4×, because a dust lane that is smoothed like the gas stops being a lane and becomes a haze.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The color is blurred with the &lt;em&gt;density&lt;/em&gt; kernel, over the &lt;em&gt;unblurred&lt;/em&gt; density, so the color spreads exactly as far as the density is about to. Blurring the color separately is how you get colored halos around black voxels.&lt;/p&gt;
&lt;h3 id="color-needs-normalizing"&gt;Color needs normalizing&lt;/h3&gt;
&lt;p&gt;The particle colors are emission coefficients, not display colors. Gas averages about 0.02, so clipping to &lt;code&gt;[0,1]&lt;/code&gt; and multiplying by 255 rounds roughly 82% of the occupied voxels to zero and leaves the whole channel carried by about thirty distinct levels. Quantizing as-is gives a black galaxy.&lt;/p&gt;
&lt;p&gt;The fix is a &lt;strong&gt;single scalar gain&lt;/strong&gt;, derived from the 99.9th percentile of the &lt;em&gt;occupied&lt;/em&gt; voxels and applied to all three channels identically. One scalar is the important part: the gain preserves the chromaticity of every voxel, so the hue is still the data&amp;rsquo;s. Per-channel normalization would give each channel the full 0–255 range and wash everything toward gray, which defeats the point of storing a per-voxel color at all. The gain is baked into the bytes, and the shader consumes the stored color as-is.&lt;/p&gt;
&lt;p&gt;Density gets the same treatment, by its peak, and is stored in the alpha channel.&lt;/p&gt;
&lt;h3 id="packing-and-merging"&gt;Packing and merging&lt;/h3&gt;
&lt;p&gt;Each channel is written as a raw heap of bytes, gzipped, next to a small text sidecar that declares the grid and the box:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Volume: emissive
Grid size: 399x40x400
Galactic grid (x, y, z): 399x400x40
Volume bounds: [-0.473301, -0.048570, -0.475536] to [0.477213, 0.046252, 0.476845]
Voxel size: 0.00250000 (cubic; the largest box side is 1, in units of the object&amp;#39;s size)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The byte count per voxel selects the format (1 byte per voxel is R8 density, 3 is &lt;code&gt;RGB8&lt;/code&gt; color, 4 is &lt;code&gt;RGBA8&lt;/code&gt; color+density) and the loader takes it from the file length, not from the file name. The RGB channels carry the chromaticity, the alpha channel the brightness, and the emitted radiance is the product &lt;code&gt;RGB × A&lt;/code&gt;. An occluding volume uses the same layout, with alpha as the normalized density.&lt;/p&gt;
&lt;p&gt;The per-channel textures are then merged into two final volumes: one &lt;strong&gt;emissive&lt;/strong&gt; (Gas at 0.7 + Bulge at 0.3) and one &lt;strong&gt;occlusive&lt;/strong&gt; (ArmDust at 0.08). Merging is a post-process on the textures, so it needs neither the particle data nor a re-conversion, but it does have to be careful:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Densities add. The color is the density-weighted mean over the inputs that carry one, and each input&amp;rsquo;s baked gain is undone first, so the merge happens in emission space. Otherwise a channel normalized to a different peak would bias the hue of the result.&lt;/li&gt;
&lt;li&gt;The merge &lt;strong&gt;verifies alignment&lt;/strong&gt; (same grid, same box to 1e-3, metadata grid consistent with the file length) rather than assuming it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Renormalizing the sum after the merge dims everything&lt;/strong&gt;: at Gas+Bulge 1/1 the peak lands at 1.36 and the texture is scaled by 0.73. Whether to do it is a decision, not a detail.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="making-the-box-fit-the-galaxy"&gt;Making the box fit the galaxy&lt;/h3&gt;
&lt;p&gt;A galaxy disk is 6.7 units wide and 0.67 thick, so a &lt;strong&gt;cubic&lt;/strong&gt; box around it is nine tenths empty space: \(400^{3}\) of &lt;code&gt;RGBA&lt;/code&gt; is 244 MiB, where the same voxel size laid out as \(400 \times 400 \times 40\) is 24 MiB. A factor of ten, spent on nothing.&lt;/p&gt;
&lt;p&gt;The fix is to flatten the grid and the box &lt;strong&gt;together&lt;/strong&gt;, deriving the grid from the data-shaped box: the detail of an \(N^{3}\) grid without the empty axes. Flattening only the grid would make the voxels anisotropic; flattening both keeps them cubic.&lt;/p&gt;
&lt;p&gt;Nothing else has to change. It is still one texture and one step count, because the ray-march step \(rayLength \times |rayDir / extent|\) is invariant under a rescale of the box: flattening it does not change how many samples a ray takes.&lt;/p&gt;
&lt;h2 id="the-volume-renderer"&gt;The volume renderer&lt;/h2&gt;
&lt;p&gt;Here we go over the galaxy shader, which is in &lt;code&gt;assets/shader/volume.galaxy.fragment.glsl&lt;/code&gt;. It is a single-pass ray marcher, but it is drawn &lt;strong&gt;twice&lt;/strong&gt;: once early as a depth-only pre-pass, and once late as the emission pass.&lt;/p&gt;
&lt;p&gt;But first, we need a piece of geometry that emits the pixels to the fragment shader were we do the work. Here, anything works, as long as the pixels reach the fragment. Since we need to render the galaxy also from the inside, we use a flipped cube at the position of the model. The transformations for the actual galaxy are done in the fragment shader, so rotating or scaling this box does nothing. It is essentially a vessel to emit pixels.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/galaxy-rendering-fragments.svg"
alt="The flipped box: rays from the camera pass through the pixels of a screen, cross the culled front faces of the box without producing a fragment, and each end on the far wall, so every pixel receives exactly one fragment. In the second panel the camera is inside the box and every pixel still receives one." width="100%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption style="margin: 0 auto; width:100%;"&gt;
&lt;h4&gt;The box&lt;/h4&gt;&lt;p class="fig-attribution"&gt;The box is drawn with its front faces culled, so it never shows up as a surface: the ray crosses the near wall and every pixel it covers receives exactly one fragment on the far wall. The shader does not use that fragment as a surface, it only takes its world position as a direction from the camera. Since the near wall is never the one drawn, the camera can also sit inside the box and the screen is still full.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h3 id="wait-two-passes"&gt;Wait, two passes?&lt;/h3&gt;
&lt;p&gt;We said we do an early depth pass and the emission pass at the end. Why is that? Well, the volume is rendered with additive blending so its own fragments cannot write a meaningful depth. Adding two semi-transparent layers is commutative; the depth buffer, which keeps a single number per pixel, is not. And the volume cannot be sorted per fragment, so any attempt to give it &amp;ldquo;the&amp;rdquo; depth in the color pass is wrong by construction.&lt;/p&gt;
&lt;p&gt;But the scene needs it. The stars of the catalog, and the whole rest of the universe, are drawn &lt;em&gt;after&lt;/em&gt; the Milky Way volume, and if nothing writes depth they all shine through the dust lanes. A galaxy whose dust hides nothing looks like a galaxy with a bright smudge on it.&lt;/p&gt;
&lt;p&gt;So the depth is produced as a &lt;strong&gt;separate mask&lt;/strong&gt;, in its own pass, before anything else is drawn:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pass&lt;/th&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Color mask&lt;/th&gt;
&lt;th&gt;Depth test&lt;/th&gt;
&lt;th&gt;Depth write&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MODEL_VERT_GRID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;clears the buffer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VOLUME_DEPTH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;off&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gl_FragDepth&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POINT_STAR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;off&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VOLUME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2110&lt;/td&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;off&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The pre-pass marches each ray only until it finds the first sample that is dense enough to occlude, writes that distance, and returns. The emission pass marches the &lt;em&gt;same&lt;/em&gt; ray from the same origin and finds the same front, so both agree on where the dust starts. Stars are switched from &amp;ldquo;no depth test&amp;rdquo; to &amp;ldquo;depth test, no writes&amp;rdquo;: they are hidden by the dust but cannot overwrite its depth.&lt;/p&gt;
&lt;h3 id="at-a-lower-resolution"&gt;At a lower resolution&lt;/h3&gt;
&lt;p&gt;The volume is not the only thing that is allowed to render small. The volumes of the other galaxies (gas, bulge and dust) go into the same reduced buffer, and so do the nebulae, which are evaluated analytically in their fragment shaders instead of being sampled from a texture. All of them share the property that makes this safe: their content has no detail at the scale of a pixel, so the upscale costs almost nothing visible, while the fill rate they give back is the expensive part of the frame.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;depth&lt;/strong&gt; is never reduced. Every one of those elements still writes depth at full resolution, in the pre-pass of the table above, and before the scene draws at the reduced scale that depth buffer is blitted onto the reduced one.&lt;/p&gt;
&lt;p&gt;That copy is what keeps the reduced pass honest. Everything depth-tested in it, the stars above all, reads the reduced depth buffer, which the full-resolution pre-pass never wrote. Bringing the full-resolution depth down first means those tests see the dust front exactly where the full-resolution pass put it: without it the stars would shine straight through the lanes, and lowering the render scale would change the image instead of only its sharpness.&lt;/p&gt;
&lt;h3 id="what-occludes-means"&gt;What &amp;ldquo;occludes&amp;rdquo; means&lt;/h3&gt;
&lt;p&gt;Not every volume should occlude. The gas is emissive, and its faint outermost haze is not opaque: letting it write depth would cut a hard, sharp-edged hole in the star field, perfectly matching the box. The dust, on the other hand, should hide what is behind its lanes.&lt;/p&gt;
&lt;p&gt;So each volume slot carries its own &lt;strong&gt;density threshold&lt;/strong&gt;, and only samples above it count as occluding. We define, for every channel, the threshold density above which occlusion happens:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;volumeDepthThreshold&amp;#34;&lt;/span&gt;: [&lt;span style="color:#a6be9d"&gt;0.0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0.015&lt;/span&gt;]
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;0.0&lt;/code&gt; disables occlusion for that slot: the gas renders, and hides nothing. The value for the dust is not arbitrary, it comes from the data. In the dust volume, the low tail of the density histogram is the blur we applied deliberately, not matter, so the threshold is set to keep the lanes and drop the tail. The constant is per texture and has to be re-tuned if the smoothing or the normalization changes.&lt;/p&gt;
&lt;h3 id="the-march"&gt;The march&lt;/h3&gt;
&lt;p&gt;Both volumes are marched in &lt;strong&gt;one loop&lt;/strong&gt;, at the same position along the ray. That is what makes the ordering correct: a dust lane and a dust glow are the same voxels, and only the order of emission and absorption within the step decides which one you get.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-glsl" data-lang="glsl"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;vec3&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;rayOrigin&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;transpose&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;R&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;u_cameraPos&lt;/span&gt;; &lt;span style="color:#828b96;font-style:italic"&gt;// camera, in the box frame&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;vec3&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;rayDir&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;normalize&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;transpose&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;R&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;v_data&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;fragPosWorld&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;vec2&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;span&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;boxIntersection&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;rayOrigin&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;rayDir&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;boundsMin&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;boundsMax&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;float&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;stepUVW&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; ...; &lt;span style="color:#828b96;font-style:italic"&gt;// TARGET_STEP_UVW of the box&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;int&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;nSteps&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;clamp&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;int&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;ceil&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;pathUVW&lt;/span&gt; &lt;span style="color:#ff636f"&gt;/&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;stepUVW&lt;/span&gt;)), &lt;span style="color:#a6be9d"&gt;16&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;u_iterations&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;float&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;jitter&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;hash12&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;gl_FragCoord&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;xy&lt;/span&gt; &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;u_time&lt;/span&gt; &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0.1&lt;/span&gt;); &lt;span style="color:#828b96;font-style:italic"&gt;// hides the banding&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;for&lt;/span&gt; (&lt;span style="color:#ff636f"&gt;int&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;i&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;; &lt;span style="color:#58a1dd"&gt;i&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;lt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;nSteps&lt;/span&gt;; &lt;span style="color:#58a1dd"&gt;i&lt;/span&gt;&lt;span style="color:#ff636f"&gt;++&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;float&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;t&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;entry&lt;/span&gt; &lt;span style="color:#ff636f"&gt;+&lt;/span&gt; (&lt;span style="color:#ff636f"&gt;float&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;i&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;+&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;jitter&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;stepSize&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;vec3&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;uvw&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;worldToVolumeUVW&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;rayOrigin&lt;/span&gt; &lt;span style="color:#ff636f"&gt;+&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;rayDir&lt;/span&gt; &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;t&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;boundsMin&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;boundsMax&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Jump whole bricks that hold no density in either volume.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;float&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;skip&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;min&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;emptyBrickSteps&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;uvwGas&lt;/span&gt;, ...), &lt;span style="color:#58a1dd"&gt;emptyBrickSteps&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;uvwDust&lt;/span&gt;, ...));
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;skip&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;1.0&lt;/span&gt;) { &lt;span style="color:#58a1dd"&gt;i&lt;/span&gt; &lt;span style="color:#ff636f"&gt;+=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;int&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;skip&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;-&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;; &lt;span style="color:#ff636f"&gt;continue&lt;/span&gt;; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;vec4&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;gas&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;sampleVolumeRGBA&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;uvwGas&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;u_volume0&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;u_volume0ColorMode&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;vec4&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;dust&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;sampleVolumeRGBA&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;uvwDust&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;u_volume1&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;u_volume1ColorMode&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Emission first: light emitted in this step lies in front of this step&amp;#39;s dust.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;emitted&lt;/span&gt; &lt;span style="color:#ff636f"&gt;+=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;volumeColor&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;GAS_RAMP&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;gas&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;a&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;gas&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rgb&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;mode&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;gas&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;a&lt;/span&gt; &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;stepUVW&lt;/span&gt; &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;transmittance&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Then absorption, Beer-Lambert, only for what is behind it.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;transmittance&lt;/span&gt; &lt;span style="color:#ff636f"&gt;*=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;exp&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;-&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;DUST_SIGMA&lt;/span&gt; &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;dust&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;a&lt;/span&gt; &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;stepUVWDust&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;transmittance&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;lt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0.001&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;break&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A few things in there are worth pointing out:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Empty brick skipping&lt;/strong&gt; is the single biggest performance win. On load, the voxel grid is reduced to an occupancy grid with one texel per 4×4×4 brick, holding the &lt;em&gt;maximum&lt;/em&gt; density of the brick. A zero texel then means every fetch inside that brick would have returned zero: no emission, no extinction, so skipping the whole brick cannot change the image. The shader does one nearest fetch instead of the dozens the brick would have cost, and advances the sample index rather than the step size, so the samples it does take sit on exactly the lattice it would have used anyway. On a galaxy volume, most of the march is empty space.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The jitter&lt;/strong&gt; decorrelates neighboring pixels so a step size in UVW does not show as concentric banding.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The transfer function&lt;/strong&gt; is \(density \times color \times step\), integrated front to back, with an exposure factor applied at the end. Density is peak-normalized and sparse, so the ramp parameter is \(log_2(1 + 255\cdot d)\). Without the log the whole volume lands in the first ramp stop and the ramp does nothing.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="known-limits"&gt;Known limits&lt;/h3&gt;
&lt;p&gt;Two, both of them consequences of doing the depth in a cheap pre-pass:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The depth is written at the &lt;strong&gt;first&lt;/strong&gt; sample above the threshold, not where the ray actually becomes optically thick. A thin lane therefore occludes as hard as a thick one.&lt;/li&gt;
&lt;li&gt;The distance is along the ray, while the log depth buffer wants view-space depth. They differ by \(cos \Theta\) away from the view axis, which is exact at the center of the screen and a few percent at the corners.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Neither is visible in practice, and both are one comparison per sample away from being fixed properly.&lt;/p&gt;
&lt;h2 id="infrastructure-in-gaia-sky"&gt;Infrastructure in Gaia Sky&lt;/h2&gt;
&lt;p&gt;The renderer is only half of it. The other half is being able to load a volume from a data pack and declare it in a scene, which turned out to be mostly about &lt;em&gt;formats&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;A volume object is a regular &lt;code&gt;ModelBody&lt;/code&gt; with the &lt;code&gt;Volume&lt;/code&gt; archetype, so it accepts &lt;code&gt;sizePc&lt;/code&gt;, &lt;code&gt;parent&lt;/code&gt;, &lt;code&gt;fadeIn&lt;/code&gt;/&lt;code&gt;fadeOut&lt;/code&gt;, &lt;code&gt;coordinates&lt;/code&gt; and everything else, plus the volume-specific attributes:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;name&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;Milky Way (volume)&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;archetype&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;Volume&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;sizePc&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;26000.0&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;fadeIn&amp;#34;&lt;/span&gt;: [&lt;span style="color:#a6be9d"&gt;1.0e3&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;7.0e3&lt;/span&gt;],
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;fadeOut&amp;#34;&lt;/span&gt;: [&lt;span style="color:#a6be9d"&gt;15e4&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;60e4&lt;/span&gt;],
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;vertexShader&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;shader/pbr.vertex.glsl&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;fragmentShader&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;shader/volume.galaxy.fragment.glsl&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;raymarchIterations&amp;#34;&lt;/span&gt;: [&lt;span style="color:#a6be9d"&gt;50&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;70&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;90&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;110&lt;/span&gt;],
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;model&amp;#34;&lt;/span&gt;: {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;type&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;sphere&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;blendMode&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;additive&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;params&amp;#34;&lt;/span&gt;: { &lt;span style="color:#58a1dd"&gt;&amp;#34;diameter&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;1.3&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;&amp;#34;quality&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;30&lt;/span&gt; },
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;material&amp;#34;&lt;/span&gt;: {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;volume0&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;$data/default-data/galaxy/volume/gal*/galaxy_emissive_rgba.raw.gz&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;volume1&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;$data/default-data/galaxy/volume/gal*/galaxy_occlusive_rgba.raw.gz&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;volumeDepthThreshold&amp;#34;&lt;/span&gt;: [&lt;span style="color:#a6be9d"&gt;0.0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0.015&lt;/span&gt;]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;raymarchIterations&lt;/code&gt; has one value per quality preset (low, normal, high, and ultra, adjustable from the settings dialog), so the same scene degrades gracefully on a weak GPU. Up to four volumes can be declared per object, and all of them are marched and composited in the same pass.&lt;/p&gt;
&lt;p&gt;The loader (&lt;code&gt;VolumeTextureLoader&lt;/code&gt;) does three things worth mentioning:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It crops.&lt;/strong&gt; The grid is cut down to the range of voxels that actually hold density, plus one voxel of padding, and the cropped grid is mapped onto the same box. Writing the empty shell into the file costs file size and load time only.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It builds the occupancy grid&lt;/strong&gt; described above, and binds it as a separate 3D texture. A slot without one gets a \(1 \times 1 \times 1\) volume of ones, which never skips.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It derives the format from the bytes&lt;/strong&gt;, not from the filename: 1, 3 or 4 bytes per voxel, with the grid from the metadata sidecar.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of this is Milky-Way-specific: any 3D tool that can write an &lt;code&gt;RGBA8&lt;/code&gt; grid can produce a volume for Gaia Sky. The format and the scene attributes are documented in the &lt;a href="https://gaia.ari.uni-heidelberg.de/gaiasky/docs/master/Format-3d-textures.html"&gt;data format&lt;/a&gt; and the &lt;a href="https://gaia.ari.uni-heidelberg.de/gaiasky/docs/master/Data-format.html#volumes"&gt;volumes section&lt;/a&gt; of the Gaia Sky documentation.&lt;/p&gt;
&lt;p&gt;Memory is the price: an 800-resolution grid is \(798 \times 80 \times 800\) &lt;code&gt;RGBA&lt;/code&gt;, about 200 MB once decompressed, &lt;strong&gt;per volume&lt;/strong&gt;. The default data pack therefore ships three resolutions and picks one by data quality setting: 200, 400 and 800 voxels on the longest side of the box:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resolution&lt;/th&gt;
&lt;th&gt;Grid (galactic)&lt;/th&gt;
&lt;th&gt;Voxels&lt;/th&gt;
&lt;th&gt;Raw, per volume&lt;/th&gt;
&lt;th&gt;Emissive, gzipped&lt;/th&gt;
&lt;th&gt;Occlusive, gzipped&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;200 × 200 × 20&lt;/td&gt;
&lt;td&gt;0.8 M&lt;/td&gt;
&lt;td&gt;3.2 MB&lt;/td&gt;
&lt;td&gt;230 KB&lt;/td&gt;
&lt;td&gt;10.6 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;399 × 400 × 40&lt;/td&gt;
&lt;td&gt;6.4 M&lt;/td&gt;
&lt;td&gt;25.5 MB&lt;/td&gt;
&lt;td&gt;1.5 MB&lt;/td&gt;
&lt;td&gt;74.6 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;800&lt;/td&gt;
&lt;td&gt;798 × 800 × 80&lt;/td&gt;
&lt;td&gt;51 M&lt;/td&gt;
&lt;td&gt;204 MB&lt;/td&gt;
&lt;td&gt;8.9 MB&lt;/td&gt;
&lt;td&gt;430 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Thanks to gzip (and to how much of the box is empty) the download cost is trivial; it is the decompressed texture that decides whether a card can hold it.&lt;/p&gt;
&lt;h2 id="results"&gt;Results&lt;/h2&gt;
&lt;p&gt;The question a resolution-independent conversion has to answer is whether the three grids really are the same galaxy. They are: the mean ray integral (the integral of the density along a sample of rays through the volume, the quantity that has to match if the object is the same) is&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resolution&lt;/th&gt;
&lt;th&gt;Mean ray integral&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;0.00020193&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;0.00020295&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;800&lt;/td&gt;
&lt;td&gt;0.00020382&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;within 1% of each other, with the same total mass and the same hues, so what changes between them is only detail. And detail is visible: at 200 the dust cannot be smoothed at all (its kernel is 0.31 voxels, so the point cloud shows through), at 400 it is 0.63 voxels, and only at 800 does it reach 1.25 voxels and become a continuous lane. This is why the dust is the channel that decides which resolution you actually want, and why the 800 volume is the one in the preview video below.&lt;/p&gt;
&lt;p&gt;Up to 800, the volume costs what the step count says, not what the grid says. Frame time for the Milky Way alone, at QHD (2560x1440) with the camera at about 34.5 kpc above the galactic disk, on an NVIDIA GTX 1070 (driver 580.178.04) on Arch Linux. I deliberately chose to use this very old card instead of my other RTX 5080 because it may be more representative of the graphics computing power present in common household PCs and laptops. Tests have been carried out with only the base dataset loaded.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resolution&lt;/th&gt;
&lt;th&gt;low (50 steps)&lt;/th&gt;
&lt;th&gt;normal (70 steps)&lt;/th&gt;
&lt;th&gt;high (90 steps)&lt;/th&gt;
&lt;th&gt;ultra (110 steps)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;3.5 ms (285 fps)&lt;/td&gt;
&lt;td&gt;3.8 ms (259 fps)&lt;/td&gt;
&lt;td&gt;4.3 ms (231 fps)&lt;/td&gt;
&lt;td&gt;4.6 ms (216 fps)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;3.4 ms (289 fps)&lt;/td&gt;
&lt;td&gt;3.8 ms (260 fps)&lt;/td&gt;
&lt;td&gt;4.1 ms (239 fps)&lt;/td&gt;
&lt;td&gt;4.5 ms (222 fps)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;800&lt;/td&gt;
&lt;td&gt;4.3 ms (230 fps)&lt;/td&gt;
&lt;td&gt;4.5 ms (220 fps)&lt;/td&gt;
&lt;td&gt;4.7 ms (212 fps)&lt;/td&gt;
&lt;td&gt;5.2 ms (192 fps)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1300&lt;/td&gt;
&lt;td&gt;5.0 ms (198 fps)&lt;/td&gt;
&lt;td&gt;5.8 ms (172 fps)&lt;/td&gt;
&lt;td&gt;6.2 ms (161 fps)&lt;/td&gt;
&lt;td&gt;6.8 ms (145 fps)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;With that hardware the resolution is almost free: going from 200 to 800, sixty-four times the voxels, costs under a millisecond at every preset (3.5 → 4.3 ms at low, 4.6 → 5.2 at ultra). This is possibly because the ray march is per-pixel and the grid does not change how many steps a ray takes. That is why I also built a 1300 volume, which no shipped dataset uses: 274 times the voxels of 200, for 1.5 to 2.2 ms (3.5 → 5.0 ms at low, 4.6 → 6.8 at ultra). Even at that scale the cost stays far below the voxel count, but it is no longer invisible: 1300 is now the most expensive thing in the table, ahead of the preset.&lt;/p&gt;
&lt;p&gt;The step count is also interesting. Current presets are 50 at low against 110 at ultra, and even that only adds 0.9 ms at 800 and 1.8 ms at 1300. The slowest cell in the table is 6.8 ms (1300, ultra) at QHD, 41% of the 16.7 ms a 60 fps frame allows, so even that volume is not limited by frame time on this card.&lt;/p&gt;
&lt;p&gt;What the card does pay for is memory. These are the sizes as decompressed and uploaded to VRAM, at 4 bytes per voxel (&lt;code&gt;RGBA8&lt;/code&gt;), for the flattened, cropped boxes the loader actually builds:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resolution&lt;/th&gt;
&lt;th&gt;Grid&lt;/th&gt;
&lt;th&gt;Voxels&lt;/th&gt;
&lt;th&gt;VRAM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;200 × 20 × 200&lt;/td&gt;
&lt;td&gt;0.8 M&lt;/td&gt;
&lt;td&gt;3.2 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;400 × 40 × 400&lt;/td&gt;
&lt;td&gt;6.4 M&lt;/td&gt;
&lt;td&gt;25.6 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;800&lt;/td&gt;
&lt;td&gt;798 × 80 × 800&lt;/td&gt;
&lt;td&gt;51.1 M&lt;/td&gt;
&lt;td&gt;204 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1300&lt;/td&gt;
&lt;td&gt;1300 × 130 × 1300&lt;/td&gt;
&lt;td&gt;219.7 M&lt;/td&gt;
&lt;td&gt;879 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Even the 1300 volume fits in the 8 GB of a GTX 1070 with room to spare. That is the flattening earning its keep: the same resolution in a cubic box would be 8.8 GB, so the high-resolution row could not have been run at all.&lt;/p&gt;
&lt;p&gt;The stills below are where the argument becomes visible, each pair sharing a camera. The top view is where the billboards fall short, and for a reason that is easy to get backwards: seen face-on, so few billboards overlap along the line of sight that there is almost no galaxy left.&lt;/p&gt;
&lt;figure class="fig-center"&gt;
&lt;div class="imgcmp" style="--imgcmp-pos: 50%;"&gt;
&lt;img class="imgcmp-after" src="https://tonisagrista.com/img/2026/10/galaxy/comp-volume-top.jpg" alt="Top view. With so few billboards along the line of sight the galaxy almost disappears; the volume still shows the disk and its dust lanes. Volume at 800 resolution."
loading="lazy" decoding="async" /&gt;
&lt;div class="imgcmp-before"&gt;
&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/comp-bb-top.jpg" alt="Top view. With so few billboards along the line of sight the galaxy almost disappears; the volume still shows the disk and its dust lanes. Volume at 800 resolution."
loading="lazy" decoding="async" /&gt;&lt;span class="imgcmp-tag imgcmp-tag--before"&gt;Billboards, 3.8.x&lt;/span&gt;&lt;/div&gt;&lt;span class="imgcmp-tag imgcmp-tag--after"&gt;Volume, 3.9.0&lt;/span&gt;&lt;div class="imgcmp-line"&gt;&lt;span class="imgcmp-grip"&gt;&lt;/span&gt;&lt;/div&gt;
&lt;input class="imgcmp-range" type="range" min="0" max="100" step="0.1" value="50"
aria-label="Top view comparison between rendering methods" title="Top view. With so few billboards along the line of sight the galaxy almost disappears; the volume still shows the disk and its dust lanes. Volume at 800 resolution." /&gt;
&lt;/div&gt;&lt;figcaption&gt;&lt;h4&gt;Top view comparison between rendering methods&lt;/h4&gt;&lt;p class="fig-attribution"&gt;Top view. With so few billboards along the line of sight the galaxy almost disappears; the volume still shows the disk and its dust lanes. Volume at 800 resolution.&lt;/p&gt;&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;Edge-on, the billboards look plausible at first glance, but they blow out, while the volume rendering stays consistent. The same additive blending that was too faint from the top saturates here: the bulge becomes a flat white patch and the disk loses its internal structure. The volume stays a thin dark lane with its dust intact. It is also dimmer overall, which is expected, because there the light is absorbed rather than added.&lt;/p&gt;
&lt;figure class="fig-center"&gt;
&lt;div class="imgcmp" style="--imgcmp-pos: 50%;"&gt;
&lt;img class="imgcmp-after" src="https://tonisagrista.com/img/2026/10/galaxy/comp-volume-side.jpg" alt="Edge view. The billboards saturate, the volume does not. Volume at 800 resolution."
loading="lazy" decoding="async" /&gt;
&lt;div class="imgcmp-before"&gt;
&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/comp-bb-side.jpg" alt="Edge view. The billboards saturate, the volume does not. Volume at 800 resolution."
loading="lazy" decoding="async" /&gt;&lt;span class="imgcmp-tag imgcmp-tag--before"&gt;Billboards, 3.8.x&lt;/span&gt;&lt;/div&gt;&lt;span class="imgcmp-tag imgcmp-tag--after"&gt;Volume, 3.9.0&lt;/span&gt;&lt;div class="imgcmp-line"&gt;&lt;span class="imgcmp-grip"&gt;&lt;/span&gt;&lt;/div&gt;
&lt;input class="imgcmp-range" type="range" min="0" max="100" step="0.1" value="50"
aria-label="Edge view comparison between rendering methods" title="Edge view. The billboards saturate, the volume does not. Volume at 800 resolution." /&gt;
&lt;/div&gt;&lt;figcaption&gt;&lt;h4&gt;Edge view comparison between rendering methods&lt;/h4&gt;&lt;p class="fig-attribution"&gt;Edge view. The billboards saturate, the volume does not. Volume at 800 resolution.&lt;/p&gt;&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;Dust is where the grid resolution shows. Three close-ups of the same lane, same camera, 110 iterations each:&lt;/p&gt;
&lt;figure class="fig-center"&gt;
&lt;div class="imgsw"&gt;
&lt;div class="imgsw-frame"&gt;
&lt;a class="imgsw-item is-active" data-imgsw-index="0"&gt;
&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/dust-200.jpg" alt="Close-up of a Milky Way dust lane rendered from grids of 200, 400 and 800 resolution (200)"
loading="lazy" decoding="async" /&gt;
&lt;/a&gt;
&lt;a class="imgsw-item" data-imgsw-index="1" aria-hidden="true"&gt;
&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/dust-400.jpg" alt="Close-up of a Milky Way dust lane rendered from grids of 200, 400 and 800 resolution (400)"
loading="lazy" decoding="async" /&gt;
&lt;/a&gt;
&lt;a class="imgsw-item" data-imgsw-index="2" aria-hidden="true"&gt;
&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/dust-800.jpg" alt="Close-up of a Milky Way dust lane rendered from grids of 200, 400 and 800 resolution (800)"
loading="lazy" decoding="async" /&gt;
&lt;/a&gt;
&lt;/div&gt;
&lt;div class="imgsw-tabs" role="group" aria-label="Dust lanes at three grid resolutions"&gt;
&lt;button type="button" class="imgsw-btn is-active" data-imgsw-index="0"
aria-pressed="true"&gt;200&lt;/button&gt;
&lt;button type="button" class="imgsw-btn" data-imgsw-index="1"
aria-pressed="false"&gt;400&lt;/button&gt;
&lt;button type="button" class="imgsw-btn" data-imgsw-index="2"
aria-pressed="false"&gt;800&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;figcaption&gt;&lt;h4&gt;Dust lanes at three grid resolutions&lt;/h4&gt;&lt;p class="fig-attribution"&gt;The same close-up at 200, 400 and 800, 110 iterations. At 200 the fog is grainy, because the dust kernel is thinner than a voxel and the point cloud shows through. 400 is already smooth and 800 is smoother still, but the two are hard to tell apart at this framing. The stars and HII regions look the same in all three: they are billboards and never go through the grid.&lt;/p&gt;&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The last comparison is the two ends of the preset range. Low is the 200 grid and 50 steps, ultra is the 800 grid and 110 steps, so both knobs move at once and this pair shows the worst case rather than the effect of either one alone.&lt;/p&gt;
&lt;p&gt;&lt;figure class="fig-center"&gt;
&lt;div class="imgcmp" style="--imgcmp-pos: 50%;"&gt;
&lt;img class="imgcmp-after" src="https://tonisagrista.com/img/2026/10/galaxy/preset-ultra-top.jpg" alt="The low preset versus the ultra preset, top view."
loading="lazy" decoding="async" /&gt;
&lt;div class="imgcmp-before"&gt;
&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/preset-low-top.jpg" alt="The low preset versus the ultra preset, top view."
loading="lazy" decoding="async" /&gt;&lt;span class="imgcmp-tag imgcmp-tag--before"&gt;Preset: low&lt;/span&gt;&lt;/div&gt;&lt;span class="imgcmp-tag imgcmp-tag--after"&gt;Preset: ultra&lt;/span&gt;&lt;div class="imgcmp-line"&gt;&lt;span class="imgcmp-grip"&gt;&lt;/span&gt;&lt;/div&gt;
&lt;input class="imgcmp-range" type="range" min="0" max="100" step="0.1" value="50"
aria-label="Top view preset comparison" title="The low preset versus the ultra preset, top view." /&gt;
&lt;/div&gt;&lt;figcaption&gt;&lt;h4&gt;Top view preset comparison&lt;/h4&gt;&lt;p class="fig-attribution"&gt;The low preset versus the ultra preset, top view.&lt;/p&gt;&lt;/figcaption&gt;&lt;/figure&gt;
&lt;figure class="fig-center"&gt;
&lt;div class="imgcmp" style="--imgcmp-pos: 50%;"&gt;
&lt;img class="imgcmp-after" src="https://tonisagrista.com/img/2026/10/galaxy/preset-ultra-in.jpg" alt="The low preset versus the ultra preset, from inside the disk, looking at the galactic center. Low uses the 200 galaxy resolution and 50 iterations. Ultra uses the 800 galaxy resolution and 110 iterations."
loading="lazy" decoding="async" /&gt;
&lt;div class="imgcmp-before"&gt;
&lt;img src="https://tonisagrista.com/img/2026/10/galaxy/preset-low-in.jpg" alt="The low preset versus the ultra preset, from inside the disk, looking at the galactic center. Low uses the 200 galaxy resolution and 50 iterations. Ultra uses the 800 galaxy resolution and 110 iterations."
loading="lazy" decoding="async" /&gt;&lt;span class="imgcmp-tag imgcmp-tag--before"&gt;Preset: low&lt;/span&gt;&lt;/div&gt;&lt;span class="imgcmp-tag imgcmp-tag--after"&gt;Preset: ultra&lt;/span&gt;&lt;div class="imgcmp-line"&gt;&lt;span class="imgcmp-grip"&gt;&lt;/span&gt;&lt;/div&gt;
&lt;input class="imgcmp-range" type="range" min="0" max="100" step="0.1" value="50"
aria-label="Preset comparison from inside the disk" title="The low preset versus the ultra preset, from inside the disk, looking at the galactic center. Low uses the 200 galaxy resolution and 50 iterations. Ultra uses the 800 galaxy resolution and 110 iterations." /&gt;
&lt;/div&gt;&lt;figcaption&gt;&lt;h4&gt;Preset comparison from inside the disk&lt;/h4&gt;&lt;p class="fig-attribution"&gt;The low preset versus the ultra preset, from inside the disk, looking at the galactic center. Low uses the 200 galaxy resolution and 50 iterations. Ultra uses the 800 galaxy resolution and 110 iterations.&lt;/p&gt;&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Against ultra, the low preset is softer and hazier, and the dust has less contrast. That is intended: low is meant for modest hardware, and it still looks decent there.&lt;/p&gt;
&lt;p&gt;Below is a video I captured with the 800 galaxy running in Gaia Sky.&lt;/p&gt;
&lt;figure&gt;
&lt;div class="yt-container"&gt;
&lt;iframe
class="yt-iframe"
src="https://www.youtube-nocookie.com/embed/3BWCaxGOCzs"
title="Volumetric Milky Way rendering in Gaia Sky"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen&gt;
&lt;/iframe&gt;&lt;figcaption&gt;
&lt;h4&gt;Volumetric Milky Way rendering in Gaia Sky&lt;/h4&gt;&lt;p class="fig-attribution"&gt;Credits: The Gaia Sky Project, ESA/Gaia/DPAC.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/div&gt;
&lt;/figure&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;For a decade the Milky Way in Gaia Sky was a compromise between what the data said and what a rasterizer could do with a pile of billboards. The volume rendering path fixes this: the galaxy is now a medium that emits and absorbs. The dust lanes hide the stars behind them and not the ones in front, and it no longer blows out or fades to nothing depending on where the camera is.&lt;/p&gt;
&lt;p&gt;Two things made it work, and neither of them is in the shader itself. The first is the conversion: sizing the blur on the particles rather than on the grid is what makes all resolutions the same object at different levels of detail. Flattening the box to the shape of the data makes real savings in memory (256 vs 25 MB for the \(400^{3}\) box). This is what lets the unshipped 1300 grid fit in the 8 GB of a GTX 1070 and still draw a frame in under 7 ms. The second is the depth pre-pass: a volume cannot write meaningful depth in a blended pass. The rest of the scene has to know where the dust is, so the cheap early pass does the trick here.&lt;/p&gt;
&lt;p&gt;So far, all this work only affects one galaxy: our own. Gaia Sky can already generate galaxies procedurally, but in 3.9.0+ that system still produces billboard particles. The next step is to adapt it to produce and render volumes. Other than that, what is left are smaller fixes rather than architecture:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Sizing the ray-march step from the grid resolution instead of the box, so a finer grid is actually sampled finer.&lt;/li&gt;
&lt;li&gt;Replacing the time-hashed jitter with a static one (the current float32 clock dies after about ten hours of uptime).&lt;/li&gt;
&lt;li&gt;Enabling scattering in the dust, which is already wired with a coefficient of zero.&lt;/li&gt;
&lt;li&gt;Tuning the occlusion threshold, which is currently a single constant for the whole volume and would probably be better as a curve.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Finally, all this work has been done assuming we start from a particle-based distribution, which is a weird place to start for such a project. Typically, one builds the volume from scratch via analytical distributions without going through a particle dataset. For DR4, due on December 2, we will build a volume directly from Stefan&amp;rsquo;s data, bypassing the particles altogether.&lt;/p&gt;
&lt;div class="footnotes" role="doc-endnotes"&gt;
&lt;hr&gt;
&lt;ol&gt;
&lt;li id="fn:1"&gt;
&lt;p&gt;Astronomy Picture of the Day: Here comes the Sun&lt;/p&gt;
&lt;p&gt;&lt;a href="https://science.nasa.gov/image-article/apod-2016-september-26-gaia-here-comes-the-sun/"&gt;https://science.nasa.gov/image-article/apod-2016-september-26-gaia-here-comes-the-sun/&lt;/a&gt;&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:2"&gt;
&lt;p&gt;Stefan Payne-Wardenaar BlueSky profile&lt;/p&gt;
&lt;p&gt;&lt;a href="https://bsky.app/profile/stefanpw.bsky.social"&gt;https://bsky.app/profile/stefanpw.bsky.social&lt;/a&gt;&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:3"&gt;
&lt;p&gt;Gaia&amp;rsquo;s Milky Way map&lt;/p&gt;
&lt;p&gt;&lt;a href="https://www.cosmos.esa.int/web/gaia/milky-way"&gt;https://www.cosmos.esa.int/web/gaia/milky-way&lt;/a&gt;&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:4"&gt;
&lt;p&gt;&lt;em&gt;Morgan McGuire and Louis Bavoil&lt;/em&gt;, &lt;strong&gt;Weighted Blended Order-Independent Transparency&lt;/strong&gt;, &lt;em&gt;Journal of Computer Graphics Techniques&lt;/em&gt; (JCGT), vol. 2, no. 2, 122-141, 2013.&lt;/p&gt;
&lt;p&gt;Available online: &lt;a href="http://jcgt.org/published/0002/02/09/"&gt;http://jcgt.org/published/0002/02/09/&lt;/a&gt;&amp;#160;&lt;a href="#fnref:4" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;</description></item><item><title>JPEG XL finally lands in Chrome!</title><link>https://tonisagrista.com/blog/2026/chrome-jpegxl/</link><pubDate>Wed, 07 Oct 2026 00:00:00 +0000</pubDate><author>Toni Sagrista Selles</author><guid>https://tonisagrista.com/blog/2026/chrome-jpegxl/</guid><description>&lt;p&gt;Today, I was browsing &lt;a href="https://news.ycombinator.com"&gt;Hacker News&lt;/a&gt;, when a particular item caught my eye:&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/2026/10/hn-jpegxl-chrome.jpg"
alt="Hacker News post with the Chrome JPEG XL announcement." width="50%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption style="margin: 0 auto; width:50%;"&gt;
&lt;p class="fig-attribution"&gt;Hacker News post with the &lt;a href="https://developer.chrome.com/blog/jpeg-xl-in-chrome"&gt;Chrome JPEG XL announcement&lt;/a&gt;.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;I opened it promptly and, indeed, it was not a prank. Finally, after &lt;a href="https://tonisagrista.com/blog/2022/jpeg-xl-chrome"&gt;years&lt;/a&gt; and &lt;a href="https://tonisagrista.com/blog/2023/jpegxl-vs-avif"&gt;years&lt;/a&gt; of bull**** from the Chrome team, they are shipping it. They are shipping a JPEG XL decoder.&lt;/p&gt;
&lt;div class="hint"&gt;
&lt;div class="hint-title"&gt;&lt;i class="fa fa-info-circle"&gt;&lt;/i&gt;&amp;nbsp;&amp;nbsp;&lt;strong&gt;JPEG XL in a nutshell&lt;/strong&gt;&lt;/div&gt;
&lt;div class="hint-body"&gt;
&lt;p&gt;For those of you who don&amp;rsquo;t know, JPEG XL is a new image codec that includes most of, if not all, the features we&amp;rsquo;d want in the image codec of the future:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;File size reduction by \(~20-60\%\) w.r.t JPEG.&lt;/li&gt;
&lt;li&gt;Lossless JPEG transcoding.&lt;/li&gt;
&lt;li&gt;Progressive decoding.&lt;/li&gt;
&lt;li&gt;Wide gamut, HDR and 32-bit support.&lt;/li&gt;
&lt;li&gt;Animations and transparency (alpha channel).&lt;/li&gt;
&lt;li&gt;Shines with high-fidelity photographic images.&lt;/li&gt;
&lt;li&gt;Fast-ish encoding and decoding.&lt;/li&gt;
&lt;li&gt;Royalty-free and FOSS.&lt;/li&gt;
&lt;li&gt;Support for super high-resolution images, of up to 1 terapixels ( \(2^{30}-1\) pixels per side).&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;This is awesome news &lt;em&gt;for the codec&lt;/em&gt;. Whether we like it or not, Chrome is the dominant web browser, with ~66.5% of the &lt;a href="https://gs.statcounter.com/browser-market-share/"&gt;market share&lt;/a&gt;, very far from its competitors (Safari, Edge, and Firefox, in that order).&lt;/p&gt;
&lt;div id="all-browser-ww-monthly-202509-202609" class="div-center" width="600" height="400" style="width:600px; height: 400px; margin: auto;"&gt;&lt;/div&gt;&lt;!-- You may change the values of width and height above to resize the chart --&gt;&lt;figcaption&gt;Source: &lt;a href="https://gs.statcounter.com/browser-market-share/"&gt;StatCounter Global Stats - Browser Market Share&lt;/a&gt;&lt;/figcaption&gt;&lt;script type="text/javascript" src="https://www.statcounter.com/js/fusioncharts.js"&gt;&lt;/script&gt;&lt;script type="text/javascript" src="https://gs.statcounter.com/chart.php?all-browser-ww-monthly-202509-202609&amp;chartWidth=600"&gt;&lt;/script&gt;
&lt;p&gt;Now, I don&amp;rsquo;t use Chrome at all, but I still think that this was just the final piece of the puzzle needed to help the format go mainstream.&lt;/p&gt;
&lt;p&gt;So, what finally pushed them over the edge? If you read &lt;a href="https://developer.chrome.com/blog/jpeg-xl-in-chrome"&gt;their announcement&lt;/a&gt;, they cite &amp;ldquo;consistent feedback and requests from web developers&amp;rdquo; and its massive popularity in the Interop 2026 project. Translated from corporate PR-speak: the community simply refused to let it go. Web developers, photographers, and open-source advocates kept pushing, complaining on bug trackers, and demanding a truly superior image format instead of settling for Google&amp;rsquo;s own WebP or the heavily pushed AVIF.&lt;/p&gt;
&lt;p&gt;The technical excuse the Chrome team used for years was that a C++ decoder was too much of an attack surface to run safely in the browser. Well, it has now been elegantly sidestepped. To bring JPEG XL to Chrome 155, they have integrated &lt;code&gt;jxl-rs&lt;/code&gt;, a pure Rust reimplementation of the decoder. This tackles the memory safety issues head-on, eliminating the risk of out-of-bounds reads and use-after-free bugs of other decoders. And to make sure it doesn&amp;rsquo;t run sluggishly, they built a SIMD abstraction layer (&lt;code&gt;jxl_simd&lt;/code&gt;) to maximize hardware performance without compromising Rust&amp;rsquo;s safety guarantees.&lt;/p&gt;
&lt;p&gt;This is a massive victory. Not just for JPEG XL, but for the open web.&lt;/p&gt;
&lt;p&gt;As I demonstrated in my &lt;a href="https://tonisagrista.com/blog/2023/jpegxl-vs-avif"&gt;comparison back in 2023&lt;/a&gt;,JXL has significant advantages over older formats and, in many cases, over AVIF as well, especially for high-fidelity photography, lossless compression, and progressive decoding. The fact that it allows lossless transcoding of existing JPEG files is of special importance. It means that we can finally upgrade our backlog of old images without losing a single pixel of quality, while saving a big chunk of disk space and/or bandwidth in the process.&lt;/p&gt;
&lt;p&gt;With &lt;a href="https://tonisagrista.com/blog/2026/firefox-jpegxl/"&gt;Firefox adding it to Labs&lt;/a&gt; earlier this year, and Chrome finally capitulating, Safari is left as the major outlier with only partial support.&lt;/p&gt;
&lt;div class="hint"&gt;
&lt;div class="hint-title"&gt;&lt;i class="fa fa-info-circle"&gt;&lt;/i&gt;&amp;nbsp;&amp;nbsp;&lt;strong&gt;Notice&lt;/strong&gt;&lt;/div&gt;
&lt;div class="hint-body"&gt;
Firefox 158 will include the JPEG XL decoder &lt;strong&gt;enabled by default&lt;/strong&gt;. See the &lt;a href="https://www.firefox.com/en-US/firefox/158.0beta/releasenotes/"&gt;beta release notes&lt;/a&gt;.
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;The dark days of &lt;a href="https://tonisagrista.com/blog/2022/jpeg-xl-chrome"&gt;&amp;ldquo;Google kills JPEG XL&amp;rdquo;&lt;/a&gt; are officially over. It&amp;rsquo;s time to start converting your image libraries, updating your &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; tags, and serving &lt;code&gt;.jxl&lt;/code&gt;. The future of (high-quality) web imagery is finally here, and I&amp;rsquo;ll be ready for it.&lt;/p&gt;</description></item><item><title>Git hash prefix or cellphone number?</title><link>https://tonisagrista.com/blog/2026/git-hash-prefix-numbers/</link><pubDate>Tue, 29 Sep 2026 00:00:00 +0000</pubDate><author>Toni Sagrista Selles</author><guid>https://tonisagrista.com/blog/2026/git-hash-prefix-numbers/</guid><description>&lt;p&gt;Today, I was playing around with a new feature in &lt;a href="https://gaiasky.space"&gt;Gaia Sky&lt;/a&gt;, and I created a new release from master. Immediately, I noticed something unusual in the generated package name:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;packages-3.8.0.066962508
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The 3.8.0 part is the release version, while the last 9 characters are the abbreviated Git commit hash.
Usually, that last part looks something like:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;packages-3.8.0.a31f7c92e
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This time, however, every single one of the hash characters is a number, &lt;code&gt;066962508&lt;/code&gt;. My first stupid reaction was thinking that something had gone wrong. However, &lt;code&gt;git log&lt;/code&gt; confirmed the hash was fine.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;➜ git log
commit 066962508de536403a185471344ba83801d45a88 (HEAD -&amp;gt; master, origin/master, origin/HEAD, gitlab/master, gitlab/HEAD, github/master, github/HEAD)
Author: langurmonkey &amp;lt;tsagrista_AT_ari.uni-heidelberg.de&amp;gt;
Date: Tue Sep 29 10:10:15 2026 +0200
feat: Handle gaiasky:// URLs forwarded from second instances and macOS open events. Part of #935.
- Scope dataset URL dedup to the startup path only, so re-sent URLs are always processed by the running instance.
- Register install4j StartupNotification listener for macOS open-URL events, with pending-URL queue for pre-UI arrival.
- Add --enable-native-access=ALL-UNNAMED to launcher VM parameters - Add compileOnly install4j-runtime dependency.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Obviously, I asked myself, &lt;strong&gt;&amp;ldquo;how likely is that&amp;rdquo;&lt;/strong&gt;? So I did some digging.&lt;/p&gt;
&lt;p&gt;Git commit hashes are hexadecimal. That means each character can be one of 16 possible values:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;0 1 2 3 4 5 6 7 8 9 a b c d e f
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;So there&amp;rsquo;s nothing special about a hash starting with &lt;code&gt;066962508&lt;/code&gt;. It&amp;rsquo;s just a hexadecimal string that happens to contain no &lt;code&gt;a&lt;/code&gt;–&lt;code&gt;f&lt;/code&gt; characters in its first nine positions.&lt;/p&gt;
&lt;p&gt;But how unlikely is that?&lt;/p&gt;
&lt;p&gt;There are 10 numerical characters out of 16 possible hexadecimal characters, so the probability of a single character being a number is:&lt;/p&gt;
&lt;p&gt;$$
\frac{10}{16} = 0.625
$$&lt;/p&gt;
&lt;p&gt;For nine consecutive characters to all be numbers:&lt;/p&gt;
&lt;p&gt;$$
\left(\frac{10}{16}\right)^9 \approx 0.0145
$$&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s about 1.45%, or roughly 1 in 69.&lt;/p&gt;
&lt;p&gt;So it&amp;rsquo;s not particularly likely, but it&amp;rsquo;s certainly not extraordinary either. Given enough commits, you&amp;rsquo;re bound to run into one eventually. It looks even stranger because of the leading zero, and a quick glance at the number may even make someone mistake it for a Spanish cellphone number.&lt;/p&gt;
&lt;p&gt;I then asked myself what the probability of &lt;strong&gt;all&lt;/strong&gt; hash characters being numbers is. Well, if we assume ~40 characters (this is the default for SHA-1 hashes, even though git also supports SHA-256 with ~64 characters), the probability of every single one being a number is:&lt;/p&gt;
&lt;p&gt;$$
\left(\frac{10}{16}\right)^{40} \approx 7.89 \times 10^{-9}
$$&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s about 1 in 126.7 million. Much more unlikely, but still not totally outlandish. But I digress.&lt;/p&gt;
&lt;p&gt;Back to the case, I checked whether this was the first time this had happened in the Gaia Sky repository:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;➜ git log --all --format&lt;span style="color:#ff636f"&gt;=&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;%H&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; | grep -E &lt;span style="color:#a6be9d"&gt;&amp;#39;^[0-9]{9}&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; | wc -l
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;123&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Nope. It has happened over a hundred and twenty times, and I had simply never noticed it before. I&amp;rsquo;m pretty sure this is the first time I package a build from one of those commits though.&lt;/p&gt;</description></item><item><title>JPEG XL lands in Firefox</title><link>https://tonisagrista.com/blog/2026/firefox-jpegxl/</link><pubDate>Wed, 17 Jun 2026 00:00:00 +0000</pubDate><author>Toni Sagrista Selles</author><guid>https://tonisagrista.com/blog/2026/firefox-jpegxl/</guid><description>&lt;p&gt;Yesterday, Firefox 152 was released. In the &lt;a href="https://www.firefox.com/en-US/firefox/152.0/releasenotes/"&gt;release notes&lt;/a&gt;, under the Firefox Labs section, it states:&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;
&lt;source srcset="https://tonisagrista.com/img/2026/06/jpegxl-firefox-labs.jxl" type="image/jxl" /&gt;
&lt;img src="https://tonisagrista.com/img/2026/06/jpegxl-firefox-labs.jpg"
alt="The Firefox Labs section of the release notes of Firefox 152. If you see JPEG-XL works!, it means your browser is JPEG-XL capable. If you see JPG :(, it means your browser does not support it." width="100%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption style="margin: 0 auto; width:100%;"&gt;
&lt;p class="fig-attribution"&gt;The Firefox Labs section of the release notes of Firefox 152. If you see &lt;span style='font-weight: 800;color:yellow'&gt;JPEG-XL works!&lt;/span&gt;, it means your browser is JPEG-XL capable. If you see &lt;span style='font-weight: 800;color:red'&gt;JPG :(&lt;/span&gt;, it means your browser does not support it.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;blockquote&gt;
&lt;p&gt;Firefox now offers experimental support for the new JPEG XL image format, which generally provides better compression than WebP, JPEG, PNG, and GIF and is designed to supersede them. You can enable it from the Firefox Labs panel in Settings.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Finally! I have written about this image format &lt;a href="https://tonisagrista.com/blog/2022/jpeg-xl-chrome"&gt;here&lt;/a&gt;, &lt;a href="https://tonisagrista.com/blog/2023/jpegxl-vs-avif"&gt;here&lt;/a&gt;, and &lt;a href="https://tonisagrista.com/blog/2025/google-unkills-jpegxl"&gt;here&lt;/a&gt;. The latter entry is particularly important because Google set the ball rolling by announcing that they would look into adding a memory-safe JPEG XL decoder into Chromium. It was only a matter of time before the other browsers would follow suit.&lt;/p&gt;
&lt;p&gt;I have not had the chance to test this myself, as at the time of writing, &lt;code&gt;firefox-152&lt;/code&gt; has not yet landed on the Arch repositories. However, it won&amp;rsquo;t be long. I&amp;rsquo;m just happy that it is finally happening. That&amp;rsquo;s all.&lt;/p&gt;
&lt;p&gt;&lt;span class="orange"&gt;Edit (2026-06-17 09:26 UTC):&lt;/span&gt;
Well, a little over an hour after I wrote the post, and version 152 is live on the Arch extra repository. I tested it quickly and it seems to work. The &lt;a href="https://jpegxl.info/resources/jpeg-xl-test-page.html"&gt;JPEG XL test page&lt;/a&gt; indicates that alpha transparency and animation work. I can&amp;rsquo;t test the wide gamut feature as I don&amp;rsquo;t have a P3 display.&lt;/p&gt;</description></item><item><title>Local agentic workflows are getting crazy good</title><link>https://tonisagrista.com/blog/2026/local-llm-qwen3.6/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><author>Toni Sagrista Selles</author><guid>https://tonisagrista.com/blog/2026/local-llm-qwen3.6/</guid><description>&lt;p&gt;Building on the previous post about &lt;a href="https://tonisagrista.com/blog/2026/qwensay"&gt;how good current open TTS models are&lt;/a&gt;, this post focuses on local &lt;abbr title="Large Language Model"&gt;LLM&lt;/abbr&gt;s, and their explosion in quality in the recent &lt;del&gt;years&lt;/del&gt; months. I have been using &lt;a href="https://openrouter.ai"&gt;OpenRouter&lt;/a&gt; with, for the most part, &lt;a href="https://openrouter.ai/deepseek/deepseek-v4-flash"&gt;DeepSeek v4 Flash&lt;/a&gt;. It is a very capable model, dirt-cheap (seems to be heavily subsidized, at $0.14/M tokens input and $0.28/M tokens output), and it works very well when it is free to browse the internet. I have also been tinkering with local models like the Qwen3.5 family, Gemma 3/4, Mistral, and more. They are good, I haven&amp;rsquo;t been able to use the versions that can run reasonably on consumer hardware for anything too productive. That is until now. The brand new Qwen3.6 models, particularly the 27B dense, and the 35B A3B &lt;abbr title="Mixture of Experts"&gt;MoE&lt;/abbr&gt;, have really impressed the community. They are trivial to run on common setups (e.g. 16GB RTX card, ~32-64GB RAM), and when boosted with agentic capabilities, they perform very well.&lt;/p&gt;
&lt;p&gt;My use case is quite modest. I typically use LLMs for research. I explain the problem and propose a couple of solutions, and ask for feedback. When I&amp;rsquo;m new to a topic, I like to get a bird&amp;rsquo;s eye view type of summary first, and then I ask for further explanation and deeper insight on certain areas that interest me. If possible, I add the sources that may help it gain more knowledge on the subject. Sometimes, I ask it to proofread my writings, but I tend to restrict it to fixing grammatical and spelling errors, with the occasional wording suggestion. I think writing is valuable and useful, as it helps with structuring my thoughts and organizing my ideas. That&amp;rsquo;s why I frown upon AI-generated blog posts. Additionally, I don&amp;rsquo;t like the overly verbose and structured texts it generates. I don&amp;rsquo;t do much AI-assisted coding. Sometimes, I may ask it to generate the odd Python script to solve a specific task that would otherwise get tedious.&lt;/p&gt;
&lt;p&gt;My setup is like this: For casual questions and general chatting, I use OpenRouter chat for remote models and LM Studio for local models. Usually, I run the models on a gaming laptop (RTX 5080 16GB, 32GB RAM) and serve them to my machine via LM Link. For researching and more complicated workflows, I use the &lt;a href="https://hermes-agent.nousresearch.com"&gt;Hermes Agent&lt;/a&gt;. It is open source (MIT), fully-featured, self-improving (learns between sessions!), and offers everything I need with minimal configuration. It works very, very well. I connect it to either DeepSeek V4 Flash via OpenRouter, or to my local LM Studio, which is tethered to the inference machine via LM Link.&lt;/p&gt;
&lt;p&gt;The Qwen3.6 27B and 35B A3B models, released in April 2026, have made strides in the community. They are extremely capable for their small size. They don&amp;rsquo;t have a ton of knowledge on all domains like the near-trillion-parameter frontier models, but when paired with the right toolset, they get very close. They don&amp;rsquo;t fit in most GPUs, but they can be partially offloaded. The MoE model in particular, with only 3B parameters active during inference, is very fast. I get ~35-40 tok/s with the 35B A3B MoE model, and about 4.5 tok/s with the 27B dense model. The dense model is borderline unusable, so I almost always rely on the MoE model. I offload 19 layers to the GPU (~12.5 GB out of a total of 26.63 GB), and use the maximum context size of ~262K tokens. This is plenty for agent sessions and works very well. The model is able to plan and execute actions very efficiently. It tends to overthink a lot, but this is an acceptable trade-off given the output quality. On OpenRouter, this exact same model is more expensive than DeepSeek V4 Flash, at $0.15/$1 per million tokens input/output. Being able to run such a model locally feels like a superpower.&lt;/p&gt;
&lt;p&gt;So, now the question is obvious. Will local LLM get good enough in the future to replace frontier models? I&amp;rsquo;d argue that it depends on the task. Frontier models have a vast amount of knowledge and are able to one-shot many complex problems successfully. They will continue to be more suited for bleeding-edge research for the years to come. But they are also very expensive, very taxing on the environment, and the primary reason for the sky-high current hardware prices. There is certainly a case to be made for local and more economic small models.For day-to-day agentic workflow, I think open models are very close already. I would bet that in the next 2 years they will get on pair for most everyday tasks. Is this prediction grounded on reality, or just wishful thinking? I&amp;rsquo;d like to think the former. In any case, I&amp;rsquo;ll be here, watching, tinkering.&lt;/p&gt;</description></item><item><title>Local TTS is getting very capable and accessible</title><link>https://tonisagrista.com/blog/2026/qwensay/</link><pubDate>Wed, 29 Apr 2026 00:00:00 +0000</pubDate><author>Toni Sagrista Selles</author><guid>https://tonisagrista.com/blog/2026/qwensay/</guid><description>&lt;p&gt;Around 2007 I spent half a year in the &lt;a href="https://abdn.ac.uk"&gt;University of Aberdeen&lt;/a&gt; working on my &lt;a href="https://tonisagrista.com/projects/#computer-aided-catalan-learning-application"&gt;final year project&lt;/a&gt; involving &lt;abbr title="Natural Language Processing"&gt;NLP&lt;/abbr&gt;. The project consisted of a series of modules to build an graphical adventure-type game controlled by language input in the form of text. It also featured speech output generated by an early &lt;abbr title="Text-to-Speech"&gt;TTS&lt;/abbr&gt; system. To achieve this, we managed to partner with a group at &lt;a href="https://www.lasalle.edu/"&gt;La Salle University&lt;/a&gt; that were working on a TTS system for Catalan. It was a closed system that was accessible via a web API, but that option was far too slow for real time use. I ended up preprocessing the audio of all dialogs in the project into WAV files just to be able to play them in sync with mouth movements. At that time, I was amazed that a computer could so easily convert text to an &lt;em&gt;understandable&lt;/em&gt; audio file. The voice was very plain and robotic, with no emotion whatsoever. The results were hit or miss, but it &lt;em&gt;worked&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Fast forward to today, TTS systems are everywhere. Several groups have released low-parameter TTS models that run very well on consumer hardware. I have been using the lightweight &lt;a href="https://github.com/KittenML/KittenTTS"&gt;Kitten TTS&lt;/a&gt; for a while with fantastic results.&lt;/p&gt;
&lt;p&gt;The models are so lightweight that some websites are heavier than entire Kitten TTS models:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Parameters&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Download&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;kitten-tts-mini&lt;/td&gt;
&lt;td&gt;80M&lt;/td&gt;
&lt;td&gt;80 MB&lt;/td&gt;
&lt;td&gt;&lt;a href="https://huggingface.co/KittenML/kitten-tts-mini-0.8"&gt;KittenML/kitten-tts-mini-0.8&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kitten-tts-micro&lt;/td&gt;
&lt;td&gt;40M&lt;/td&gt;
&lt;td&gt;41 MB&lt;/td&gt;
&lt;td&gt;&lt;a href="https://huggingface.co/KittenML/kitten-tts-micro-0.8"&gt;KittenML/kitten-tts-micro-0.8&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kitten-tts-nano&lt;/td&gt;
&lt;td&gt;15M&lt;/td&gt;
&lt;td&gt;56 MB&lt;/td&gt;
&lt;td&gt;&lt;a href="https://huggingface.co/KittenML/kitten-tts-nano-0.8-fp32"&gt;KittenML/kitten-tts-nano-0.8&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kitten-tts-nano (int8)&lt;/td&gt;
&lt;td&gt;15M&lt;/td&gt;
&lt;td&gt;25 MB&lt;/td&gt;
&lt;td&gt;&lt;a href="https://huggingface.co/KittenML/kitten-tts-nano-0.8-int8"&gt;KittenML/kitten-tts-nano-0.8-int8&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Projects like &lt;a href="https://github.com/Mic92/puss-say"&gt;&lt;code&gt;puss-say&lt;/code&gt;&lt;/a&gt; streamline and trivialize Kitten TTS inference. I have a shell script in one of my &lt;code&gt;bin&lt;/code&gt; directories that does everything in a single command:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;#!/usr/bin/env bash
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;uvx --from &lt;span style="color:#a6be9d"&gt;&amp;#39;git+https://github.com/Mic92/puss-say&amp;#39;&lt;/span&gt; puss-say &lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;$@&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This clones the project, pulls dependencies and models, and plays the audio. It is quite fast, especially when using cached data. Kitten TTS produces acceptable results, though the output usually lacks emotion and nuance. For simple use cases (reading notifications, generating voiceovers for scripts) it&amp;rsquo;s more than sufficient.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/QwenLM/Qwen3-TTS"&gt;Qwen3-TTS&lt;/a&gt;, which I&amp;rsquo;ve been recently testing, represents a step-up in quality. It&amp;rsquo;s extremely good, and local inference is practical even on modest hardware given the model sizes. It offers three interesting variants:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameters&lt;/th&gt;
&lt;th&gt;Hugging Face ID&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;VRAM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1.7B&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Free-form voice descriptions&lt;/td&gt;
&lt;td&gt;~6 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.7B&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Preset speakers + style instructions&lt;/td&gt;
&lt;td&gt;~6 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.6B&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Qwen/Qwen3-TTS-12Hz-0.6B-Base&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Voice cloning from reference audio&lt;/td&gt;
&lt;td&gt;~2 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The voice design models are particularly clever: you describe the voice you want alongside the text to convert. Want a deep, gravelly voice with a Scottish accent? Or an excited teenager talking about a video game? Just describe it. It&amp;rsquo;s remarkable that you can run this locally so easily. However, as far as I know there&amp;rsquo;s no off-the-shelf CLI tool that handles dependencies, downloads the model, and runs inference out of the box.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s why I created &lt;a href="https://codeberg.org/langurmonkey/qwensay"&gt;QwenSay&lt;/a&gt;. With it, you can clone the repository and convert text to speech locally from your terminal without wrestling with dependencies or writing any code.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s how it works. First, set it up:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;# Clone the repo&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;git clone ssh://git@codeberg.org/langurmonkey/qwensay.git
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;cd&lt;/span&gt; qwensay
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;# Default dependencies&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;uv sync
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;# For modern RTX cards, use flash attention&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;uv sync --extra gpu
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;# For Pascal (GTX 10x0 family), you need a special CUDA version&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;uv sync --group torch-pascal --no-group torch-default
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now, you are ready to convert your text to speech with Qwen3-TTS:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;uv run qwensay.py &lt;span style="color:#a6be9d"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; --text &lt;span style="color:#a6be9d"&gt;&amp;#34;Good morning! Today is going to be a great day.&amp;#34;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; --instruct &lt;span style="color:#a6be9d"&gt;&amp;#34;A cheerful, energetic young woman with a clear American accent&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This uses the default 1.7B voice design model. You can also specify the model with &lt;code&gt;--model&lt;/code&gt;. There are many other CLI arguments that you can use to tune your output. Check out the &lt;a href="https://codeberg.org/langurmonkey/qwensay/src/branch/master/README.md"&gt;repository documentation&lt;/a&gt; for more details.&lt;/p&gt;
&lt;p&gt;Whether you&amp;rsquo;re building accessibility features, creating voiceovers for projects, or just experimenting, this is worth a try. I&amp;rsquo;ve made QwenSay my go-to TTS tool because it produces high-quality results and is genuinely fast.&lt;/p&gt;</description></item><item><title>Fine-tuning Qwen3.5 for Gaia Sky</title><link>https://tonisagrista.com/blog/2026/fine-tuning-qwen3.5-gaiasky/</link><pubDate>Wed, 01 Apr 2026 00:00:00 +0000</pubDate><author>Toni Sagristà Sellés</author><guid>https://tonisagrista.com/blog/2026/fine-tuning-qwen3.5-gaiasky/</guid><description>&lt;p&gt;A little over a year ago I set up a &lt;a href="https://tonisagrista.com/blog/2025/local-llm-rag"&gt;local pipeline&lt;/a&gt; to use different &lt;abbr title="Large Language Model"&gt;LLM&lt;/abbr&gt;s to &lt;a href="https://tonisagrista.com/blog/2025/gaiasky-ai-assistant"&gt;respond to Gaia Sky questions&lt;/a&gt; using &lt;abbr title="Retrieval Augmented Generation"&gt;RAG&lt;/abbr&gt;. In that post, I built a dynamic scrapper that parsed the Gaia Sky website and documentation and ingested the content it into a vector database. Then, I built a minimal terminal chatbot interface that received the user prompt, queried the database for semantically similar data, and built up the context for each LLM call. The results were promising, and I found that they (obviously) strongly depended on the model used.&lt;/p&gt;
&lt;p&gt;Fast forward a few months, and the Qwen 3.5 models were released by Alibaba. The general consensus is that they are quite good for their size. I&amp;rsquo;ve been testing them for local inference with a similar impression. I thought that it would be interesting to repeat the exercise of creating a Gaia Sky AI assistant, but using a radically different approach: Instead of RAG, I would &lt;strong&gt;fine-tune the model&lt;/strong&gt; itself. In this post, I describe this fine-tuning project, from the creation and engineering of the training dataset to the fine-tuning and production of the final &lt;abbr title="GPT-Generated Unified Format"&gt;GGUF&lt;/abbr&gt; models.&lt;/p&gt;
&lt;p&gt;This project is composed by two, very distinct parts, which map to top-level chapters in this post:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="#training-dataset-creation"&gt;Training dataset creation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#fine-tuning"&gt;Fine tuning&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;At the end I quickly evaluate the results in the &lt;a href="#testing"&gt;testing&lt;/a&gt; section.&lt;/p&gt;
&lt;p&gt;The source code, dataset, and models discussed in this post are in the following repositories:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Dataset creation and fine-tuning - &lt;i class="ln-codeberg" aria-hidden="true" title="Codeberg"&gt;&lt;/i&gt; &lt;a href="https://codeberg.org/gaiasky/gaiasky-finetune"&gt;gaiasky-finetune&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Gaia Sky training dataset repository - &lt;i class="ln-huggingface" aria-hidden="true" title="Codeberg"&gt;&lt;/i&gt; &lt;a href="https://hf.co/Langurmonkey/gaiasky-training-dataset"&gt;gaiasky-training-dataset&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Qwen3.5 Gaia Sky fine-tuned models - &lt;i class="ln-huggingface" aria-hidden="true" title="Codeberg"&gt;&lt;/i&gt; &lt;a href="https://hf.co/Langurmonkey/gaiasky-qwen-3.5-gguf"&gt;gaiasky-qwen-3.5-gguf&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="hardware"&gt;Hardware&lt;/h2&gt;
&lt;p&gt;Here is the hardware I have used to create the dataset and fine-tune the model:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Desktop PC&lt;/strong&gt; - Arch Linux, Intel(R) Core(TM) i7-7700 (8) @ 4.20 GHz, 32 GB RAM, NVIDIA GeForce GTX 1070 8 GB.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Laptop 1&lt;/strong&gt; - Windows 11, WSL2 (Arch Linux), Intel(R) Core(TM) Ultra 9 275HX (24) @ @ 3.07 GHz, 32 GB RAM, NVIDIA GeForce RTX 5080 Mobile 16 GB.
&lt;a href="#laptop2"&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Laptop 2&lt;/strong&gt; - Arch Linux, Intel(R) Core(TM) i7 8750H (12) @ 4.10 GHz, 16 GB RAM, NVIDIA GeForce GTX 1060 Mobile 6 GB.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="training-dataset-creation"&gt;Training dataset creation&lt;/h2&gt;
&lt;p&gt;The creation of the training dataset is the most important piece of work in this project. It is composed of three parts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="#documentation-dataset"&gt;Documentation dataset&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#api-dataset"&gt;API dataset&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#identity"&gt;Identity&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="documentation-dataset"&gt;Documentation dataset&lt;/h3&gt;
&lt;p&gt;When I started this project, my first instinct was &amp;ldquo;more is better.&amp;rdquo; I thought that if I fed the model every single &lt;code&gt;.java&lt;/code&gt;, &lt;code&gt;.glsl&lt;/code&gt;, &lt;code&gt;.rst&lt;/code&gt;, and &lt;code&gt;.md&lt;/code&gt; file in the Gaia Sky repositories (project, documentation, etc.), it would emerge as an expert. Oh boy, was I wrong.&lt;/p&gt;
&lt;p&gt;A large codebase contains a lot of boilerplate noise. Getters, setters, license blocks, and infrastructure code that doesn&amp;rsquo;t actually help a model understand &lt;em&gt;how&lt;/em&gt; the engine works or &lt;em&gt;how&lt;/em&gt; to write scripts for it. I soon realized that the dataset is the single most important part of the project, and it needed a surgical approach.&lt;/p&gt;
&lt;p&gt;The plan was to automate the process of creating the dataset to a degree, and then use it to fine-tune the &lt;strong&gt;Qwen 3.5 4B&lt;/strong&gt; and &lt;strong&gt;8B&lt;/strong&gt; model variants.&lt;/p&gt;
&lt;p&gt;I wrote &lt;code&gt;generate-raw-dataset.py&lt;/code&gt; to act as a high-pass filter. Instead of a blind crawl, I implemented an &lt;strong&gt;allowlist&lt;/strong&gt; system. I would only let in the load-bearing files:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Documentation:&lt;/strong&gt; By far, the most important data, containing exhaustive human-written documentation pages. We convert the documentation &lt;abbr title="reStructuredText"&gt;RST&lt;/abbr&gt; files to Markdown with &lt;code&gt;pandoc&lt;/code&gt;, and then we add some additional key files, like the project&amp;rsquo;s &lt;code&gt;README.md&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Core Logic:&lt;/strong&gt; Selected Java files that are representative of the brain of the engine (main loop, scene, renderer, etc.).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Visual Logic:&lt;/strong&gt; Selected shader files that define the look of the cosmos (stars, particles, PBR, etc.).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Almost every source file in Gaia Sky starts with a copyright header. This is &amp;ldquo;dead weight&amp;rdquo; for training. I added a regex-based stripper to ensure the model&amp;rsquo;s limited context window was filled with code logic, not license text:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;def&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;strip_java_copyright&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;content&lt;/span&gt;):
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;# Matches the opening /* until the first */ at the start of the file&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;content&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;re&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;sub&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;r&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;^/\*.*?\*/&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;&amp;#39;&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;content&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;count&lt;/span&gt;&lt;span style="color:#ff636f"&gt;=&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;flags&lt;/span&gt;&lt;span style="color:#ff636f"&gt;=&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;re&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;DOTALL&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;return&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;content&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;strip&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The output of this first phase was a &lt;code&gt;jsonl&lt;/code&gt; file where each line represented a single, cleaned-up file. It looked like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;instruction&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;core/src/gaiasky/util/coord/Coordinates.java&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;output&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;public class Coordinates {\n // Implementation of transformation matrices...\n}&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;source_file&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;core/src/gaiasky/util/coord/Coordinates.java&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This provided the &amp;ldquo;Context&amp;rdquo; for the next phase. However, a model trained directly on this would just learn to autocomplete files. To make it an &lt;em&gt;assistant&lt;/em&gt;, we had to turn these files into a conversation.&lt;/p&gt;
&lt;p&gt;Once I had a clean extraction of the most relevant information pieces, I faced a new problem. A raw dump of a &lt;code&gt;Coordinates.java&lt;/code&gt; file is great for a search engine, but it is not a conversation. To turn these files into training data, I used a &amp;ldquo;teacher&amp;rdquo; model, Qwen 3.5 27B, to look at each file and generate a specific number of Q&amp;amp;A pairs. I wrote &lt;code&gt;distill-dataset.py&lt;/code&gt; to handle this. The script calculates how many questions a file is worth based on its length and type. A long documentation file might get 25 Q&amp;amp;A pairs, while a short shader might only get 4. Below is an extract of the method that computes the number of target pairs for a file.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;def&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;calculate_target_pairs&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;file_path&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;content&lt;/span&gt;):
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;&amp;#34;&amp;#34;Determine how many Q&amp;amp;A pairs to generate based on file metadata.&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;filename&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;file_path&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;lower&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;content_len&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;len&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;content&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;# Skip files that are essentially empty&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;content_len&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;lt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;150&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;return&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;# Set a modest base count that depends on type&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;# Docs are high-value, so they start slightly higher than code&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;filename&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;endswith&lt;/span&gt;((&lt;span style="color:#a6be9d"&gt;&amp;#39;.md&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;.rst&amp;#39;&lt;/span&gt;)):
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;base_count&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;4&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;# 1 pair per 750 characters for documentation&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;chars_per_pair&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;750&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;else&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;base_count&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;2&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;# 1 pair per 1500 characters for source code&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;chars_per_pair&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;2000&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;# Calculate length-based bonus without a tiny cap&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;# This scales better for massive files while keeping short files lean&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;length_bonus&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;content_len&lt;/span&gt; &lt;span style="color:#ff636f"&gt;//&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;chars_per_pair&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;# Sum and apply a reasonable maximum&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;final_count&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;base_count&lt;/span&gt; &lt;span style="color:#ff636f"&gt;+&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;length_bonus&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;# Cap it to avoid overwhelming the distillation for single files&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;# but ensure even tiny files get at least the base_count&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;return&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;max&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;base_count&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;min&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;final_count&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;20&lt;/span&gt;))
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Initially, I used the &lt;abbr title="Mixture of Experts"&gt;MoE&lt;/abbr&gt; Qwen 3.5 30B A3B, but it was consistently outputting the wrong format. Then I switched to the 27B dense model, and it performed a little better. Even so, I had to tell the model &lt;strong&gt;exactly&lt;/strong&gt; how to behave. Here are the key items I learned the hard way:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Match the answer type:&lt;/strong&gt; If the question doesn&amp;rsquo;t ask for code, don&amp;rsquo;t provide it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Grounding:&lt;/strong&gt; Every claim must be directly grounded on the source text.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Diversity:&lt;/strong&gt; Every question must cover a different detail.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I also found that, at these model sizes, it is better to batch Q&amp;amp;A pairs instead of asking the model to provide 20 of them in one go. I finally gravitated to 3 Q&amp;amp;A pairs per inference call. To prevent the model from repeating itself across batches, I tracked existing questions and fed them back into the prompt as exclusions.&lt;/p&gt;
&lt;p&gt;The prompt is constructed as follows:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;# A snippet from the distillation prompt&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;prompt&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;f&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;You are a Gaia Sky technical expert. &lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;file_hint&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;\n&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;f&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;Generate exactly &lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;current_target&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt; Q&amp;amp;A pairs strictly based on the source text below. &amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;Every fact and claim in your answers must be directly grounded in the source text. &amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;Never invent API calls or function names not present in the source. &amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;Match the answer type to the question: if no code is asked for, give no code. &amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;HIGHLY DIVERSE Q&amp;amp;A pairs. Every question must cover a COMPLETELY DIFFERENT topic or detail &amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;of the source text than the ones already provided. &amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;f&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;avoid_context&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;\n\n&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;Format the output as JSONL: one JSON object per line, with the question in the &amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;\&amp;#34;&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;instruction&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;\&amp;#34;&lt;/span&gt;&lt;span style="color:#a6be9d"&gt; field and the answer in the &lt;/span&gt;&lt;span style="color:#a6be9d"&gt;\&amp;#34;&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;output&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;\&amp;#34;&lt;/span&gt;&lt;span style="color:#a6be9d"&gt; field. &amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;Output ONLY the JSONL, nothing else.&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;\n\n&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;f&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;SOURCE FILE: &lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;file_path&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;\n&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;f&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;=&amp;#39;&lt;/span&gt; &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;60&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;\n&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;f&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;file_content&lt;/span&gt;[:&lt;span style="color:#58a1dd"&gt;context_limit&lt;/span&gt;]&lt;span style="color:#a6be9d"&gt;}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;\n&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;f&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;=&amp;#39;&lt;/span&gt; &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;60&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It consists of the following parts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Base text - This is composed by the raw strings in the &lt;code&gt;prompt&lt;/code&gt; variable.&lt;/li&gt;
&lt;li&gt;The file hint (&lt;code&gt;{file_hint}&lt;/code&gt;) - We add hints depending on the filetype. The following table displays the hint for each type.
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Filetype&lt;/th&gt;
&lt;th&gt;Extensions&lt;/th&gt;
&lt;th&gt;Hint&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.java&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;This is Java source code. Focus on class responsibilities, method signatures, and architectural patterns. Do NOT generate Python scripting examples.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;This is Python scripting code for the Gaia Sky API. Questions about usage and parameters are appropriate.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shader&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.glsl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;This is a GLSL shader. Focus on the rendering technique, uniforms, and mathematical operations. Do NOT generate Python scripting examples.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.md&lt;/code&gt; &lt;code&gt;.rst&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;This is documentation. Focus on concepts, features, workflows, and user-facing features.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/li&gt;
&lt;li&gt;The pair count (&lt;code&gt;{current_target}&lt;/code&gt;) - Contains the number of Q&amp;amp;A pairs to generate.&lt;/li&gt;
&lt;li&gt;The previous Q&amp;amp;A pairs, if any (&lt;code&gt;{avoid_context}&lt;/code&gt;) - This is constructed by listing the existing pairs, as parsed by the program in the output, or accumulated in the current run.
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;instr_list&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; [&lt;span style="color:#58a1dd"&gt;p&lt;/span&gt;[&lt;span style="color:#a6be9d"&gt;&amp;#39;instruction&amp;#39;&lt;/span&gt;] &lt;span style="color:#ff636f"&gt;for&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;p&lt;/span&gt; &lt;span style="color:#ff636f"&gt;in&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;existing_pairs&lt;/span&gt;]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;avoid_context&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;\n&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;Do NOT repeat or rephrase any of these already-covered questions:&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;\n&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;+&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;\n&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;join&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;f&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;- &lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;instr&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt; &lt;span style="color:#ff636f"&gt;for&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;instr&lt;/span&gt; &lt;span style="color:#ff636f"&gt;in&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;instr_list&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; )
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/li&gt;
&lt;li&gt;The filepath and content (&lt;code&gt;{file_path}&lt;/code&gt;, &lt;code&gt;{file_content}&lt;/code&gt;) - Contain the filename and the actual content, which is capped to fit within the context length.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;However, local LLMs tend to be chatty. Even with such strict instructions, larger models like the 27B sometimes also mess up. From time to time, this model would leak its own reasoning into the output. It would start its response with &lt;code&gt;&amp;quot;The user asked to provide 34 Q&amp;amp;A pairs given the source material...&amp;quot;&lt;/code&gt; or it would include meta-talk like &lt;code&gt;&amp;quot;This question is safe because it is derived from line 45.&amp;quot;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;This created &lt;em&gt;dirty&lt;/em&gt; data that polluted the dataset and undermined the fine-tuning process. If I trained on this, the final model would start every answer by talking to itself.&lt;/p&gt;
&lt;p&gt;To fix this, I built &lt;code&gt;sanitize-jsonl.py&lt;/code&gt;. This script is a heavy-duty cleaner that uses regex to strip out training artifacts. It first tries to rescue bad rows, and if it fails, it deletes them. If the model accidentally put both the question and answer in the output field, the sanitizer attempts to detect the question mark and splits them back into the correct structure.&lt;/p&gt;
&lt;p&gt;Here is a look at what the data looked like before and after the sanitization process.&lt;/p&gt;
&lt;h6 id="raw-distilled-entry"&gt;Raw distilled entry&lt;/h6&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;instruction&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;I need to generate 3 pairs based on the Gaia Sky API. Here is pair 1:&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;output&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;Question: How do I set the camera position? Answer: Use the gs.setPosition(pos)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt; method. I ensured this doesn&amp;#39;t repeat previous pairs.&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;source_file&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;APIv1.md&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h6 id="sanitized-gold-entry"&gt;Sanitized &amp;ldquo;gold&amp;rdquo; entry&lt;/h6&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;instruction&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;How do I set the camera position?&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;output&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;Use the gs.setPosition(pos) method.&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;source_file&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;api.md&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The sanitizer also had to deal with Javadoc remnants. Since Gaia Sky is a Java project, class- and method-level comment blocks are full of HTML tags like &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;ul&amp;gt;&lt;/code&gt;, and &lt;code&gt;&amp;lt;li&amp;gt;&lt;/code&gt; (Javadoc syntax). The script converts these into clean Markdown so the LLM learns a consistent documentation style.&lt;/p&gt;
&lt;p&gt;By the end of this process, I had &lt;code&gt;gaiasky-gold.jsonl&lt;/code&gt;. This contains a curated, clean list of questions and answers based on the whole project documentation.&lt;/p&gt;
&lt;h3 id="api-dataset"&gt;API dataset&lt;/h3&gt;
&lt;p&gt;Documentation is important, but I want the model to learn some Gaia Sky scripting as well. To do so, a new API/scripting dataset needed to be generated. To solve this, I built a synthetic data factory designed to teach the model both the &lt;em&gt;content&lt;/em&gt; of the API and the &lt;em&gt;context&lt;/em&gt; of how to use it.&lt;/p&gt;
&lt;p&gt;The first step was grounding the model. I wrote a script (&lt;code&gt;api-extract.py&lt;/code&gt;) that scans the Java source files and uses Regex to pair Javadoc comments with their corresponding method signatures. The &lt;code&gt;extract_from_file()&lt;/code&gt; method uses regular expressions and a little bit of logic to generate Q&amp;amp;A pairs based on method signatures and their Javadoc documentation:&lt;/p&gt;
&lt;details class="collapsedcode"&gt;
&lt;summary&gt;
&lt;span class="code"&gt;Code: &lt;/span&gt;&lt;span class="summary"&gt;Extract API Q&amp;amp;A pairs from a file&lt;/span&gt;
&lt;/summary&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-Python" data-lang="Python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;def&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;extract_from_file&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;file_path&lt;/span&gt;):
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;&amp;#34;&amp;#34;Extracts Javadoc blocks and method signatures from a single Java file.&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#ff636f"&gt;not&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;os&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;path&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;exists&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;file_path&lt;/span&gt;):
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;print&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;f&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;Skipping: &lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;file_path&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt; (not found)&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;return&lt;/span&gt; []
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;with&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;open&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;file_path&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;r&amp;#39;&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;encoding&lt;/span&gt;&lt;span style="color:#ff636f"&gt;=&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;utf-8&amp;#39;&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;as&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;f&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;content&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;f&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;read&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;# Regex: Matches Javadoc and the following method signature&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;pattern&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;re&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;compile&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;r&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;/\*\*(.*?)\*/\s+([^;{]+)[;{]&amp;#39;&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;re&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;DOTALL&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;extracted&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; []
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;for&lt;/span&gt; &lt;span style="color:#ff636f"&gt;match&lt;/span&gt; &lt;span style="color:#ff636f"&gt;in&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;pattern&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;finditer&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;content&lt;/span&gt;):
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;doc&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;match&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;group&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;)&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;strip&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;# Clean Javadoc: remove leading stars and excess whitespace&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;doc&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;\n&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;join&lt;/span&gt;([&lt;span style="color:#58a1dd"&gt;line&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;strip&lt;/span&gt;()&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;lstrip&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;&amp;#39;*&amp;#39;&lt;/span&gt;)&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;strip&lt;/span&gt;() &lt;span style="color:#ff636f"&gt;for&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;line&lt;/span&gt; &lt;span style="color:#ff636f"&gt;in&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;doc&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;split&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;&amp;#39;&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;\n&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;line&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;strip&lt;/span&gt;()])
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;method_sig&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;match&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;group&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;2&lt;/span&gt;)&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;strip&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;# Clean method: remove annotations (like @Override) and newlines&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;method_sig&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;re&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;sub&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;r&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#39;@\w+(\(.*\))?&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;&amp;#39;&amp;#39;&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;method_sig&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;method_sig&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34; &amp;#34;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;join&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;method_sig&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;split&lt;/span&gt;())
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;extracted&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;append&lt;/span&gt;({
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;instruction&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;f&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;How do I use the &lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;method_sig&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;split&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;&amp;#39;(&amp;#39;&lt;/span&gt;)[&lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;]&lt;span style="color:#a6be9d"&gt;}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt; method in Gaia Sky?&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;output&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;f&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;The method `&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;method_sig&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;` is used for: &lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;doc&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;source_file&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;f&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;API_Reference/&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;os&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;path&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;basename&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;file_path&lt;/span&gt;)&lt;span style="color:#a6be9d"&gt;}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; })
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;return&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;extracted&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/details&gt;
&lt;p&gt;This process produces the &lt;code&gt;gaiasky-api-raw.jsonl&lt;/code&gt;, which is used in the next step. It contains the API calls with their respective documentation.&lt;/p&gt;
&lt;p&gt;However, knowing a function exists isn&amp;rsquo;t enough. The model needs to know how to script with it. To address this, I developed &lt;code&gt;generate-scripting-dataset.py&lt;/code&gt; to transform those raw Java signatures into a diverse pedagogical dataset. As input, it gets all test and showcase scripts in the Gaia Sky repository, and the raw API &lt;abbr title="JSON Lines"&gt;JSONL&lt;/abbr&gt; file. It produces four types of output, termed A, B, C, and D:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Type A:&lt;/strong&gt; The API reference&lt;/p&gt;
&lt;p&gt;These are direct &amp;ldquo;How do I use X?&amp;rdquo; pairs. They include the parameters, the return types, and a basic Python example.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Type B:&lt;/strong&gt; The task synthesis&lt;/p&gt;
&lt;p&gt;This step is optional, and I ended up not including it in the final dataset. However, I think it is still worth mentioning. I used the larger teacher dense model (27B) to generate complex tasks (e.g., &amp;ldquo;Write a script that navigates to Mars, waits 5 seconds, and takes a screenshot&amp;rdquo;). The script provided the teacher model with a safe list of real functions extracted in Step 1 as a sort of guardrail. If the teacher tried to hallucinate a command, the script flagged and discarded it.&lt;/p&gt;
&lt;p&gt;The results of this section were kind of underwhelming, possibly because more parameters are needed for such open-ended tasks.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Type C:&lt;/strong&gt; Adversarial error correction&lt;/p&gt;
&lt;p&gt;This is my favorite part. I programmatically &lt;em&gt;broke&lt;/em&gt; the API calls to teach the model how to fix its own mistakes. The script would generate a &lt;em&gt;wrong&lt;/em&gt; script (e.g., using &lt;code&gt;snake_case&lt;/code&gt; instead of &lt;code&gt;camelCase&lt;/code&gt; or missing a required argument) and then provide the &lt;em&gt;correct&lt;/em&gt; version. The end goal was to prevent common LLM failures before they happen.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Type D:&lt;/strong&gt; The &amp;ldquo;gold standard&amp;rdquo; Library&lt;/p&gt;
&lt;p&gt;Finally, I indexed the actual test and showcase scripts from the Gaia Sky repository. These are human-written, battle-tested scripts that show the model how to handle complex logic, loops, and math.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="identity"&gt;Identity&lt;/h3&gt;
&lt;p&gt;Finally, I prepared a small &lt;code&gt;identity.jsonl&lt;/code&gt; file with essential project information that &lt;strong&gt;must&lt;/strong&gt; appear in the final integrated training dataset. It only contains 17 lines of Q&amp;amp;A, but it is rather important. Here is an excerpt of a few lines (formatted for readability):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;instruction&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;Which institution is behind Gaia Sky?&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;output&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;Gaia Sky is developed at the Astronomisches Rechen-Institut (ARI),
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt; which is part of the Zentrum für Astronomie (ZAH) at Heidelberg University.&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;source_file&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;Identity/Project_Meta&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;instruction&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;Is Gaia Sky part of the ESA Gaia Mission?&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;output&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;Yes, Gaia Sky is developed within the framework of ESA&amp;#39;s Gaia mission,
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt; specifically as part of the German contribution to the Gaia Data Processing
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt; and Analysis Consortium (DPAC).&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;source_file&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;Identity/Project_Meta&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;instruction&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;What is the official repository for Gaia Sky?&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;output&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;The official repository is hosted on Codeberg
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt; at https://codeberg.org/gaiasky/gaiasky.&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;&amp;#34;source_file&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6be9d"&gt;&amp;#34;Identity/Project_Meta&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The final dataset was composed by concatenating the three parts, documentation, API, and identity. It can be explored here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://hf.co/datasets/Langurmonkey/gaiasky-training-dataset"&gt;gaiasky-training-dataset@HuggingFace&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Once the dataset was ready, it was time for the actual fine-tuning.&lt;/p&gt;
&lt;h2 id="fine-tuning"&gt;Fine-tuning&lt;/h2&gt;
&lt;p&gt;With a dataset of 3,800+ specialized Gaia Sky pairs ready, it was time for the actual training. For this, I leaned on two heavy hitters in the open-source world: &lt;strong&gt;Unsloth&lt;/strong&gt; and &lt;strong&gt;Qwen 3.5&lt;/strong&gt;. I started by training the 4B model, and then realized that I could also fit the 9B one in my GPU. In the post I&amp;rsquo;ll focus on the larger version of the model. I went as high as my local hardware allowed. Otherwise, I would have tried the 27B model, or even the 122B-A10B.&lt;/p&gt;
&lt;p&gt;Training a model with 9 billion parameters typically requires a massive server cluster, but by using &lt;strong&gt;4-bit &lt;abbr title="Low-Rank Adaptation"&gt;LoRA&lt;/abbr&gt;&lt;/strong&gt;&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;, I was able to squeeze the entire process onto a single &lt;strong&gt;RTX 5080 (16GB)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The RTX 5080 is a beast, but to get the most out of it, I enabled &lt;strong&gt;TensorFloat-32 (TF32)&lt;/strong&gt;. This allows the GPU to handle the heavy matrix multiplications of deep learning much faster than standard &lt;code&gt;float32&lt;/code&gt;, without the precision loss of &lt;code&gt;float16&lt;/code&gt;.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;# blackwell-specific optimizations&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;torch&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;backends&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;cuda&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;matmul&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;allow_tf32&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;True&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;torch&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;backends&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;cudnn&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;allow_tf32&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;True&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I used the following parameters for the fine-tuning:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;LoRA Rank:&lt;/strong&gt; 32&lt;/p&gt;
&lt;p&gt;A balance between learning new patterns (like the Gaia Sky API) and retaining general knowledge.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Target Modules:&lt;/strong&gt; &lt;code&gt;q, k, v, o, gate, up, down&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;All major projection layers)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;BF16:&lt;/strong&gt; Yes&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Learning rate:&lt;/strong&gt; \(2.0\times10^{-4}\)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Epochs:&lt;/strong&gt; 1&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Optimizer:&lt;/strong&gt; AdamW 8-bit&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The dataset is downloaded directly from the hub. It gets tokenized properly and passed into the &lt;abbr title="Supervised Fine-Tuning"&gt;SFT&lt;/abbr&gt; Trainer object for fine-tuning. The full code is in the file &lt;code&gt;finetune.py&lt;/code&gt;, and is surprisingly compact. The heavy lifting is done by &lt;code&gt;unsloth&lt;/code&gt; and &lt;code&gt;torch&lt;/code&gt;, of course.&lt;/p&gt;
&lt;p&gt;Once the LoRA weights are trained, they are dead weight until converted into a format people could actually use. I wrote &lt;code&gt;push-model.py&lt;/code&gt; to automate the most tedious part of the pipeline:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Quantization:&lt;/strong&gt; Converting the model to &lt;strong&gt;Q4_K_M GGUF&lt;/strong&gt;, or whatever other quant. This reduces the model size enough that it can run on almost any modern laptop while keeping its capabilities mostly intact.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HF upload:&lt;/strong&gt; Automatically pushing the finished &lt;code&gt;.gguf&lt;/code&gt; file to HuggingFace so the community can pull it directly into LM Studio or Ollama.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;After roughly an hour of training, and another one of quantizing and uploading, I had a fine-tuned &lt;strong&gt;Gaia Sky expert&lt;/strong&gt; that performs great. Or did I? The next section discusses the performance of the model.&lt;/p&gt;
&lt;h2 id="testing"&gt;Testing&lt;/h2&gt;
&lt;p&gt;Given the limited time I have and the low-parameter models used, my tests have been scarce. My expectations are not very high, but I still wanted to test the output of the fine-tuning and see how well the new knowledge was retained by the model. I only tested the Qwen 3.5 9B fine-tuned variant and compared it to the base model. You can get this model from HuggingFace:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/Langurmonkey/gaiasky-qwen-3.5-gguf"&gt;gaiasky-qwen-3.5-gguf@HuggingFace&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I used the same questions as in the &lt;a href="https://tonisagrista.com/blog/2025/gaiasky-ai-assistant"&gt;Gaia Sky AI assistant post&lt;/a&gt; from 2025. My evaluation of the results is totally subjective, and includes a comparison with the base Qwen 3.5 9B.&lt;/p&gt;
&lt;p&gt;First, I recommend using a low temperature setting (&lt;strong&gt;0.2&lt;/strong&gt; in my tests) to avoid hallucinations and make sure the model does not get very &amp;ldquo;creative&amp;rdquo;. Also, I would suggest using the following system prompt, which frames the conversation and sets the tone:&lt;/p&gt;
&lt;div class="container-block"&gt;
&lt;p&gt;You are the Gaia Sky AI Expert, a specialized assistant with deep knowledge of the Gaia Sky universe, its Python API (v1 and v2), and its Java source code. Your goal is to help users explore the cosmos, write scripts, and understand the engine&amp;rsquo;s inner workings.&lt;/p&gt;
&lt;h3 id="knowledge--capabilities"&gt;Knowledge &amp;amp; Capabilities:&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;API Expertise: You know both the Legacy API (v1) and the Modern Component API (v2: camera, time, data, scene, graphics, camcorder, etc.). Prioritize the Modern API (v2) for new scripts unless Legacy is requested. Never invent method names, be 100% sure, otherwise suggest the user to look up the docs.&lt;/li&gt;
&lt;li&gt;Source Code: You understand the underlying Java architecture (libGDX, Ashley ECS, LWJGL3, OpenGL) and can explain how features like LOD, shaders, and data loaders are implemented.&lt;/li&gt;
&lt;li&gt;Documentation: You are familiar with the Gaia Sky capabilities, installation guides, and dataset management.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="behavioral-guidelines"&gt;Behavioral Guidelines:&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Be Grounded: If a feature or method does not exist in your training data, do not invent it. State: &amp;ldquo;I don&amp;rsquo;t see that specific feature in the current Gaia Sky implementation.&amp;rdquo;&lt;/li&gt;
&lt;li&gt;Code Style: Always provide clean, commented Python or Java code. Use snake_case for Modern API calls.&lt;/li&gt;
&lt;li&gt;Tone: Professional, scientific, and enthusiastic about astrovisualization. Answer greetings warmly, then offer Gaia Sky-specific assistance.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p&gt;That said, let&amp;rsquo;s go for the first question.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Q: State the possible usages of Gaia Sky.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;A: Gaia Sky is a powerful open-source universe explorer and planetarium software designed for both interactive exploration and scientific visualization. Its primary use cases include:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Interactive Exploration&lt;/strong&gt;: Navigate the Milky Way, explore exoplanets, view the solar system in high detail, and time-travel through cosmic history using an intuitive interface.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scientific Visualization&lt;/strong&gt;: Visualize complex astrophysical datasets (catalogs, simulations) with advanced rendering techniques like LOD (Level of Detail), shaders, and particle effects.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Education &amp;amp; Outreach&lt;/strong&gt;: Create immersive presentations or tutorials to teach astronomy concepts through dynamic 3D models of celestial objects.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scripting &amp;amp; Automation&lt;/strong&gt;: Automate camera paths, data loading, and scene manipulation via Python (Modern API v2) or Java for reproducible workflows and research presentations.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data Analysis&lt;/strong&gt;: Load and manipulate large-scale astronomical datasets directly within the application to analyze spatial distributions without external tools.&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is an excellent response to an easy question. It did not invent anything and correctly applied documentation knowledge like camera paths or LOD. Compared to base Qwen, which hallucinated a lot, this is much more grounded.&lt;/p&gt;
&lt;p&gt;Next is a question about the installation procedure on Linux.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Q: How do I install Gaia Sky on Linux?&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;A: To install Gaia Sky on Linux, follow these steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Download the Installer&lt;/strong&gt;&lt;br&gt;
Visit &lt;a href="https://gaiasky.space/download"&gt;https://gaiasky.space/download&lt;/a&gt; and download the &lt;code&gt;.deb&lt;/code&gt; package for your architecture (e.g., &lt;code&gt;gaiasky_3.6.9_amd64.deb&lt;/code&gt;).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Install via Package Manager&lt;/strong&gt;&lt;br&gt;
Open a terminal and run:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;sudo dpkg -i gaiasky_*.deb
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If dependency errors occur, fix them with:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;sudo apt-get install -f
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Launch Gaia Sky&lt;/strong&gt;&lt;br&gt;
Run the command:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gaiasky
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The application will start in your default browser or terminal window.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is not bad. I did not specify the distribution, so it just assumed Debian. The instructions themselves look fine. Still, I wanted to see if it had absorbed the knowledge for other distros, so I asked specifically for Arch Linux:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Q: How do I install it on Arch Linux specifically?&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;A: To install Gaia Sky on &lt;strong&gt;Arch Linux&lt;/strong&gt;, you have two primary options depending on whether you prefer the official repositories or building from source:&lt;/p&gt;
&lt;h4 id="option-1-using-the-official-aur-recommended"&gt;Option 1: Using the Official AUR (Recommended)&lt;/h4&gt;
&lt;p&gt;The most straightforward method is via the &lt;a href="https://aur.archlinux.org/"&gt;Arch User Repository (AUR)&lt;/a&gt;. You will need an AUR helper like &lt;code&gt;yay&lt;/code&gt;, &lt;code&gt;paru&lt;/code&gt;, or &lt;code&gt;pamac&lt;/code&gt;.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Install via AUR Helper&lt;/strong&gt;:
Open your terminal and run:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;yay -S gaiasky
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;# OR using paru&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;paru -S gaiasky
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This will automatically resolve dependencies (Java, OpenGL libraries) and install the application.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Verify Installation&lt;/strong&gt;:
Once installed, launch it with:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gaiasky
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id="option-2-building-from-source"&gt;Option 2: Building from Source&lt;/h4&gt;
&lt;p&gt;If you prefer to compile Gaia Sky yourself or need a specific version not yet in the AUR:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Clone the Repository&lt;/strong&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;git clone https://codeberg.org/gaiasky/gaiasky.git
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;cd&lt;/span&gt; gaiasky
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Build and Install&lt;/strong&gt;:
Use Maven to build the project (requires Java Development Kit):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;mvn clean package -DskipTests
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;After building, you can run it directly from the &lt;code&gt;target&lt;/code&gt; directory or install it using the provided Gradle wrapper if available in newer versions.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id="post-installation-notes-for-arch"&gt;Post-Installation Notes for Arch&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dependencies&lt;/strong&gt;: Ensure your system has up-to-date OpenGL drivers and Java (JDK 17+ is recommended). The AUR package typically handles these automatically.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data Location&lt;/strong&gt;: By default, datasets are stored in &lt;code&gt;~/.local/share/gaiasky/&lt;/code&gt;. You may want to create symbolic links from &lt;code&gt;/opt/gaiasky/data&lt;/code&gt; if you prefer system-wide data management.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="additional-notes"&gt;Additional Notes&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Ensure you have at least 4GB of free disk space for datasets and cache files.&lt;/li&gt;
&lt;li&gt;For GPU acceleration, verify that your graphics drivers support OpenGL 4.3+.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Very good response. It recommends using the AUR package (correct), and also provides instructions to build and run it from source (also correct). However, it hallucinates the use of Maven instead of Gradle.&lt;/p&gt;
&lt;p&gt;The base model produced a long wall of text with mostly nonsense that would look plausible to the non-expert. In general, the responses from the base model were much longer and their content was very often factually wrong.&lt;/p&gt;
&lt;p&gt;The next question is a bit more technical.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Q: How are cubemap textures used in Gaia Sky?&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;A: Cubemaps in Gaia Sky serve a specific purpose related to &lt;strong&gt;planetary rendering&lt;/strong&gt; rather than general environment mapping for the entire scene. They are primarily utilized to generate high-resolution, seamless spherical textures for planets (like Earth or Mars) by stitching together six square faces into a continuous surface. This technique allows for detailed texture representation without the distortion often seen in standard equirectangular projections near poles.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key Technical Details:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Application&lt;/strong&gt;: Used specifically for planet textures where high fidelity is required across all latitudes and longitudes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Structure&lt;/strong&gt;: Consists of six square images (front, back, left, right, top, bottom) that are algorithmically mapped onto a sphere.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rendering Engine&lt;/strong&gt;: The underlying rendering engine uses these cubemap textures to apply surface details dynamically as the planet rotates or changes perspective.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This feature is distinct from standard environment maps used for sky reflections or star glow effects, focusing instead on accurate planetary visualization.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is excellent, even though it seems to imply that cubemaps are not used for environment maps and reflections, which is incorrect. However, it correctly identifies that they are mainly used to avoid distortions at the poles. Maybe it increased the importance of it because there is an entire documentation section devoted to cubemap textures in planets.&lt;/p&gt;
&lt;p&gt;The base model produced a long, Markdown-formatted, pretentious output that even included totally made-up scripts. Again, the fine-tuned response was much more grounded and correct.&lt;/p&gt;
&lt;p&gt;Now, onto the scripting questions. Here is where the fine-tuned model starts to hesitate and invent slightly. Here are the possible reasons:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Low parameter count not being able to retain the information properly.&lt;/li&gt;
&lt;li&gt;Very low number of test and showcase scripts used in fine-tuning.&lt;/li&gt;
&lt;li&gt;Bad design of the training dataset.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I personally think it has more to do with the first two reasons than the third.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Q: How do I get the camera position with the APIv1 and scripting in Gaia Sky?&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;A: To retrieve the current camera position using the &lt;strong&gt;Legacy API (v1)&lt;/strong&gt;, you call &lt;code&gt;getPosition()&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;from&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;py4j.java_gateway&lt;/span&gt; &lt;span style="color:#ff636f"&gt;import&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;JavaGateway&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;gateway&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;JavaGateway&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;gs&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;gateway&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;entry_point&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;pos&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;gs&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;getPosition&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;print&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;pos&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Output:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;[0.0, 0.0, 0.0]
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;hr&gt;
&lt;p&gt;For the &lt;strong&gt;Modern Component API (v2)&lt;/strong&gt;, you use &lt;code&gt;camera.get_position()&lt;/code&gt; from the &lt;code&gt;Camera&lt;/code&gt; module:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;from&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;py4j.java_gateway&lt;/span&gt; &lt;span style="color:#ff636f"&gt;import&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;JavaGateway&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;gateway&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;JavaGateway&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;gs&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;gateway&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;entry_point&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;from&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;gaiasky.camera&lt;/span&gt; &lt;span style="color:#ff636f"&gt;import&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Camera&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;cam&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Camera&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;gs&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;pos&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;cam&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;get_position&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;print&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;pos&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Output:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;[0.0, 0.0, 0.0]
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/blockquote&gt;
&lt;p&gt;First, it invented &lt;code&gt;getPosition()&lt;/code&gt; (correct is &lt;code&gt;getCameraPosition()&lt;/code&gt;) for APIv1. For APIv2, it correctly used &lt;code&gt;camera.get_position()&lt;/code&gt;, but the way it gets the &lt;code&gt;camera&lt;/code&gt; object is incorrect. It should have done something like&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;apiv2&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;gateway&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;entry_point&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;apiv2&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;cam&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;apiv2&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;camera&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;pos&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;cam&lt;/span&gt;&lt;span style="color:#ff636f"&gt;.&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;get_position&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So, I think either this model is too small to be effectively fine-tuned for Gaia Sky scripting, or the training dataset is insufficient to achieve sufficient retention. Could also be both, as I said above.&lt;/p&gt;
&lt;p&gt;The base model has no idea about Gaia Sky scripting or anything related to it, so it just makes stuff up. Not even worth further mention.&lt;/p&gt;
&lt;h2 id="performance"&gt;Performance&lt;/h2&gt;
&lt;p&gt;I used the &lt;a href="#laptop2"&gt;Laptop 2&lt;/a&gt; described above for testing and inference, with 28/32 layers on the GPU and a context window of ~4k, and I consistently got about 12 tok/s. Performance is exactly the same as with the base models, so this section is this short.&lt;/p&gt;
&lt;h2 id="conclusions"&gt;Conclusions&lt;/h2&gt;
&lt;p&gt;This fine-tuning experiment has yielded valuable insights into the strengths and limitations of domain-specific model adaptation at lower parameter counts for local use.&lt;/p&gt;
&lt;p&gt;I think the foundational approach was sound. The dataset curation process, with its surgical filtering, teacher-based distillation, and rigorous sanitization, successfully encoded domain knowledge into the model. Proof of this is evident in the testing: the fine-tuned model, as opposed to the base one, correctly answered conceptual and documentation-heavy questions about Gaia Sky&amp;rsquo;s purpose, installation, and rendering techniques without hallucinating. It understood architectural details like LOD and cubemaps, and avoided inventing features that don&amp;rsquo;t exist. This demonstrates that fine-tuning can be an effective alternative to RAG for teaching models about a specific domain.&lt;/p&gt;
&lt;p&gt;However, it also struggled. The 9B model hit a hard ceiling when it came to API scripting and method names. It invented &lt;code&gt;getPosition()&lt;/code&gt; instead of &lt;code&gt;getCameraPosition()&lt;/code&gt;, misunderstood how to instantiate APIv2 objects, and generally lacked the capacity to reliably retain the specific, syntactic details of the API surface. This is a classic problem: smaller models can absorb &lt;em&gt;concepts&lt;/em&gt; and &lt;em&gt;documentation&lt;/em&gt;, but struggle to memorize exact function signatures and usage patterns. With only 3,800+ training pairs and a 9B parameter budget, the model simply didn&amp;rsquo;t have enough capacity to encode both general knowledge &lt;em&gt;and&lt;/em&gt; precise API details.&lt;/p&gt;
&lt;p&gt;So, what are the next steps? I believe the 4B and 9B models are too small for reliable Gaia Sky scripting assistance. My next experiment will be to &lt;strong&gt;fine-tune the Qwen 3.5 27B model&lt;/strong&gt;. The jump from 9B to 27B parameters should provide substantially more capacity to encode API signatures without sacrificing general knowledge. Additionally, I could increase the scripting dataset by:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Expanding the test and showcase scripts used in Type D training.&lt;/li&gt;
&lt;li&gt;Generating more synthetic scripting examples in Type A (API reference) with better coverage of edge cases and parameter variations.&lt;/li&gt;
&lt;li&gt;Adding adversarial examples (Type C) for the most commonly confused API patterns.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That said, the hardware constraint is real. 27B requires more than my RTX 5080 can reasonably handle for full fine-tuning. However, with careful quantization (using 8-bit optimizers or even lower precision), 4-bit LoRA, and possibly gradient checkpointing, it may fit. If not, a cloud provider like Lambda Labs or Paperspace might be the way forward for a single training run.&lt;/p&gt;
&lt;p&gt;All in all, I think fine-tuning is a viable path for building domain-expert models, but it requires the right balance of dataset quality, model size, and hardware. For Gaia Sky specifically, a 27B model with a more robust scripting dataset would likely be the sweet spot before considering the jump to 70B+ models. I consider the infrastructure proven. It&amp;rsquo;s now a matter of scale.&lt;/p&gt;
&lt;div class="footnotes" role="doc-endnotes"&gt;
&lt;hr&gt;
&lt;ol&gt;
&lt;li id="fn:1"&gt;
&lt;p&gt;Low-Rank Adaptation is a fine-tuning technique that enables large models to gain additional domain knowledge and adapt to specific tasks with minimal computational and memory overhead. Recommended reading: &lt;a href="https://arxiv.org/abs/2106.09685"&gt;LoRA: Low-Rank Adaptation of Large Language Models&lt;/a&gt;, by E. J. Hu et. al.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;</description></item><item><title>7Artisans EF-FX adapter review</title><link>https://tonisagrista.com/blog/2026/7artisans-ef-fx/</link><pubDate>Tue, 10 Mar 2026 00:00:00 +0000</pubDate><author>Toni Sagrista Selles</author><guid>https://tonisagrista.com/blog/2026/7artisans-ef-fx/</guid><description>&lt;p&gt;A while back I &lt;a href="https://tonisagrista.com/blog/2021/viltrox-ef-fx1-review"&gt;reviewed the Viltrox EF-FX1 adapter&lt;/a&gt;, my first attempt at bridging my old Canon EF and EF-S lenses over to my Fujifilm X-S10. The abridged version of that experience: it was frustrating. Firmware roulette, random errors, camera freezes requiring battery pulls, and auto focus performance that varied wildly between versions. I kept it because it was &lt;em&gt;just&lt;/em&gt; good enough, but it never stopped feeling like a workaround rather than a solution. Moreover, Viltrox never released any further firmware versions, so the 2.29 blob tested in that post really is the &lt;em&gt;last&lt;/em&gt; firmware.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;a href="https://tonisagrista.com/img/photography/ef-fx-7artisans.avif"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/photography/ef-fx-7artisans.avif" width="60%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;/a&gt;&lt;figcaption style="margin: 0 auto; width:60%;"&gt;
&lt;h4&gt;The 7Artisans EF-FX lens adapter. It is a good and cheap solution to use your old EF/EF-S lenses on your Fuji X-mount camera.&lt;/h4&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Fast forward to recently, and I decided to give the whole thing another shot. This time with the &lt;a href="https://7artisans.store/products/7artisans-ef-fx-lens-mount-adapter-with-auto-exposure-auto-focus-canon-ef-ef-s-lens-to-canon-eos-r-mirrorlescamera"&gt;&lt;strong&gt;7Artisans EF-FX adapter&lt;/strong&gt;&lt;/a&gt;. Same concept: an electronic adapter with auto focus support, aperture control, and EXIF data transmission, and at 119€ it sits right in the same price bracket as the Viltrox. But is it actually better? Spoiler: yes. Let me walk you through it.&lt;/p&gt;
&lt;h2 id="lenses-and-body"&gt;Lenses and body&lt;/h2&gt;
&lt;p&gt;Same test bench as last time, same lenses, same body — in the interest of a fair comparison:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Body&lt;/strong&gt;: Fujifilm X-S10&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lenses&lt;/strong&gt;:
&lt;ul&gt;
&lt;li&gt;Canon EF 28-105mm f/3.5-4.5 USM II&lt;/li&gt;
&lt;li&gt;Canon EF 50mm f/1.8 II&lt;/li&gt;
&lt;li&gt;Sigma 10-20mm f/4-5.6 EX DC HSM&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="build-quality"&gt;Build quality&lt;/h2&gt;
&lt;p&gt;The adapter feels solid. The frame is all metal and has a reassuringly substantial weight to it. It is not heavy enough to unbalance a lens, but enough to communicate that it is not going to crack if you look at it wrong. Canon lenses mount securely, the Fuji X end clicks into the body with no play whatsoever, and the whole assembly feels like a single unit once locked. There is no wobble, no flex, no creaking.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;a href="https://tonisagrista.com/img/photography/ef-fx-comparison-angle.avif"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/photography/ef-fx-comparison-angle.avif" width="60%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;/a&gt;&lt;figcaption style="margin: 0 auto; width:60%;"&gt;
&lt;h4&gt;The Viltrox EF-FX (left) and the 7Artisans EF-FX (right). They look and feel very similar from the outside.&lt;/h4&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;This one also has a Micro-USB port for firmware updates. Why is it using Micro-USB instead of USB-C in 2026? No fu***** idea. It is a bummer. The adapter itself is, however, better built than I expected at this price point.&lt;/p&gt;
&lt;h2 id="auto-focus-performance"&gt;Auto focus performance&lt;/h2&gt;
&lt;p&gt;This is where things get interesting, especially if you read the &lt;a href="https://tonisagrista.com/blog/2021/viltrox-ef-fx1-review"&gt;Viltrox review&lt;/a&gt; and remember the table of doom I put together there.&lt;/p&gt;
&lt;p&gt;With the 7Artisans adapter, I did not need a table. In good lighting conditions, auto focus with all three lenses is &lt;strong&gt;fast and reliable&lt;/strong&gt;. The Canon 50mm f/1.8 II in particular snaps to focus quickly and confidently. The difference compared to the Viltrox (on most firmware versions) is night and day. The 28-105mm, which was the most problematic lens with the Viltrox, also performs well: focus acquisition is smooth and I have not once seen the dreaded &amp;ldquo;TURN OFF THE CAMERA AND TURN ON AGAIN&amp;rdquo; message. Not once.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;a href="https://tonisagrista.com/img/photography/ef-fx-comparison.avif"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/photography/ef-fx-comparison.avif" width="60%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;/a&gt;&lt;figcaption style="margin: 0 auto; width:60%;"&gt;
&lt;h4&gt;The Viltrox EF-FX (left) and the 7Artisans EF-FX attached to the Canon 50mm f/1.8 II (right). The lens snaps into the adapter in an oddly satisfying way.&lt;/h4&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The Sigma 10-20mm is similarly well-behaved. At wide angles there is slightly more hunting in ambiguous scenes, but that is expected behaviour for any wide-angle lens and not something I&amp;rsquo;d pin on the adapter.&lt;/p&gt;
&lt;p&gt;The caveat is &lt;strong&gt;low light&lt;/strong&gt;. Drop the ambient light and things become noticeably more hit or miss. The adapter does not handle low-contrast, low-light scenes gracefully; focus will hunt, and sometimes give up altogether, or snap to a comically out-of-focus setting. This is not a dealbreaker for me, but if you shoot a lot of available-light indoor work or night photography with these lenses, temper your expectations accordingly. I know what to expect of these kind of cheap adapters, and this one is enough.&lt;/p&gt;
&lt;h2 id="no-firmware-archaeology-required"&gt;No firmware archaeology required&lt;/h2&gt;
&lt;p&gt;One of the most annoying aspects of the Viltrox experience was the firmware situation. Digging up old versions, flashing them one by one, and hoping for the best. I am happy to report that with the 7Artisans I simply mounted it and it worked. I updated to the latest firmware once, without incident, and have had no reason to go looking for alternatives. The update process was painless.&lt;/p&gt;
&lt;h2 id="aperture-control-and-exif"&gt;Aperture control and EXIF&lt;/h2&gt;
&lt;p&gt;Both work as expected. Aperture adjusts from the camera body across all three lenses, and EXIF data (focal length, aperture, lens identification) seem to be written correctly to files. Nothing surprising here, but it is worth confirming since these features were also present on the Viltrox. The adapter itself has no controls or buttons other than the lens release lever.&lt;/p&gt;
&lt;h2 id="wrap-up"&gt;Wrap-up&lt;/h2&gt;
&lt;p&gt;After the Viltrox experience, I was not sure a third-party EF-to-FX adapter could actually work well. I ordered the 7Artisans off Amazon fully ready to return it if it did not perform. However, it has changed my mind. It is not perfect (low light auto focus is genuinely unreliable), but in normal shooting conditions it is a solid, no-fuss solution that lets me keep using lenses I like on a camera I enjoy shooting with. There are no crashes, no firmware gymnastics, no error messages demanding I restart the camera mid-session.&lt;/p&gt;
&lt;p&gt;If you are coming from the Canon ecosystem and moving to Fuji X, and you want to bring your glass with you without spending much native-lens money, the 7Artisans EF-FX adapter is worth a serious look. For me, it is the upgrade the Viltrox never quite managed to be. Moreover, there is a world of old, cheap EF and EF-S lenses in second-hand markets that only get more attractive with time.&lt;/p&gt;</description></item><item><title>GGUF quantization guide</title><link>https://tonisagrista.com/blog/2026/quantization/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><author>Toni Sagristà Sellés</author><guid>https://tonisagrista.com/blog/2026/quantization/</guid><description>&lt;p&gt;I like running my own LLMs locally. Open models are becoming more and more powerful, with exciting releases like the latest Qwen 3.5 family scoring highly in benchmarks even in their smaller variants. This makes managing and running your own models more viable, as it becomes increasingly easy to repurpose old hardware for local inference with progressively better results. For local users and modest purposes, the GGUF format introduced by llama.cpp is the de-facto default.&lt;/p&gt;
&lt;p&gt;Since local inference is typically heavily restricted by the available hardware, several optimization techniques have been implemented to make the models leaner and faster. Perhaps the most important of these is &lt;strong&gt;quantization&lt;/strong&gt;, which trims down the bit count per parameter to achieve lower memory usage and (sometimes) faster inference&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;. The challenge is that there are many different formats and strategies for quantization. In this post, I summarize them, providing a bird&amp;rsquo;s-eye view on the available techniques, their strengths, and their weaknesses.&lt;/p&gt;
&lt;h2 id="naming-conventions"&gt;Naming conventions&lt;/h2&gt;
&lt;p&gt;Most GGUF quantization names follow this pattern: &lt;strong&gt;&lt;code&gt;Q{bits}{method}{size}&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Q&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Quantized format&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2/3/4/5/6/8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bits per weight (lower = smaller file, more compression)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;K&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&amp;ldquo;K-quant&amp;rdquo;: grouped/blockwise quantization with per-group scales&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;I&lt;/code&gt; / &lt;code&gt;IQ&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&amp;ldquo;I-quant&amp;rdquo;: importance-matrix/non-linear quantization for aggressive compression&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt; / &lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Legacy ungrouped formats (symmetric/asymmetric)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_S&lt;/code&gt; / &lt;code&gt;_M&lt;/code&gt; / &lt;code&gt;_L&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Small/Medium/Large: precision mix across tensor types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_XS&lt;/code&gt; / &lt;code&gt;_XXS&lt;/code&gt; / &lt;code&gt;_NL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Extra-small / Non-linear variants for I-quants&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="unquantized-formats"&gt;Unquantized formats&lt;/h2&gt;
&lt;p&gt;These are base formats with no compression. Usually, but not always, models are trained in these formats.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Bits&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FP32&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;32-bit float&lt;/td&gt;
&lt;td&gt;Full precision, ~26GB for 7B model&lt;/td&gt;
&lt;td&gt;Research, debugging&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FP16&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;16-bit float&lt;/td&gt;
&lt;td&gt;Half precision, ~13GB for 7B model&lt;/td&gt;
&lt;td&gt;GPU training/inference baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BF16&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;16-bit brain float&lt;/td&gt;
&lt;td&gt;Same size as FP16, better dynamic range for training&lt;/td&gt;
&lt;td&gt;Training on modern GPUs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;These preserve maximum accuracy but require significant VRAM. Most local users quantize to reduce size by 75-90%.&lt;/p&gt;
&lt;h2 id="legacy-quantization-formats-q_0-q_1"&gt;Legacy quantization formats (&lt;code&gt;Q*_0&lt;/code&gt;, &lt;code&gt;Q*_1&lt;/code&gt;)&lt;/h2&gt;
&lt;p&gt;Simple per-block linear quantization. Fast but less accurate at low bits.&lt;/p&gt;
&lt;p&gt;Following is a table that contains the format name, number of bits, size (for a 7B model), perplexity, and some notes/recommendations. The &lt;strong&gt;perplexity&lt;/strong&gt; represents the difference between the quantized model and the base model, with lower scores indicating better accuracy and less uncertainty.&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Bits&lt;/th&gt;
&lt;th&gt;Size (7B)&lt;/th&gt;
&lt;th&gt;Perplexity \(\Delta\)&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Q8_0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~8-bit&lt;/td&gt;
&lt;td&gt;~6.7 GB&lt;/td&gt;
&lt;td&gt;+0.0004&lt;/td&gt;
&lt;td&gt;Near-lossless; safe INT8 baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Q5_1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~5-bit&lt;/td&gt;
&lt;td&gt;~4.7 GB&lt;/td&gt;
&lt;td&gt;+0.0415&lt;/td&gt;
&lt;td&gt;Legacy; superseded by Q5_K_M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Q5_0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~5-bit&lt;/td&gt;
&lt;td&gt;~4.3 GB&lt;/td&gt;
&lt;td&gt;+0.0796&lt;/td&gt;
&lt;td&gt;Legacy; balanced but outdated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Q4_1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~4-bit&lt;/td&gt;
&lt;td&gt;~3.9 GB&lt;/td&gt;
&lt;td&gt;+0.1846&lt;/td&gt;
&lt;td&gt;Legacy; substantial quality loss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Q4_0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~4-bit&lt;/td&gt;
&lt;td&gt;~3.5 GB&lt;/td&gt;
&lt;td&gt;+0.2499&lt;/td&gt;
&lt;td&gt;Legacy; high quality loss&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;K-quants or I-quants are generally preferred over legacy formats at \(\leq 5\) bits. Q8_0 remains useful for compatibility.&lt;/p&gt;
&lt;h2 id="k-quant-formats-modern-default"&gt;K-Quant formats (modern default)&lt;/h2&gt;
&lt;p&gt;Use two-level block quantization (small blocks \(\rightarrow\) super-blocks) with double-quantized scales for better quality-per-bit.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Effective Bits&lt;/th&gt;
&lt;th&gt;Size (7B)&lt;/th&gt;
&lt;th&gt;Perplexity \(\Delta\)&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Q6_K&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~6.0&lt;/td&gt;
&lt;td&gt;~5.15 GB&lt;/td&gt;
&lt;td&gt;+0.0044&lt;/td&gt;
&lt;td&gt;&amp;ldquo;Almost lossless&amp;rdquo; with savings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Q5_K_L&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~5.3&lt;/td&gt;
&lt;td&gt;~4.6 GB&lt;/td&gt;
&lt;td&gt;+0.010&lt;/td&gt;
&lt;td&gt;High-quality 5-bit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Q5_K_M&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~5.1&lt;/td&gt;
&lt;td&gt;~4.45 GB&lt;/td&gt;
&lt;td&gt;+0.0142&lt;/td&gt;
&lt;td&gt;Recommended high-quality 5-bit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Q5_K_S&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~4.9&lt;/td&gt;
&lt;td&gt;~4.33 GB&lt;/td&gt;
&lt;td&gt;+0.0353&lt;/td&gt;
&lt;td&gt;Recommended balanced 5-bit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Q4_K_L&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~4.7&lt;/td&gt;
&lt;td&gt;~4.0 GB&lt;/td&gt;
&lt;td&gt;+0.040&lt;/td&gt;
&lt;td&gt;Relaxed 4-bit mix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Q4_K_M&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~4.5&lt;/td&gt;
&lt;td&gt;~3.80 GB&lt;/td&gt;
&lt;td&gt;+0.0535&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Most popular default&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Q4_K_S&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~4.3&lt;/td&gt;
&lt;td&gt;~3.56 GB&lt;/td&gt;
&lt;td&gt;+0.1149&lt;/td&gt;
&lt;td&gt;Smaller 4-bit, more loss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Q3_K_L&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~3.5&lt;/td&gt;
&lt;td&gt;~3.35 GB&lt;/td&gt;
&lt;td&gt;+0.1803&lt;/td&gt;
&lt;td&gt;Aggressive 3-bit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Q3_K_M&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~3.3&lt;/td&gt;
&lt;td&gt;~3.06 GB&lt;/td&gt;
&lt;td&gt;+0.2437&lt;/td&gt;
&lt;td&gt;Balanced 3-bit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Q3_K_S&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~3.1&lt;/td&gt;
&lt;td&gt;~2.75 GB&lt;/td&gt;
&lt;td&gt;+0.5505&lt;/td&gt;
&lt;td&gt;Very small, high loss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Q2_K&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~2.5&lt;/td&gt;
&lt;td&gt;~2.67 GB&lt;/td&gt;
&lt;td&gt;+0.8698&lt;/td&gt;
&lt;td&gt;Extreme compression, not recommended&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;Q4_K_M&lt;/code&gt;&lt;/strong&gt; seems to be the community sweet spot, as it offers \(\sim 75\%\) size reduction with minimal noticeable quality loss for most tasks.&lt;/p&gt;
&lt;h2 id="i-quant-formats-aggressive-compression"&gt;I-Quant formats (aggressive compression)&lt;/h2&gt;
&lt;p&gt;IQs use non-linear reconstruction, lookup tables, and importance-matrix calibration for maximum quality at very low bit counts. They trade decoding speed for size.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Effective Bits&lt;/th&gt;
&lt;th&gt;Size (7B)&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IQ4_NL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~4.5&lt;/td&gt;
&lt;td&gt;~3.9 GB&lt;/td&gt;
&lt;td&gt;Non-linear 4-bit; CPU-friendly speed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IQ4_XS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~4.25&lt;/td&gt;
&lt;td&gt;~3.7 GB&lt;/td&gt;
&lt;td&gt;Best quality/size at 4-bit; slightly slower decode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IQ3_M&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~3.6&lt;/td&gt;
&lt;td&gt;~3.2 GB&lt;/td&gt;
&lt;td&gt;High-quality 3-bit I-quant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IQ3_S&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~3.4&lt;/td&gt;
&lt;td&gt;~3.0 GB&lt;/td&gt;
&lt;td&gt;Balanced 3-bit aggressive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IQ3_XS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~3.2&lt;/td&gt;
&lt;td&gt;~2.8 GB&lt;/td&gt;
&lt;td&gt;Very small 3-bit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IQ3_XXS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~3.0&lt;/td&gt;
&lt;td&gt;~2.6 GB&lt;/td&gt;
&lt;td&gt;Extreme 3-bit compression&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IQ2_M&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~2.7&lt;/td&gt;
&lt;td&gt;~2.4 GB&lt;/td&gt;
&lt;td&gt;High-quality 2-bit (rare)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IQ2_S&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~2.5&lt;/td&gt;
&lt;td&gt;~2.2 GB&lt;/td&gt;
&lt;td&gt;Aggressive 2-bit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IQ2_XS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~2.3&lt;/td&gt;
&lt;td&gt;~2.0 GB&lt;/td&gt;
&lt;td&gt;Very aggressive 2-bit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IQ2_XXS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~2.1&lt;/td&gt;
&lt;td&gt;~1.9 GB&lt;/td&gt;
&lt;td&gt;Extreme; significant quality loss&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;I-quants require &lt;strong&gt;importance matrix (imatrix) calibration&lt;/strong&gt; during quantization for best results. Without it, quality can degrade noticeably.&lt;/p&gt;
&lt;h2 id="special-formats"&gt;Special formats&lt;/h2&gt;
&lt;p&gt;There are even more aggressive quantization methods, like ternary quantization, which converts weights to ternary values.&lt;sup id="fnref:3"&gt;&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref"&gt;3&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Bits&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TQ1_0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~1.6&lt;/td&gt;
&lt;td&gt;Ternary quantization \(\{-1, 0, +1\}\), for massive models like DeepSeek where fitting in VRAM is critical&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="decision-guide"&gt;Decision guide&lt;/h2&gt;
&lt;p&gt;Below I attempt to provide some general rules as to what quant to choose in certain situations.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Best balance overall \(\rightarrow\) &lt;code&gt;Q4_K_M&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Max quality, still compressed \(\rightarrow\) &lt;code&gt;Q5_K_M&lt;/code&gt; or &lt;code&gt;Q6_K&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Tight VRAM, acceptable quality loss \(\rightarrow\) &lt;code&gt;Q4_K_S&lt;/code&gt; or &lt;code&gt;IQ4_XS&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Max compression \(\rightarrow\) &lt;code&gt;IQ3_XS&lt;/code&gt; or &lt;code&gt;Q3_K_S&lt;/code&gt; \(\Delta\) &lt;span class="red"&gt;test quality!&lt;/span&gt;
&lt;/li&gt;
&lt;li&gt;Near-lossless accuracy \(\rightarrow\) &lt;code&gt;Q8_0&lt;/code&gt; or keep &lt;code&gt;FP16&lt;/code&gt;/&lt;code&gt;BF16&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;CPU inference \(\rightarrow\) &lt;code&gt;Q4_K_M&lt;/code&gt; or &lt;code&gt;IQ4_NL&lt;/code&gt; \(\because\) &lt;span class="orange"&gt;better decode speed&lt;/span&gt;
&lt;/li&gt;
&lt;li&gt;Fit a ~50B model on 16GB VRAM \(\rightarrow\) &lt;code&gt;IQ3_S&lt;/code&gt; or &lt;code&gt;Q3_K_M&lt;/code&gt; \(+\) CPU offload&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="conclusions"&gt;Conclusions&lt;/h2&gt;
&lt;p&gt;The set of quantization methods available may look like the wild west at first glance, but this mess is not without some order. There are always good reasons behind every quantization type. Here are some takeaways:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Bits \(\neq\) quality alone&lt;/strong&gt;: A well-designed 4-bit format (&lt;code&gt;Q4_K_M&lt;/code&gt;, &lt;code&gt;IQ4_XS&lt;/code&gt;) can outperform a naive 5-bit legacy format.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Suffixes matter&lt;/strong&gt;: &lt;code&gt;_M&lt;/code&gt; variants selectively keep sensitive layers at higher precision, improving quality with minimal size cost.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hardware matters&lt;/strong&gt;: I-quants compress more but decode slower on CPUs; K-quants often give better tokens/sec on consumer hardware.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Calibration helps&lt;/strong&gt;: Models quantized with an importance matrix (imatrix) retain more accuracy, especially at \(\leq 3\) bits.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Test your use case&lt;/strong&gt;: Perplexity benchmarks are only guides. Always validate outputs for your specific tasks.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For the latest format support and benchmarks, check the &lt;a href="https://github.com/ggerganov/llama.cpp"&gt;llama.cpp repository&lt;/a&gt; or community hubs like Hugging Face, where curators like &lt;a href="https://huggingface.co/bartowski"&gt;bartowski&lt;/a&gt; and &lt;a href="https://huggingface.co/unsloth"&gt;Unsloth&lt;/a&gt; publish tested GGUF variants.&lt;/p&gt;
&lt;div class="footnotes" role="doc-endnotes"&gt;
&lt;hr&gt;
&lt;ol&gt;
&lt;li id="fn:1"&gt;
&lt;p&gt;For an exceptionally good (visual) guide as to how quantization is actually performed, I recommend reading this &lt;em&gt;ngrok&lt;/em&gt; blog post: &lt;a href="https://ngrok.com/blog/quantization"&gt;Quantization from the ground up&lt;/a&gt;.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:2"&gt;
&lt;p&gt;A good technical explanation of perplexity is in &lt;a href="https://ngrok.com/blog/quantization#perplexity"&gt;this section of this &lt;em&gt;ngrok&lt;/em&gt; post&lt;/a&gt;.&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:3"&gt;
&lt;p&gt;&lt;em&gt;C. Zhu and S. Han and H. Mao and W. J. Dally&lt;/em&gt;. &amp;ldquo;Trained Ternary Quantization.&amp;rdquo; &lt;em&gt;arXiv preprint&lt;/em&gt; &lt;a href="https://arxiv.org/abs/1612.01064"&gt;arXiv:1612.01064&lt;/a&gt; (2016).&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;</description></item><item><title>LM Studio on systemd linger</title><link>https://tonisagrista.com/blog/2026/lmster-service/</link><pubDate>Fri, 27 Feb 2026 00:00:00 +0000</pubDate><author>Toni Sagristà Sellés</author><guid>https://tonisagrista.com/blog/2026/lmster-service/</guid><description>&lt;p&gt;The release of &lt;strong&gt;LM Studio 0.4.5&lt;/strong&gt; has introduced a much needed feature in this local LLM suite that has it much more attractive with respect to other similar projects. &lt;a href="https://lmstudio.ai/docs/lmlink"&gt;LM Link&lt;/a&gt; allows you to connect multiple LM Studio instances across your network to share models and perform inference seamlessly.&lt;/p&gt;
&lt;p&gt;By sheer chance, I was just playing around with setting up an LM Studio server in an old laptop that I planned to use for inference. I would connect AnythingLLM clients to it to make API requests. The timing of 0.4.5 was perfect for me, as I could now use LM Studio for inference directly, and forget about using up my own Tailscale network. But some setup was needed in the laptop. To make this work effectively, the LM Studio server needs to run in the background, start automatically on boot, and persist even when I&amp;rsquo;m not logged in.&lt;/p&gt;
&lt;p&gt;The LM Studio website provides the source of a &lt;a href="https://lmstudio.ai/docs/developer/core/headless_llmster#create-systemd-service"&gt;service file&lt;/a&gt;. It suggests creating it as a system-wide service, which is weird, as the default installation method (at least on Linux) sets everything up in the user home directory. I modified it a bit to make things clean, as I want this to be a user &lt;code&gt;systemd&lt;/code&gt; service. It keeps the process tied to your user environment but, with a little tweak called lingering, allows it to run without an active SSH or GUI session. Here is the setup.&lt;/p&gt;
&lt;p&gt;By default, user services stop the moment the user logs out. To prevent this and allow the LM Studio daemon to start at boot and stay alive, run:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;loginctl enable-linger &lt;span style="color:#58a1dd"&gt;$USERNAME&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then, create a directory for your user services if it doesn&amp;rsquo;t exist:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;mkdir -p ~/.config/systemd/user/
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;After that, create a file named &lt;code&gt;lms.service&lt;/code&gt; in that directory (&lt;code&gt;~/.config/systemd/user/lms.service&lt;/code&gt;), with the following contents:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;[&lt;span style="color:#58a1dd"&gt;Unit&lt;/span&gt;]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;Description&lt;/span&gt;=&lt;span style="color:#58a1dd"&gt;LM&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Studio&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Server&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;After&lt;/span&gt;=&lt;span style="color:#58a1dd"&gt;network&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;target&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;[&lt;span style="color:#58a1dd"&gt;Service&lt;/span&gt;]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;Type&lt;/span&gt;=&lt;span style="color:#58a1dd"&gt;forking&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;# This line kills any existing lms processes to prevent the &amp;#34;left-over&amp;#34; error&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;ExecStartPre&lt;/span&gt;=&lt;span style="color:#58a1dd"&gt;-&lt;/span&gt;/&lt;span style="color:#58a1dd"&gt;home&lt;/span&gt;/$&lt;span style="color:#58a1dd"&gt;USERNAME&lt;/span&gt;/.&lt;span style="color:#58a1dd"&gt;lmstudio&lt;/span&gt;/&lt;span style="color:#58a1dd"&gt;bin&lt;/span&gt;/&lt;span style="color:#58a1dd"&gt;lms&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;daemon&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;down&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;ExecStartPre&lt;/span&gt;=/&lt;span style="color:#58a1dd"&gt;home&lt;/span&gt;/$&lt;span style="color:#58a1dd"&gt;USERNAME&lt;/span&gt;/.&lt;span style="color:#58a1dd"&gt;lmstudio&lt;/span&gt;/&lt;span style="color:#58a1dd"&gt;bin&lt;/span&gt;/&lt;span style="color:#58a1dd"&gt;lms&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;daemon&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;up&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;ExecStart&lt;/span&gt;=/&lt;span style="color:#58a1dd"&gt;home&lt;/span&gt;/$&lt;span style="color:#58a1dd"&gt;USERNAME&lt;/span&gt;/.&lt;span style="color:#58a1dd"&gt;lmstudio&lt;/span&gt;/&lt;span style="color:#58a1dd"&gt;bin&lt;/span&gt;/&lt;span style="color:#58a1dd"&gt;lms&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;server&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;start&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;# Give it a specific PID file if lms supports it, otherwise systemd guesses&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;ExecStop&lt;/span&gt;=/&lt;span style="color:#58a1dd"&gt;home&lt;/span&gt;/$&lt;span style="color:#58a1dd"&gt;USERNAME&lt;/span&gt;/.&lt;span style="color:#58a1dd"&gt;lmstudio&lt;/span&gt;/&lt;span style="color:#58a1dd"&gt;bin&lt;/span&gt;/&lt;span style="color:#58a1dd"&gt;lms&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;server&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;stop&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;ExecStopPost&lt;/span&gt;=/&lt;span style="color:#58a1dd"&gt;home&lt;/span&gt;/$&lt;span style="color:#58a1dd"&gt;USERNAME&lt;/span&gt;/.&lt;span style="color:#58a1dd"&gt;lmstudio&lt;/span&gt;/&lt;span style="color:#58a1dd"&gt;bin&lt;/span&gt;/&lt;span style="color:#58a1dd"&gt;lms&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;daemon&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;down&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;# Ensure systemd doesn&amp;#39;t get confused by the CLI exiting&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;RemainAfterExit&lt;/span&gt;=&lt;span style="color:#58a1dd"&gt;yes&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;[&lt;span style="color:#58a1dd"&gt;Install&lt;/span&gt;]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;WantedBy&lt;/span&gt;=&lt;span style="color:#58a1dd"&gt;default&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;target&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Once the file is saved, tell systemd to reload its configuration and enable the service:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;# Reload the user daemon&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;systemctl --user daemon-reload
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;# Enable the service to start on boot&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;systemctl --user &lt;span style="color:#58a1dd"&gt;enable&lt;/span&gt; lms.service
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;# Start the service now&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;systemctl --user start lms.service
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If you want to load a specific model by default, add an additional &lt;code&gt;ExecStartPre&lt;/code&gt; line, like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;ExecStartPre&lt;/span&gt;=/&lt;span style="color:#58a1dd"&gt;home&lt;/span&gt;/&lt;span style="color:#58a1dd"&gt;YOUR_USERNAME&lt;/span&gt;/.&lt;span style="color:#58a1dd"&gt;lmstudio&lt;/span&gt;/&lt;span style="color:#58a1dd"&gt;bin&lt;/span&gt;/&lt;span style="color:#58a1dd"&gt;lms&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;load&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;openai&lt;/span&gt;/&lt;span style="color:#58a1dd"&gt;gpt-oss-20b&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;--yes&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;You can check the service status with &lt;code&gt;systemctl --user lms.service&lt;/code&gt;. And that is it. You can now use your old hardware for inference with small local LLMs from any of your other machines.&lt;/p&gt;</description></item><item><title>Yet another architectural update for Play Kid</title><link>https://tonisagrista.com/blog/2026/playkid-update-again/</link><pubDate>Wed, 28 Jan 2026 00:00:00 +0000</pubDate><author>Toni Sagrista Selles</author><guid>https://tonisagrista.com/blog/2026/playkid-update-again/</guid><description>&lt;p&gt;I finished my previous post on Play Kid, only two days ago, with the following words:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Next, I&amp;rsquo;ll probably think about adding Game Boy Color support, but not before taking some time off from this project.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Yeah, this was a lie.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/playkid/logo-3x.avif"decoding="async" /&gt;
&lt;/picture&gt;
&lt;/figure&gt;
&lt;p&gt;I have previously written about Play Kid, my Game Boy emulator. &lt;a href="https://tonisagrista.com/blog/2026/playkid"&gt;Here&lt;/a&gt;, I introduced it and talked about the base implementation and the quirks of the Game Boy CPU, PPU, and hardware in general. &lt;a href="https://tonisagrista.com/blog/2026/playkid-update"&gt;Here&lt;/a&gt;, I explained the tech stack update from SDL2 to Rust-native libraries. In that last post, I mentioned the dependency version hell I unwillingly descended into as a result of adopting &lt;a href="https://crates.io/crates/pixels"&gt;&lt;code&gt;pixels&lt;/code&gt;&lt;/a&gt; as my rendering library. This forced me to stay on very old versions of &lt;code&gt;wgpu&lt;/code&gt; and &lt;code&gt;egui&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I want my Game Boy emulator to use the latest crate versions for various reasons, so this could not be. I saw a simple and direct path of update, which consisted on adopting &lt;a href="https://crates.io/crates/eframe"&gt;&lt;code&gt;eframe&lt;/code&gt;&lt;/a&gt; to manage the application life cycle, directly drawing the LCD to a texture, and dropping &lt;code&gt;pixels&lt;/code&gt; altogether.&lt;/p&gt;
&lt;p&gt;One of the things that nagged me about the &lt;code&gt;pixels&lt;/code&gt; crate is that its integration with &lt;code&gt;egui&lt;/code&gt; was kind of lackluster. There was no easy way to render the &lt;code&gt;pixels&lt;/code&gt; frame buffer to an &lt;code&gt;egui&lt;/code&gt; panel, so I had to render it centered inside the window. The immediate mode GUI lived in mostly in &lt;code&gt;Window&lt;/code&gt; widgets on top of it. Unfortunately, these windows occluded the Game Boy LCD. In a proper debugger, you must be able to see the entire LCD plus the debug interface.&lt;/p&gt;
&lt;p&gt;So I dropped &lt;code&gt;pixels&lt;/code&gt; and adopted a render-to-texture approach. In it, you create the LCD texture at the beginning from the &lt;code&gt;egui&lt;/code&gt; context, and then copy the LCD contents to it in the &lt;code&gt;update()&lt;/code&gt; method.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;fn&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;new&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;..&lt;/span&gt;.) -&amp;gt; &lt;span style="color:#58a1dd"&gt;Self&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; [&lt;span style="color:#ff636f"&gt;..&lt;/span&gt;.]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Create texture from egui context and Game Boy LCD width and height.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;texture&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;_cc&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;egui_ctx&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;load_texture&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;lcd_screen&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;egui&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;ColorImage&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;new&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; [&lt;span style="color:#58a1dd"&gt;DISPLAY_WIDTH&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;DISPLAY_HEIGHT&lt;/span&gt;],
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;vec!&lt;/span&gt;[&lt;span style="color:#58a1dd"&gt;egui&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;Color32&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;BLACK&lt;/span&gt;; &lt;span style="color:#58a1dd"&gt;DISPLAY_WIDTH&lt;/span&gt; &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;DISPLAY_HEIGHT&lt;/span&gt;],
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;egui&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;TextureOptions&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;NEAREST&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; );
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Create app struct.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;Self&lt;/span&gt; {&lt;span style="color:#ff636f"&gt;..&lt;/span&gt;.}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; [&lt;span style="color:#ff636f"&gt;..&lt;/span&gt;.]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;fn&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;update&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;..&lt;/span&gt;.) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; [&lt;span style="color:#ff636f"&gt;..&lt;/span&gt;.]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Render LCD to texture.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;frame_ready&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;size&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; [&lt;span style="color:#58a1dd"&gt;DISPLAY_WIDTH&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;DISPLAY_HEIGHT&lt;/span&gt;];
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;color_image&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;egui&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;ColorImage&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;from_rgba_unmultiplied&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;size&lt;/span&gt;, &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;machine&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;memory&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ppu&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;fb_front&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;screen_texture&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; .&lt;span style="color:#58a1dd"&gt;set&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;color_image&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;egui&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;TextureOptions&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;NEAREST&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With this, we can easily render the texture in &lt;code&gt;egui&lt;/code&gt;&amp;rsquo;s &lt;code&gt;CentralPanel&lt;/code&gt;, and the debug interface in a &lt;code&gt;SidePanel&lt;/code&gt; to the right. This is the result:&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/playkid/debug-mode-3.avif" width="80%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption style="margin: 0 auto; width:80%;"&gt;
&lt;h4&gt;The debug panel, showing the machine state and a code disassembly.&lt;/h4&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Some additional tweaks here and there, and the UI looks much more polished and professional in version 0.3.0.&lt;/p&gt;
&lt;p&gt;Version 0.4.0 enables loading ROM files from the UI. Initially I thought about making the cartridge struct optional with &lt;code&gt;Option&amp;lt;Cartridge&amp;gt;&lt;/code&gt;, but this spiraled out of control fast. I found that making the full &lt;code&gt;Machine&lt;/code&gt; (which contains the &lt;code&gt;Memory&lt;/code&gt;, &lt;code&gt;PPU&lt;/code&gt;, &lt;code&gt;APU&lt;/code&gt;, &lt;code&gt;Joystick&lt;/code&gt;, etc.) optional worked much better, as there was only one reference to it in the top-level struct, &lt;code&gt;PlayKid&lt;/code&gt;. And, like so, you can dynamically load ROM files from the UI:&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/playkid/open-rom.avif" width="80%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption style="margin: 0 auto; width:80%;"&gt;
&lt;h4&gt;Play Kid with the top menu bar and the &amp;lsquo;Open ROM&amp;rsquo; menu entry.&lt;/h4&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;So, what&amp;rsquo;s in the future for Play Kid? Well, there are a couple of features that I&amp;rsquo;d really like to add at some point:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Save states&lt;/strong&gt;&amp;mdash;Currently, Play Kid emulates the SRAM by saving and restoring it from files for supported games. I would like to add saving and restoring the full state of the emulator in what is known as save states. Possibly, the &lt;a href="https://crates.io/crates/serde"&gt;&lt;code&gt;serde&lt;/code&gt;&lt;/a&gt; crate can help with this.&lt;/li&gt;
&lt;li&gt;&lt;abbr title="Game Boy Color"&gt;&lt;strong&gt;GBC&lt;/strong&gt;&lt;/abbr&gt;&amp;mdash;Of course, I would like adding Game Boy Color support. It is not trivial, but also not exceedingly complicated. I never owned a GBC, so I&amp;rsquo;d see this as a good opportunity to explore its game catalog.&lt;/li&gt;
&lt;/ul&gt;</description></item><item><title>Game Boy emulator tech stack update</title><link>https://tonisagrista.com/blog/2026/playkid-update/</link><pubDate>Mon, 26 Jan 2026 00:00:00 +0000</pubDate><author>Toni Sagrista Selles</author><guid>https://tonisagrista.com/blog/2026/playkid-update/</guid><description>&lt;div class="hint"&gt;
&lt;div class="hint-title"&gt;&lt;i class="fa fa-info-circle"&gt;&lt;/i&gt;&amp;nbsp;&amp;nbsp;&lt;strong&gt;Notice&lt;/strong&gt;&lt;/div&gt;
&lt;div class="hint-body"&gt;
This post is about Play Kid version &lt;code&gt;0.2.0&lt;/code&gt;, which uses &lt;code&gt;pixels&lt;/code&gt; to create and manage the pixel frame buffer. Since then, versions &lt;code&gt;0.3.0&lt;/code&gt; and &lt;code&gt;0.4.0&lt;/code&gt; have been released, which dropped &lt;code&gt;pixels&lt;/code&gt; in favor of rendering directly to a texture. See &lt;a href="https://tonisagrista.com/blog/2026/playkid-update-again"&gt;this write-up&lt;/a&gt; for more information.
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;In my &lt;a href="https://tonisagrista.com/blog/2026/playkid/"&gt;previous post&lt;/a&gt;, I shared the journey of building &lt;a href="https://tonisagrista.com/projects/playkid"&gt;&lt;strong&gt;Play Kid&lt;/strong&gt;&lt;/a&gt;, my Game Boy emulator. At the time, I was using &lt;strong&gt;SDL2&lt;/strong&gt; to handle the &amp;ldquo;heavy lifting&amp;rdquo; of graphics, audio, and input. This was released as v0.1.0. It worked, and it worked well, but it always felt a bit like a &amp;ldquo;guest&amp;rdquo; in the Rust ecosystem. SDL2 is a C library at heart, and while the Rust wrappers are good, they bring along some baggage like shared library dependencies and difficult integration with Rust-native UI frameworks.&lt;/p&gt;
&lt;p&gt;So I decided to perform a heart transplant on Play Kid. For version v0.2.0 I’ve moved away from SDL2 entirely, replacing it with a stack of modern, native Rust libraries: &lt;a href="https://crates.io/crates/wgpu"&gt;&lt;strong&gt;&lt;code&gt;wgpu&lt;/code&gt;&lt;/strong&gt;&lt;/a&gt;, &lt;a href="https://crates.io/crates/pixels"&gt;&lt;strong&gt;&lt;code&gt;pixels&lt;/code&gt;&lt;/strong&gt;&lt;/a&gt;, &lt;a href="https://crates.io/crates/egui"&gt;&lt;strong&gt;&lt;code&gt;egui&lt;/code&gt;&lt;/strong&gt;&lt;/a&gt;, &lt;a href="https://crates.io/crates/winit"&gt;&lt;strong&gt;&lt;code&gt;winit&lt;/code&gt;&lt;/strong&gt;&lt;/a&gt;, &lt;a href="https://crates.io/crates/rodio"&gt;&lt;strong&gt;&lt;code&gt;rodio&lt;/code&gt;&lt;/strong&gt;&lt;/a&gt;, and &lt;a href="https://crates.io/crates/gilrs"&gt;&lt;strong&gt;&lt;code&gt;gilrs&lt;/code&gt;&lt;/strong&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;winit&lt;/code&gt; &amp;amp; &lt;code&gt;pixels&lt;/code&gt;: These handle the windowing and the actual Game Boy frame buffer. &lt;code&gt;pixels&lt;/code&gt; allows me to treat the 160x144 LCD as a simple pixel buffer while &lt;code&gt;wgpu&lt;/code&gt; handles the hardware-accelerated scaling and aspect ratio correction behind the scenes.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;egui&lt;/code&gt;: This was a big step-up. Instead of my minimal homegrown UI library from the SDL2 version, I now have access to a full-featured, immediate-mode GUI. This allowed me to build the debugger I had in mind from the beginning.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rodio&lt;/code&gt; &amp;amp; &lt;code&gt;gilrs&lt;/code&gt;: These replaced SDL2’s audio and controller handling with pure-Rust alternatives that feel much more ergonomic to use alongside the rest of the machine.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="debug-panel"&gt;Debug panel&lt;/h2&gt;
&lt;p&gt;The most visible change is the new &lt;strong&gt;Debug Panel&lt;/strong&gt;.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/playkid/debug-mode-2.avif" width="75%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption style="margin: 0 auto; width:75%;"&gt;
&lt;h4&gt;The new integrated debugger features a real-time disassembly view and breakpoint management.&lt;/h4&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;One of the coolest additions is the &lt;strong&gt;Code disassembly&lt;/strong&gt; panel. It decodes the ROM instructions in real-time, highlighting the current &lt;code&gt;PC&lt;/code&gt; and allowing me to toggle breakpoints just by clicking on a line. The breakpoints themselves are now managed in a dedicated list, shown in red at the bottom.&lt;/p&gt;
&lt;p&gt;The rest of the debug panel shows what we already had: the state of the CPU, the PPU, and the joypad.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;video width="75%" poster="/img/playkid/playkid-ui.jpg" preload="none"controls="controls"
&gt;
&lt;source src="https://tonisagrista.com/img/playkid/playkid-ui.mp4" autostart="false"&gt;
Your browser does not support the video tag.
&lt;/video&gt;&lt;figcaption style="margin: 0 auto; width:75%;"&gt;
&lt;h4&gt;Playing around with the new Play Kid UI based on egui.&lt;/h4&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id="dependency-hell"&gt;Dependency hell&lt;/h2&gt;
&lt;p&gt;Of course, no modern Rust migration is complete without a descent into &lt;strong&gt;dependency hell&lt;/strong&gt;. This new stack comes with a major catch: &lt;code&gt;pixels&lt;/code&gt; is a bit of a picky gatekeeper. Its latest version is 0.15 (January 2025). It is pinned to an older version of &lt;code&gt;wgpu&lt;/code&gt; (0.19 vs the current 28.0), and it essentially freezes the rest of the project in a time capsule.&lt;/p&gt;
&lt;p&gt;To keep the types compatible, I’m forced to stay on &lt;code&gt;egui&lt;/code&gt; 0.26 (current is 0.33) and &lt;code&gt;winit&lt;/code&gt; 0.29 (current is 0.30), even though the rest of the ecosystem has moved on to much newer, shinier versions. It’s kind of frustrating. You get the convenience of the &lt;code&gt;pixels&lt;/code&gt; buffer, but you pay for it by being locked out of the latest API improvements and features. Navigating these version constraints felt like solving a hostage negotiation between crate maintainers. Not very fun.&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Despite the dependency issues, I think the project is now in a much better place. The code is cleaner, the debugger is much better, and it’s easier to ship binaries for Linux, Windows, and macOS via GitHub Actions.&lt;/p&gt;
&lt;p&gt;If you’re interested in seeing the new architecture or trying out the new debugger, the code is updated on &lt;a href="https://codeberg.org/langurmonkey/playkid"&gt;Codeberg&lt;/a&gt; and &lt;a href="https://github.com/langurmonkey/playkid"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Next, I&amp;rsquo;ll probably think about adding Game Boy Color support, but not before taking some time off from this project.&lt;/p&gt;</description></item><item><title>Play Kid</title><link>https://tonisagrista.com/projects/playkid/</link><pubDate>Tue, 20 Jan 2026 00:00:00 +0000</pubDate><guid>https://tonisagrista.com/projects/playkid/</guid><description>&lt;p&gt;&lt;strong&gt;Play Kid&lt;/strong&gt; is yet another Game Boy emulator, written in Rust. But hey, it is MY Game Boy emulator, and I&amp;rsquo;m proud of it. I wrote some words about its development &lt;a href="https://tonisagrista.com/blog/2026/playkid"&gt;here&lt;/a&gt;. Find all posts about Play Kid &lt;a href="https://tonisagrista.com/tags/play-kid"&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/playkid/logo-3x.avif"decoding="async" /&gt;
&lt;/picture&gt;
&lt;/figure&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/playkid/grid.avif" width="100%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption style="margin: 0 auto; width:100%;"&gt;
&lt;h4&gt;Play Kid running different games with different color palettes.&lt;/h4&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Here are the main features of Play Kid:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;All CPU instructions implemented.&lt;/li&gt;
&lt;li&gt;Full memory map implemented.&lt;/li&gt;
&lt;li&gt;Modes: ROM, MBC1, MBC2, MBC3.&lt;/li&gt;
&lt;li&gt;Audio is implemented, with 4 channels, envelopes, sweep, and stereo.&lt;/li&gt;
&lt;li&gt;Supports game controllers.&lt;/li&gt;
&lt;li&gt;Multiple color palettes.&lt;/li&gt;
&lt;li&gt;Save screenshot of current frame buffer.&lt;/li&gt;
&lt;li&gt;FPS counter.&lt;/li&gt;
&lt;li&gt;Respects 160:144 aspect ratio by letter-boxing.&lt;/li&gt;
&lt;li&gt;Debug panel:
&lt;ul&gt;
&lt;li&gt;Step instruction.&lt;/li&gt;
&lt;li&gt;Step scanline.&lt;/li&gt;
&lt;li&gt;Pause/continue current execution.&lt;/li&gt;
&lt;li&gt;Displays internal state of CPU, PPU, and Joypad.&lt;/li&gt;
&lt;li&gt;Full program disassembly, with breakpoints.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Save RAM to &lt;code&gt;.sav&lt;/code&gt; files to emulate the battery-backed SRAM. Those are saved every minute.&lt;/li&gt;
&lt;li&gt;Working games/roms:
&lt;ul&gt;
&lt;li&gt;Passes &lt;code&gt;dmg-acid2&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Tetris&lt;/li&gt;
&lt;li&gt;Pokémon&lt;/li&gt;
&lt;li&gt;Super Mario Land&lt;/li&gt;
&lt;li&gt;Super Mario Land 2: 6 Golden Coins&lt;/li&gt;
&lt;li&gt;Wario Land (Super Mario Land 3)&lt;/li&gt;
&lt;li&gt;Wario Land II&lt;/li&gt;
&lt;li&gt;Bugs Bunny Crazy Castle&lt;/li&gt;
&lt;li&gt;The Amazing Spider-Man&lt;/li&gt;
&lt;li&gt;Dr. Mario&lt;/li&gt;
&lt;li&gt;Probably many, many more&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;For &lt;a href="https://codeberg.org/langurmonkey/playkid/releases"&gt;Linux, macOS, and Windows&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1 id="downloads"&gt;Downloads&lt;/h1&gt;
&lt;p&gt;You can grab packages for Linux, macOS, and Windows here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codeberg.org/langurmonkey/playkid/releases"&gt;Downloads&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/langurmonkey/rts-engine"&gt;Mirror (GitHub)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h1 id="build"&gt;Build&lt;/h1&gt;
&lt;p&gt;Build the project with &lt;code&gt;cargo build&lt;/code&gt;.&lt;/p&gt;
&lt;h1 id="run"&gt;Run&lt;/h1&gt;
&lt;p&gt;The usual Rust stuff.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; cargo run
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;You can also pass in a ROM file with &lt;code&gt;cargo run -- your-rom.gb&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Make the binary with:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; cargo build --release
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h1 id="operation"&gt;Operation&lt;/h1&gt;
&lt;p&gt;If you don&amp;rsquo;t pass in any ROM file as an argument, you need to select using the top menu bar, &lt;kbd&gt;File&lt;/kbd&gt;▶&lt;kbd&gt;Open ROM&amp;hellip;&lt;/kbd&gt;.&lt;/p&gt;
&lt;p&gt;Here are the Joypad keyboard mappings:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;kbd&gt;enter&lt;/kbd&gt; - Start button&lt;/li&gt;
&lt;li&gt;&lt;kbd&gt;space&lt;/kbd&gt; - Select button&lt;/li&gt;
&lt;li&gt;&lt;kbd&gt;a&lt;/kbd&gt; - A button&lt;/li&gt;
&lt;li&gt;&lt;kbd&gt;b&lt;/kbd&gt; - B button&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The keyboard is clumsy for playing Game Boy games, so you can use any game controller. Controllers are detected when hot-plugged.&lt;/p&gt;
&lt;p&gt;Additionally, there are some more actions available:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;kbd&gt;p&lt;/kbd&gt; - change the palette colors&lt;/li&gt;
&lt;li&gt;&lt;kbd&gt;w&lt;/kbd&gt; - trigger the SRAM save operation to &lt;code&gt;.sav&lt;/code&gt; file.&lt;/li&gt;
&lt;li&gt;&lt;kbd&gt;f&lt;/kbd&gt; - toggle FPS monitor&lt;/li&gt;
&lt;li&gt;&lt;kbd&gt;s&lt;/kbd&gt; - save a screenshot, with name &lt;code&gt;screenshot_[time].jpg&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;kbd&gt;d&lt;/kbd&gt; - enter debug mode&lt;/li&gt;
&lt;li&gt;&lt;kbd&gt;Esc&lt;/kbd&gt; - exit the emulator&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can also use the provided UI.&lt;/p&gt;
&lt;h1 id="debug-panel"&gt;Debug panel&lt;/h1&gt;
&lt;p&gt;You can open the debug panel any time by pressing &lt;kbd&gt;d&lt;/kbd&gt;, by clicking on &lt;kbd&gt;Machine&lt;/kbd&gt;▶&lt;kbd&gt;Debug panel&amp;hellip;&lt;/kbd&gt;, or activate it at launch with the &lt;code&gt;-d&lt;/code&gt;/&lt;code&gt;--debug&lt;/code&gt; flag. The debug panel shows up in a translucent window. It provides a view of the internal state of the emulator, with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Current address, instruction, operands, and opcode, to the top.&lt;/li&gt;
&lt;li&gt;Internal state of CPU, PPU, and JOYP, to the left.&lt;/li&gt;
&lt;li&gt;Disassembly of the program, to the right.&lt;/li&gt;
&lt;li&gt;Breakpoints.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/playkid/debug-mode-3.avif" width="80%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption style="margin: 0 auto; width:80%;"&gt;
&lt;h4&gt;The debug panel, showing the machine state and a code disassembly.&lt;/h4&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;You can use the provided UI controls to work with debug mode. You can also use the keyboard. These are the key bindings:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;kbd&gt;F6&lt;/kbd&gt; - step a single instruction&lt;/li&gt;
&lt;li&gt;&lt;kbd&gt;F7&lt;/kbd&gt; - step a scanline&lt;/li&gt;
&lt;li&gt;&lt;kbd&gt;F9&lt;/kbd&gt; - continue execution until breakpoint (if paused), or pause execution (if running)&lt;/li&gt;
&lt;li&gt;&lt;kbd&gt;r&lt;/kbd&gt; - reset the CPU&lt;/li&gt;
&lt;li&gt;&lt;kbd&gt;d&lt;/kbd&gt; - exit debug mode and go back to normal full-speed emulation&lt;/li&gt;
&lt;li&gt;&lt;kbd&gt;Esc&lt;/kbd&gt; - exit the emulator&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can also use breakpoints. A list with the current breakpoint addresses is provided at the bottom. To create a breakpoint, either &lt;strong&gt;click on the address&lt;/strong&gt; in the disassembly panel, or enter it (in &lt;code&gt;$abcd&lt;/code&gt; format) into the text field and click &lt;kbd&gt;+&lt;/kbd&gt;. Remove a breakpoint by clicking the &lt;kbd&gt;×&lt;/kbd&gt; in the breakpoints list. Clear all current breakpoints with &lt;kbd&gt;Clear all&lt;/kbd&gt;.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;video width="75%" poster="/img/playkid/playkid-ui.jpg" preload="none"controls="controls"
&gt;
&lt;source src="https://tonisagrista.com/img/playkid/playkid-ui.mp4" autostart="false"&gt;
Your browser does not support the video tag.
&lt;/video&gt;&lt;figcaption style="margin: 0 auto; width:75%;"&gt;
&lt;h4&gt;Playing around with the Play Kid debug UI.&lt;/h4&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h1 id="cli-args"&gt;CLI args&lt;/h1&gt;
&lt;p&gt;There are some CLI arguments that you can use:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Play Kid 0.4.0
Toni Sagristà - tonisagrista.com
Minimalist Game Boy emulator for the cool kids.
Usage: playkid [OPTIONS] [INPUT]
Arguments:
[INPUT] Path to the input ROM file to load
Options:
-s, --scale &amp;lt;SCALE&amp;gt; Initial window scale. It can also be resized manually [default: 4]
-d, --debug Activate debug mode. Use `d` to stop program at any point
-f, --fps Show FPS counter. Use `f` to toggle on and off
--skipcheck Skip global checksum, header checksum, and logo sequence check
-h, --help Print help
-V, --version Print version
&lt;/code&gt;&lt;/pre&gt;&lt;h1 id="sdl2-version"&gt;SDL2 version&lt;/h1&gt;
&lt;p&gt;Play Kid started as an SDL2 application, but it was moved to a pure Rust tech stack using &lt;code&gt;pixels&lt;/code&gt;, &lt;code&gt;winit&lt;/code&gt;, &lt;code&gt;egui&lt;/code&gt;, and &lt;code&gt;rodio&lt;/code&gt;. This makes it much easier to build for different targets (including WASM!). Additionally, the SDL2 version contains a minimalist homegrown UI library that I&amp;rsquo;m particularly proud about, but it can&amp;rsquo;t hold a candle to &lt;code&gt;egui&lt;/code&gt;. It looks like this:&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/playkid/debug-mode-sdl2.avif" width="100%" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption style="margin: 0 auto; width:100%;"&gt;
&lt;h4&gt;The debug mode.&lt;/h4&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The SDL2 version is forever tagged &lt;code&gt;playkid-sdl2&lt;/code&gt; (&lt;a href="https://codeberg.org/langurmonkey/playkid/src/tag/playkid-sdl2"&gt;playkid-sdl2@codeberg&lt;/a&gt;, &lt;a href="https://github.com/langurmonkey/playkid/tree/playkid-sdl2"&gt;playkid-sdl2@github&lt;/a&gt;).&lt;/p&gt;
&lt;h1 id="useful-links"&gt;Useful links&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;Pandocs: &lt;a href="https://gbdev.io/pandocs/"&gt;https://gbdev.io/pandocs/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Complete tech reference: &lt;a href="https://gekkio.fi/files/gb-docs/gbctr.pdf"&gt;https://gekkio.fi/files/gb-docs/gbctr.pdf&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Game Boy CPU manual: &lt;a href="http://marc.rawer.de/Gameboy/Docs/GBCPUman.pdf"&gt;http://marc.rawer.de/Gameboy/Docs/GBCPUman.pdf&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Game Boy CPU instructions: &lt;a href="https://meganesu.github.io/generate-gb-opcodes/"&gt;https://meganesu.github.io/generate-gb-opcodes/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description></item><item><title>Implementing a Game Boy emulator</title><link>https://tonisagrista.com/blog/2026/playkid/</link><pubDate>Tue, 20 Jan 2026 00:00:00 +0000</pubDate><author>Toni Sagristà Sellés</author><guid>https://tonisagrista.com/blog/2026/playkid/</guid><description>&lt;div class="hint"&gt;
&lt;div class="hint-title"&gt;&lt;i class="fa fa-info-circle"&gt;&lt;/i&gt;&amp;nbsp;&amp;nbsp;&lt;strong&gt;Notice&lt;/strong&gt;&lt;/div&gt;
&lt;div class="hint-body"&gt;
This post is about Play Kid version &lt;code&gt;0.1.0&lt;/code&gt;, which works with SDL2. Since then, version &lt;code&gt;0.2.0&lt;/code&gt; has been released. This new version uses Rust native crates like &lt;code&gt;winit&lt;/code&gt;, &lt;code&gt;egui&lt;/code&gt;, &lt;code&gt;rodio&lt;/code&gt;, and &lt;code&gt;pixels&lt;/code&gt;. See &lt;a href="https://tonisagrista.com/blog/2026/playkid-update"&gt;this write-up&lt;/a&gt; for more information on the new version.
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;When I was a kid my parents got me and my brothers a brand new Game Boy. I used to play it a lot, and I was mesmerized and amazed at how such a tiny brick could give life to so many wonderful worlds. Some of my favorite games were &lt;strong&gt;Super Mario Land&lt;/strong&gt; (1, 2, and 3, Wario Land), &lt;strong&gt;The Amazing Spider-Man&lt;/strong&gt;, and &lt;strong&gt;Kirby&amp;rsquo;s Dream Land&lt;/strong&gt;. We also had some other games, like &lt;strong&gt;Bugs Bunny Crazy Castle&lt;/strong&gt; or &lt;strong&gt;Tetris&lt;/strong&gt;, that I also played quite a lot. I remember fondly too.&lt;/p&gt;
&lt;p&gt;The first Super Mario Land in particular was magical to me. I would complete it, and then do it again in one sitting. There were no save games, as it used &lt;abbr title="Memory Bank Controller 1"&gt;MBC1&lt;/abbr&gt; with no &lt;abbr title="Random Access Memory"&gt;RAM&lt;/abbr&gt; (more on that later). You really had to &lt;em&gt;git gud&lt;/em&gt;. The second play through had the same levels but more enemies, in what must have been one of the first implementations of &lt;em&gt;New Game+&lt;/em&gt;. After this harder mode was completed, you could select and play any level. Too bad all the progress went away as soon as you powered off the device. &lt;strong&gt;Super Mario Land 2: 6 Golden Coins&lt;/strong&gt; already introduced save games (used MBC1+RAM+Battery). It was a huge step-up in QoL for Game Boy games. We also had the original &lt;abbr title="Nintendo Entertainment System"&gt;NES&lt;/abbr&gt;, and later the &lt;abbr title="Super NES"&gt;SNES&lt;/abbr&gt;, but the Game Boy always remained that cozy little fun device that you could just pick up and play for a quick session. It was a significant part of my childhood.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;video width="45%" poster="/img/playkid/supermarioland-thumb.jpg" preload="none"controls="controls"
&gt;
&lt;source src="https://tonisagrista.com/img/playkid/supermarioland-levels2-4.mp4" autostart="false"&gt;
Your browser does not support the video tag.
&lt;/video&gt;&lt;figcaption style="margin: 0 auto; width:45%;"&gt;
&lt;h4&gt;Me playing Super Mario Land, levels 2 to 4, with my own emulator.&lt;/h4&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Some time ago, I wrote a &lt;a href="https://tonisagrista.com/blog/2021/chip8-implementation/"&gt;CHIP-8 emulator&lt;/a&gt; in Rust, and found the experience very rewarding. The consensus online seems to be that the recommended next step, as far as emulator development goes, is the original Game Boy. I know that the internet is flooded with &amp;ldquo;Implementing a Game Boy emulator&amp;rdquo; kind of articles, and (judging by the GitHub counts) this is a very unoriginal project, but I still wanted to try my hand at it. So I took the bait and started working on it. Today, I&amp;rsquo;m excited to share my very own Game Boy emulator.&lt;/p&gt;
&lt;p&gt;The original Game Boy, also referred to as &lt;abbr title="Dot Matrix Game"&gt;DMG&lt;/abbr&gt; in nerdy spaces and released in 1989, is an excellent target for emulation. Its hardware is rather well-documented, its architecture is relatively simple compared to modern systems, yet it&amp;rsquo;s complex enough to present real challenges, which I for sure faced.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/playkid/logo-3x.avif"decoding="async" /&gt;
&lt;/picture&gt;
&lt;/figure&gt;
&lt;p&gt;I called my implementation &lt;a href="https://tonisagrista.com/projects/playkid"&gt;&lt;strong&gt;Play Kid&lt;/strong&gt;&lt;/a&gt;. It is written in Rust and uses &lt;abbr title="Simple DirectMedia Layer 2"&gt;SDL2&lt;/abbr&gt; for graphics, sound, and input. Here are the main features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;All CPU instructions implemented.&lt;/li&gt;
&lt;li&gt;Full memory map implemented.&lt;/li&gt;
&lt;li&gt;Modes: &lt;abbr title="Read Only Memory"&gt;ROM&lt;/abbr&gt;, MBC1, MBC2, MBC3.&lt;/li&gt;
&lt;li&gt;Audio is implemented, with 4 channels, envelopes, sweep, and stereo.&lt;/li&gt;
&lt;li&gt;Supports game controllers via SDL (also hot-plugging).&lt;/li&gt;
&lt;li&gt;Multiple color palettes.&lt;/li&gt;
&lt;li&gt;Save screenshot of current frame buffer.&lt;/li&gt;
&lt;li&gt;Respects 160:144 aspect ratio by letter-boxing.&lt;/li&gt;
&lt;li&gt;Debug mode:
&lt;ul&gt;
&lt;li&gt;Step instruction.&lt;/li&gt;
&lt;li&gt;Step scanline.&lt;/li&gt;
&lt;li&gt;Pause/continue current execution.&lt;/li&gt;
&lt;li&gt;FPS counter.&lt;/li&gt;
&lt;li&gt;Displays internal state.&lt;/li&gt;
&lt;li&gt;Breakpoints.&lt;/li&gt;
&lt;li&gt;Uses own minimal UI library with horizontal/vertical layouts, labels, buttons, and text fields.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Save RAM to &lt;code&gt;.sav&lt;/code&gt; files to emulate the battery-backed SRAM.&lt;/li&gt;
&lt;li&gt;Automatically adapts to multi-DPI setups by scaling the UI.&lt;/li&gt;
&lt;li&gt;Working games/roms:
&lt;ul&gt;
&lt;li&gt;Passes &lt;code&gt;dmg-acid2&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Tetris&lt;/li&gt;
&lt;li&gt;Pokémon Red&lt;/li&gt;
&lt;li&gt;Super Mario Land&lt;/li&gt;
&lt;li&gt;Super Mario Land 2: 6 Golden Coins&lt;/li&gt;
&lt;li&gt;Super Mario Land 3: Wario Land&lt;/li&gt;
&lt;li&gt;Wario Land II&lt;/li&gt;
&lt;li&gt;Bugs Bunny Crazy Castle&lt;/li&gt;
&lt;li&gt;The Amazing Spider-Man&lt;/li&gt;
&lt;li&gt;Kirby&amp;rsquo;s Dream Land&lt;/li&gt;
&lt;li&gt;Dr. Mario&lt;/li&gt;
&lt;li&gt;Probably many more&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Works on &lt;a href="https://codeberg.org/langurmonkey/playkid/releases"&gt;Linux, macOS, and Windows&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In this post, I navigate the general architecture of the Game Boy, emphasize interesting hardware quirks that made it a challenge to emulate properly, and present my implementation. I won&amp;rsquo;t go into too much detail, as I took care to make the code understandable and well-commented. In any case, for more details on a specific subject, you can refer to the code repository itself.&lt;/p&gt;
&lt;h2 id="foreword"&gt;Foreword&lt;/h2&gt;
&lt;p&gt;I have based my implementation, mainly, on three resources:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://gbdev.io/pandocs/"&gt;Pan Docs&lt;/a&gt; &amp;ndash; possibly the best documentation on the Game Boy system out there.&lt;/li&gt;
&lt;li&gt;&lt;a href="http://marc.rawer.de/Gameboy/Docs/GBCPUman.pdf"&gt;Game Boy CPU Manual&lt;/a&gt; &amp;ndash; documents the Game Boy architecture in great detail.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://meganesu.github.io/generate-gb-opcodes/"&gt;Instruction table&lt;/a&gt; &amp;ndash; all 8-bit &lt;abbr title="operation codes"&gt;opcodes&lt;/abbr&gt; into a single table, complete with descriptions for each one of them. Very handy.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On certain occasions, I found some of the Opcode descriptions to be unclear or a bit ambiguous. In these cases, I referred to actual pre-existing implementations. By far, the implementation I liked the most is &lt;a href="https://github.com/mvdnes/rboy"&gt;RBoy&lt;/a&gt;. It is a very compact and mostly uncommented Game Boy Color emulator, written in Rust. Most of the instructions are the same as in the Game Boy DMG, so it worked very well for me.&lt;/p&gt;
&lt;h2 id="game-boy-essentials"&gt;Game Boy essentials&lt;/h2&gt;
&lt;p&gt;Before we get into the details, let&amp;rsquo;s talk a bit about how the Game Boy actually works. Emulation is basically about mimicking hardware in software. We&amp;rsquo;re not just running Game Boy code, we&amp;rsquo;re simulating an entire machine cycle by cycle.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;
&lt;source srcset="https://tonisagrista.com/img/playkid/gameboy.jxl" type="image/jxl" /&gt;
&lt;img src="https://tonisagrista.com/img/playkid/gameboy.avif"
alt="The original Nintendo Game Boy. Original by Evan-Amos." loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption&gt;
&lt;p class="fig-attribution"&gt;The original Nintendo Game Boy. Original by &lt;a href="https://commons.wikimedia.org/wiki/User:Evan-Amos"&gt;Evan-Amos&lt;/a&gt;.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The Game Boy&amp;rsquo;s execution model is pretty simple. The CPU has a 16-bit &lt;abbr title="Program Counter"&gt;PC&lt;/abbr&gt; register that points to the next instruction in memory. Each cycle, it fetches the byte at that address, decodes it into an instruction, executes it, and increments the PC. Instructions are composed of opcode+operand, and can be one, two, or three bytes long. Each instruction takes a specific number of machine cycles (M-cycles) to complete, usually between 1 and 6. Since the CPU runs at around 4.19 MHz (master clock) and one M-cycle equals four clock cycles (&lt;strong&gt;T-cycles&lt;/strong&gt;), timing is important.&lt;/p&gt;
&lt;p&gt;The system uses memory-mapped I/O. This means hardware components like the display, audio, and input are accessed by reading and writing to specific memory addresses. Want to check if the &lt;kbd&gt;A&lt;/kbd&gt; button is pressed? Read from address &lt;code&gt;0xFF00&lt;/code&gt;. Need to start a &lt;kbd&gt;DMA&lt;/kbd&gt; transfer? Write to &lt;code&gt;0xFF46&lt;/code&gt;. This design makes the architecture simpler, but it also means that memory access can trigger all sorts of side effects, and you need to manage the mapping in your code.&lt;/p&gt;
&lt;p&gt;Interrupts let hardware grab the CPU&amp;rsquo;s attention. When something happens (the display finishing a frame, or a timer overflow), the corresponding interrupt flag is set. If interrupts are enabled (via the &lt;abbr title="Interrupt Master Enable"&gt;IME&lt;/abbr&gt; flag), the CPU pauses what it&amp;rsquo;s doing, pushes the current PC onto the stack, and jumps to a predefined interrupt handler address. This lets games sync with hardware events instead of constantly checking for them.&lt;/p&gt;
&lt;p&gt;The hardware components all move forward together based on the CPU&amp;rsquo;s cycle count. When the CPU executes a 4 M-cycle instruction, the &lt;abbr title="Pixel-Processing Unit"&gt;PPU&lt;/abbr&gt; advances 16 T-cycles worth of scanline rendering, the &lt;abbr title="Audio-Processing Unit"&gt;APU&lt;/abbr&gt; moves its sound generation forward, and timers tick along. Everything runs in lockstep, which means accurate emulation needs cycle-level precision across all the different parts.&lt;/p&gt;
&lt;p&gt;Understanding this basic execution model (fetch, decode, execute, repeat, with interrupts and memory-mapped hardware mixed in) is key before getting into the specifics. Each part of the system we&amp;rsquo;ll look at next works within this framework.&lt;/p&gt;
&lt;h2 id="the-cpu"&gt;The CPU&lt;/h2&gt;
&lt;p&gt;The heart of the Game Boy is a modified Intel 8080/Zilog Z80 processor running at approximately 4.19 MHz. Nintendo&amp;rsquo;s custom chip, sometimes called the SM83 or LR35902, includes most Z80 instructions but removes some and adds a few Game Boy-specific ones.&lt;/p&gt;
&lt;p&gt;The CPU features eight 8-bit registers (A, B, C, D, E, F, H, L) that can be paired into four 16-bit registers (AF, BC, DE, HL), plus a 16-bit &lt;abbr title="Stack Pointer"&gt;SP&lt;/abbr&gt; and the aforementioned PC. The F register is special, as its four most significant bits hold flags that track the results of operations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Zero (Z)&lt;/li&gt;
&lt;li&gt;Subtraction (N)&lt;/li&gt;
&lt;li&gt;Half-Carry (H)&lt;/li&gt;
&lt;li&gt;Carry (C)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In my implementation, the CPU is contained in &lt;code&gt;machine.rs&lt;/code&gt;, which contains the main components of the hardware (memory, registers, PPU, APU, etc.), controls the execution state (with debug), and implements the instructions.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// # Machine
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// The machine contains the registers, the memory, and the display, and
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// controls the execution and CPU state. It also implements the CPU, which
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// decodes and executes instructions.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#ff636f"&gt;struct&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Machine&lt;/span&gt;&lt;span style="color:#ff636f"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;&amp;#39;a&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;&amp;#39;b&lt;/span&gt;&lt;span style="color:#ff636f"&gt;&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// The registers.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;registers&lt;/span&gt;: &lt;span style="color:#58a1dd"&gt;Registers&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// The main memory.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;memory&lt;/span&gt;: &lt;span style="color:#58a1dd"&gt;Memory&lt;/span&gt;&lt;span style="color:#ff636f"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;&amp;#39;a&lt;/span&gt;&lt;span style="color:#ff636f"&gt;&amp;gt;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// The display.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;display&lt;/span&gt;: &lt;span style="color:#58a1dd"&gt;Display&lt;/span&gt;&lt;span style="color:#ff636f"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;&amp;#39;b&lt;/span&gt;&lt;span style="color:#ff636f"&gt;&amp;gt;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// The UI state.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;ui_state&lt;/span&gt;: &lt;span style="color:#58a1dd"&gt;Rc&lt;/span&gt;&lt;span style="color:#ff636f"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;RefCell&lt;/span&gt;&lt;span style="color:#ff636f"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;UIState&lt;/span&gt;&lt;span style="color:#ff636f"&gt;&amp;gt;&amp;gt;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// Interrupt master enable flag.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;ime&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;bool&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// EI operation is delayed by one instruction, so we use this counter.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;ei&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// DI operation is delayed by one instruction, so we use this counter.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;di&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// Flag that holds the running status.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;running&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;bool&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// CPU halted.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;halted&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;bool&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// T-states, basic unit of time, and 1:1 with the clock.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;t_cycles&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u32&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// M-cycles, base unit for CPU instructions, and 1:4 with the clock.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;m_cycles&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u32&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// The debug manager.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;debug&lt;/span&gt;: &lt;span style="color:#58a1dd"&gt;DebugManager&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// Print FPS every second.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;fps&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;bool&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// The event pump.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;event_pump&lt;/span&gt;: &lt;span style="color:#58a1dd"&gt;EventPump&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The registers are implemented in a struct, which is trivial:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// # Registers
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// We have 7 1-bit registers (`a`, `b`, `c`, `d`, `e`, `h`, `l`),
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// which can be accessed individually, or together as 16 bits,
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// in the combinations `af`, `bc`, `de` and `hl`.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// We also have the flags register, `f`, whose 4 most significant bits are the flags
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// zero `z`, subtraction `n`, half-carry `h` and carry `c`.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// Additionally, we have two 16-bit special registers, the stack pointer `sp`, and
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// the program counter `pc`.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#ff636f"&gt;struct&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Registers&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;a&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;b&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;c&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;d&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;e&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// Flags register.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// - Bit 7: `z`, zero flag
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// - Bit 6: `n`, subtraction flag (BCD)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// - Bit 5: `h`, half-carry flag (BCD)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// - Bit 4: `c`, carry flag
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;f&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;h&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;l&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// Stack pointer register.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;sp&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u16&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// Program counter register.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;pc&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u16&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This struct is complented with handy methods to read and write the registers, either individually or as 16-bit combinations. As you can see, the code is &lt;strong&gt;very&lt;/strong&gt; well documented.&lt;/p&gt;
&lt;p&gt;The instructions live in an &lt;code&gt;enum&lt;/code&gt;, as do their operands. The instructions enum contains the 256 8-bit instructions, and the 256 16-bit instructions prefixed by &lt;code&gt;0xCB&lt;/code&gt;.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// # Instructions
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// This enum contains all supported instructions.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// Instructions that can act with both r8 and r16 registers are first named
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// by default in their r8 version. The r16 versions add a `16` at the end of the name.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;#[derive(Debug)]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#ff636f"&gt;enum&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Instruction&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// NoOp.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;NOP&lt;/span&gt;(),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Stop.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;STOP&lt;/span&gt;(),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Halt.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;HALT&lt;/span&gt;(),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Jump HL.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;JPHL&lt;/span&gt;(),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Jump.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;JP&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;CC&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Relative jumps.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;JR&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;CC&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Push.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;PUSH&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;R16EXT&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Pop.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;POP&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;R16EXT&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Load.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;LD&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;R8&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;LDcp&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;R8&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;R8&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;LD16&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;R16&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; [&lt;span style="color:#ff636f"&gt;..&lt;/span&gt;.]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// Enumerates the r8 registers.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;#[derive(Debug)]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#ff636f"&gt;enum&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;R8&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;A&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;B&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;C&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;D&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;E&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;H&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;L&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;HL&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// Enumerates the r16 registers.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;#[derive(Debug)]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#ff636f"&gt;enum&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;R16&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;BC&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;DE&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;HL&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;SP&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// Enumerates r16 registers for POP and PUSH, which contain AF.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;#[derive(Debug)]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#ff636f"&gt;enum&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;R16EXT&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;BC&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;DE&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;HL&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;AF&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// Enumerates the R16 registers to be used in (some) load operations.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;#[derive(Debug)]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#ff636f"&gt;enum&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;R16LD&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;BC&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;DE&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;HLp&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;HLm&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;A8&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;C&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;A16&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// Enumerates jump conditions, mostly flags.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;#[derive(Debug)]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#ff636f"&gt;enum&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;CC&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;NONE&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;NZ&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;Z&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;NC&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;C&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// RST&amp;#39;s target address, divided by 8.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;#[derive(Debug)]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#ff636f"&gt;enum&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;TGT3&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;T0&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;T1&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;T2&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;T3&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;T4&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;T5&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;T6&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;T7&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And then, I have a &lt;code&gt;from_byte(byte: u8)&lt;/code&gt; method that matches opcodes to their actual enum values:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;impl&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Instruction&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// Construct an instruction from its byte representation.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#ff636f"&gt;fn&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;from_byte&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;byte&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;) -&amp;gt; &lt;span style="color:#58a1dd"&gt;Option&lt;/span&gt;&lt;span style="color:#ff636f"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;Instruction&lt;/span&gt;&lt;span style="color:#ff636f"&gt;&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;match&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;byte&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// NOP
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x00&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Some&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;Instruction&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;NOP&lt;/span&gt;()),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// STOP
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x10&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Some&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;Instruction&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;STOP&lt;/span&gt;()),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// LD (r16)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x01&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Some&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;Instruction&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;LD16&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;R16&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;BC&lt;/span&gt;)),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x11&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Some&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;Instruction&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;LD16&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;R16&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;DE&lt;/span&gt;)),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x21&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Some&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;Instruction&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;LD16&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;R16&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;HL&lt;/span&gt;)),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x31&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Some&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;Instruction&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;LD16&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;R16&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;SP&lt;/span&gt;)),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; [&lt;span style="color:#ff636f"&gt;..&lt;/span&gt;.]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Undocumented OPCODE
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;_&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;println!&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;: Unimplemented instruction &lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{:#02x}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;, treating as NOP&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;WARN&amp;#34;&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;yellow&lt;/span&gt;(),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;byte&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; );
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;Some&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;Instruction&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;NOP&lt;/span&gt;())
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I also have a &lt;code&gt;from_byte_0xcb(byte: u8)&lt;/code&gt; method used in the actual implementation to decode the 16-bit opcodes.&lt;/p&gt;
&lt;p&gt;Actually implementing the CPU meant building a &lt;code&gt;match&lt;/code&gt; with cases for ever single one of the 512 instructions. Each instruction updates the PC, manipulates registers or memory, and sets flags according to specific rules. Some instructions were very straightforward to implement; others, like &lt;abbr title="Decimal Adjust Accumulator"&gt;DAA&lt;/abbr&gt;, required careful consideration of edge cases and bit manipulation. The implementation structure is quite straightforward:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// Execute a single instruction, and returns the number of cycles it takes.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;fn&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;execute&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;run_instr&lt;/span&gt;: &lt;span style="color:#58a1dd"&gt;RunInstr&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;opcode&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;) -&amp;gt; &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Actually execute the instruction.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;match&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;run_instr&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;instr&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// NOP: no operation.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;Instruction&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;NOP&lt;/span&gt;() &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// STOP
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;Instruction&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;STOP&lt;/span&gt;() &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;stop&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// HALT
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;Instruction&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;HALT&lt;/span&gt;() &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// HALT only halts if IME is enabled.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ime&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;halt&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Otherwise, HALT acts like NOP.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// LD r16
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;Instruction&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;LD16&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;r16&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#ff636f"&gt;match&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;r16&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;R16&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;BC&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;nw&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;read16&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;registers&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;set_bc&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;nw&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;3&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;R16&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;DE&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;nw&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;read16&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;registers&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;set_de&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;nw&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;3&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;R16&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;HL&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;nw&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;read16&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;registers&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;set_hl&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;nw&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;3&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;R16&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;SP&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;registers&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;sp&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;read16&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;3&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; },
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; [&lt;span style="color:#ff636f"&gt;..&lt;/span&gt;.]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// OPCODE 16-bit (0xCB).
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;Instruction&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;OPCODE16&lt;/span&gt;() &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Read next byte, construct instruction, execute 0xCB instruction.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;opcode0xcb&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;read8&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;instr0xcb&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Instruction&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;from_byte_0xcb&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;opcode0xcb&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;msg&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;format!&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;&amp;#34;Incorrect 0xCB opcode: &lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{:#04X}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;opcode0xcb&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;execute_0xcb&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;instr0xcb&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;expect&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;msg&lt;/span&gt;), &lt;span style="color:#58a1dd"&gt;opcode0xcb&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Never should happen.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;_&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;panic!&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;&amp;#34;Instruction is not implemented: &lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{:#04X}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;opcode&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And, of course, there is an analogous &lt;code&gt;execute_0xcb(...)&lt;/code&gt; method that contains the implementation for all the 16-bit opcodes.&lt;/p&gt;
&lt;p&gt;The CPU doesn&amp;rsquo;t run in isolation and needs to be cycle-accurate. Each instruction takes a specific number of machine cycles (M-cycles), typically 1-6, and the rest of the system needs to advance accordingly. Getting timing right is quite important, because the graphics hardware, audio, and even some games depend on precise cycle counts. This is why &lt;code&gt;execute(...) -&amp;gt; u8&lt;/code&gt; returns an unsigned integer. This is the number of cycles that the instruction takes. The main loop handles the input events, and then runs as many cycles per frame as necessary.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;while&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;running&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;frame_start_time&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;std&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;time&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;Instant&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;now&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Handle input events.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;handle_events&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#ff636f"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;cycles_this_frame&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;usize&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;while&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;cycles_this_frame&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;lt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;constants&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;CYCLES_PER_FRAME&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;t&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;m&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;machine_cycle&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;m_cycles&lt;/span&gt; &lt;span style="color:#ff636f"&gt;+=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;m&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;t_cycles&lt;/span&gt; &lt;span style="color:#ff636f"&gt;+=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;t&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;cycles_this_frame&lt;/span&gt; &lt;span style="color:#ff636f"&gt;+=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;t&lt;/span&gt; &lt;span style="color:#ff636f"&gt;as&lt;/span&gt; &lt;span style="color:#ff636f"&gt;usize&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Fill the LCD buffer if needed.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;display&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;render_lcd&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;memory&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Flush and present the frame.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;display&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;canvas&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;flush&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;display&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;present&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;frame_count&lt;/span&gt; &lt;span style="color:#ff636f"&gt;+=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Sleep for the remaining time in the frame.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;sleep_next_frame&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;frame_start_time&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The machine cycle runs a single CPU cycle. A CPU cycle does the following:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Read next opcode from the memory address pointed by PC&lt;/li&gt;
&lt;li&gt;Increment PC&lt;/li&gt;
&lt;li&gt;Decode the instruction&lt;/li&gt;
&lt;li&gt;Run the instruction &amp;ndash; &lt;code&gt;execute(...)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Run a memory cycle&lt;/li&gt;
&lt;li&gt;Update the IME and handle interrupts&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The code looks like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// Main loop of the machine.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;fn&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;cycle&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;) -&amp;gt; &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Fetch next instruction, and parse it.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;opcode&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;read8&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;run_instr&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;RunInstr&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;new&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;opcode&lt;/span&gt;, &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;memory&lt;/span&gt;, &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;registers&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Execute the instruction.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;execute&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;run_instr&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;opcode&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// Run a machine cycle.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;fn&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;machine_cycle&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;) -&amp;gt; (&lt;span style="color:#ff636f"&gt;u32&lt;/span&gt;, &lt;span style="color:#ff636f"&gt;u32&lt;/span&gt;, &lt;span style="color:#ff636f"&gt;bool&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// CPU instruction.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// One machine cycle (M-cycle) is 4 clock cycles.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#ff636f"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;m_cycles&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;running&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;halted&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// CPU is halted, don&amp;#39;t execute instructions but still consume 1 M-cycle.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; } &lt;span style="color:#ff636f"&gt;else&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Run next CPU instruction.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;cycle&lt;/span&gt;() &lt;span style="color:#ff636f"&gt;as&lt;/span&gt; &lt;span style="color:#ff636f"&gt;u32&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; } &lt;span style="color:#ff636f"&gt;else&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// NOOP instruction.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; };
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#ff636f"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;t_cycles&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;m_cycles&lt;/span&gt; &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;4&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Memory cycle.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;m_cycles&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Memory cycle.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;_&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;r&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;memory&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;cycle&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;t_cycles&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#ff636f"&gt;!&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;r&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Close down.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;running&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;false&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;return&lt;/span&gt; (&lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#ff636f"&gt;false&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; } &lt;span style="color:#ff636f"&gt;else&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;t_cycles&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;m_cycles&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Update IME.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ime_update&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Handle interrupts if necessary.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;interrupt_m_cycles&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;interrupt_handling&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Return.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;interrupt_m_cycles&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; (&lt;span style="color:#58a1dd"&gt;interrupt_m_cycles&lt;/span&gt; &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;4&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;interrupt_m_cycles&lt;/span&gt;, &lt;span style="color:#ff636f"&gt;true&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; } &lt;span style="color:#ff636f"&gt;else&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; (&lt;span style="color:#58a1dd"&gt;t_cycles&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;m_cycles&lt;/span&gt;, &lt;span style="color:#ff636f"&gt;true&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;We&amp;rsquo;ll talk about the memory cycle when we reach the memory section. For now, let&amp;rsquo;s look at interrupts, as it was an interesting challenge. The Game Boy supports five interrupt types: V-Blank, LCD STAT, Timer, Serial, and Joypad. The CPU checks for pending interrupts after each instruction, and if interrupts are enabled and one is pending, it pushes the PC onto the stack and jumps to the corresponding interrupt vector. Implementing the IME flag and its one-instruction delay after the EI instruction was a subtle detail that broke several games until I got it right. This is handled in &lt;code&gt;ime_update()&lt;/code&gt; and &lt;code&gt;interrupt_handling()&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// Updates the IME (Interrupt Master Enable) flag.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// This is necessary because the effect of the EI and DI instructions
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// is delayed by one instruction.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;fn&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;ime_update&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;di&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;match&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;di&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;2&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ime&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;false&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;_&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; };
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ei&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;match&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ei&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;2&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ime&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;true&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;_&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; };
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// Interrupt handling. The IF bit corresponding to this interrupt, and the IME flag
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// are reset by the CUP. IF acknowledges the interrupt, and IME prevents any other
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// interrupts from being handled until re-enabled (with RETI).
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// In this case, the corresponding interrupt handler is called by pushing the PC
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// to the stack, and then setting it to the address of the interrupt handler.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;fn&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;interrupt_handling&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;) -&amp;gt; &lt;span style="color:#ff636f"&gt;u32&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;pending&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;memory&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ie&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;memory&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;iff&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Wake from HALT if any interrupt is pending (even if IME is disabled).
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;pending&lt;/span&gt; &lt;span style="color:#ff636f"&gt;!=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;halted&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;halted&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;false&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// If no interrupts are pending OR IME is disabled, return.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#ff636f"&gt;!&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ime&lt;/span&gt; &lt;span style="color:#ff636f"&gt;||&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;pending&lt;/span&gt; &lt;span style="color:#ff636f"&gt;==&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;return&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Reset IME.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ime&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;false&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// IE and IF have the following format:
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;//
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// | 7 6 5 | 4 | 3 | 2 | 1 | 0 |
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// | 1 | Joypad | Serial | Timer | LCD | VBlank |
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;//
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;for&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;i&lt;/span&gt; &lt;span style="color:#ff636f"&gt;in&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;&lt;span style="color:#ff636f"&gt;..&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;5&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;bit&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;i&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;pending&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;bit&lt;/span&gt; &lt;span style="color:#ff636f"&gt;!=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;memory&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;iff&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;!&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;bit&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;pc&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;registers&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;pc&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;push_stack&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;pc&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;registers&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;pc&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;match&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;bit&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x01&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x0040&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x02&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x0048&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x04&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x0050&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x08&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x0058&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x10&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x0060&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;_&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;panic!&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;&amp;#34;Unknown interrupt bit &lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;bit&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; };
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;break&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;5&lt;/span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// 5 M-cycles = 20 T-cycles
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="memory"&gt;Memory&lt;/h2&gt;
&lt;p&gt;The Game Boy&amp;rsquo;s 16-bit address space is divided into distinct regions, each serving a specific purpose:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;0x0000&lt;/code&gt;-&lt;code&gt;0x3FFF&lt;/code&gt;: ROM bank 0 (16 KB, fixed)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0x4000&lt;/code&gt;-&lt;code&gt;0x7FFF&lt;/code&gt;: ROM bank 1-N (16 KB, switchable via MBCs, more on that later)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0x8000&lt;/code&gt;-&lt;code&gt;0x9FFF&lt;/code&gt;: Video RAM (8 KB)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0xA000&lt;/code&gt;-&lt;code&gt;0xBFFF&lt;/code&gt;: External RAM (8 KB, cartridge)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0xC000&lt;/code&gt;-&lt;code&gt;0xDFFF&lt;/code&gt;: Work RAM (8 KB)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0xE000&lt;/code&gt;-&lt;code&gt;0xFDFF&lt;/code&gt;: Echo RAM (mirror of 0xC000-0xDDFF)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0xFE00&lt;/code&gt;-&lt;code&gt;0xFE9F&lt;/code&gt;: &lt;abbr title="Object Attribute Memory"&gt;OAM&lt;/abbr&gt; (sprite data)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0xFF00&lt;/code&gt;-&lt;code&gt;0xFF7F&lt;/code&gt;: &lt;abbr title="Input/Output"&gt;I/O&lt;/abbr&gt; registers&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0xFF80&lt;/code&gt;-&lt;code&gt;0xFFFE&lt;/code&gt;: High RAM (127 bytes)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0xFFFF&lt;/code&gt;: &lt;abbr title="Interrupt Enable"&gt;IE&lt;/abbr&gt; register&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Implementing memory access required a dispatcher that routes reads and writes to the appropriate hardware component. Reading from &lt;abbr title="Video RAM"&gt;VRAM&lt;/abbr&gt; accesses the PPU&amp;rsquo;s memory, writing to &lt;code&gt;0xFF00&lt;/code&gt;-&lt;code&gt;0xFF7F&lt;/code&gt; triggers side effects in various subsystems, and accessing cartridge ROM or RAM goes through the mapper.&lt;/p&gt;
&lt;p&gt;The memory struct contains the Work RAM, the High RAM, I/O registers, the &lt;abbr title="Interrupt Flag"&gt;IF&lt;/abbr&gt; and IE, a reference to the PPU, a reference to the APU, a reference to the cartridge, the timer, and the joypad.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// # Memory
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// The Game Boy uses a 2-byte address space (0x0000 to 0xFFFF) to map the different
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6be9d"&gt;/// types of memory (RAM, VRAM, Cartridge memory, etc.)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#ff636f"&gt;struct&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Memory&lt;/span&gt;&lt;span style="color:#ff636f"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;&amp;#39;a&lt;/span&gt;&lt;span style="color:#ff636f"&gt;&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// Work RAM.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;wram&lt;/span&gt;: [&lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;; &lt;span style="color:#58a1dd"&gt;constants&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;WRAM_SIZE&lt;/span&gt;],
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// High RAM.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;hram&lt;/span&gt;: [&lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;; &lt;span style="color:#58a1dd"&gt;constants&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;HRAM_SIZE&lt;/span&gt;],
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// I/O registers.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;io&lt;/span&gt;: [&lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;; &lt;span style="color:#58a1dd"&gt;constants&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;IO_SIZE&lt;/span&gt;],
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// IF: interrupt flag.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;iff&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// IE flag: interrupt enable.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;ie&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Cartridge reference.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;cart&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;&amp;#39;a&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Cartridge&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// The PPU, Picture Processing Unit.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;ppu&lt;/span&gt;: &lt;span style="color:#58a1dd"&gt;PPU&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// The timer.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;timer&lt;/span&gt;: &lt;span style="color:#58a1dd"&gt;Timer&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// The joypad.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;joypad&lt;/span&gt;: &lt;span style="color:#58a1dd"&gt;Joypad&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// The APU, Audio Processing Unit.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;apu&lt;/span&gt;: &lt;span style="color:#58a1dd"&gt;APU&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The actual mapping is implemented in the &lt;code&gt;read()&lt;/code&gt; and &lt;code&gt;write()&lt;/code&gt; operations. The address is matched to the specific range in the address space and forwarded to the appropriate component. For instance, the read method:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// Read a byte of memory at the given `address`.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#ff636f"&gt;fn&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;read8&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;address&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u16&lt;/span&gt;) -&amp;gt; &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;match&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;address&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x0000&lt;/span&gt;&lt;span style="color:#ff636f"&gt;..=&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;0x3FFF&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// 16kB bank #0 (cartridge).
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;cart&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;read&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;address&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x4000&lt;/span&gt;&lt;span style="color:#ff636f"&gt;..=&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;0x7FFF&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// 16kB switchable ROM bank (cartridge).
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;cart&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;read&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;address&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x8000&lt;/span&gt;&lt;span style="color:#ff636f"&gt;..=&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;0x9FFF&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// VRAM.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ppu&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;read&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;address&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0xA000&lt;/span&gt;&lt;span style="color:#ff636f"&gt;..=&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;0xBFFF&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// 8kB switchable RAM bank (cartridge).
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;cart&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;read_ram&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;address&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; [&lt;span style="color:#ff636f"&gt;..&lt;/span&gt;.]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Memory-mapped I/O registers control everything from &lt;abbr title="Liquid Crystal Display"&gt;LCD&lt;/abbr&gt; modes to audio channels. Some registers are read-only, some write-only, and some have bits that behave differently on read versus write. The &lt;abbr title="Direct Memory Access"&gt;DMA&lt;/abbr&gt; register at &lt;code&gt;0xFF46&lt;/code&gt; is particularly interesting. Writing to it triggers a transfer of 160 bytes from ROM or RAM to OAM, during which the CPU should only access High RAM. Getting DMA timing correct was essential for sprite-heavy games.&lt;/p&gt;
&lt;h2 id="the-ppu"&gt;The PPU&lt;/h2&gt;
&lt;p&gt;The Picture Processing Unit is what puts pixels on the screen. It generates a 160×144 display running at around 60 Hz. Getting it working meant understanding scanlines, timing, and how backgrounds, windows, and sprites all come together.&lt;/p&gt;
&lt;p&gt;The PPU works one scanline at a time. Each scanline takes 456 T-cycles (dots), and a full frame has 154 scanlines: 144 visible ones and 10 during V-Blank. While rendering a scanline, the PPU goes through different modes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Mode 2 (OAM Search)&lt;/strong&gt;: Scanning which sprites will be visible on this scanline (80 dots)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mode 3 (Pixel Transfer)&lt;/strong&gt;: Actually drawing the pixels (variable, ~172 dots)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mode 0 (H-Blank)&lt;/strong&gt;: Horizontal blank, waiting for the scanline to finish (~204 dots)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mode 1 (V-Blank)&lt;/strong&gt;: Vertical blank, screen done rendering (10 scanlines × 456 dots)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;During V-Blank, games usually update their state and prepare for the next frame. My implementation transitions between modes based on the current scanline and dot count:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;new_mode&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ly&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;144&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// VBlank.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;} &lt;span style="color:#ff636f"&gt;else&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;match&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;fdot&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;&lt;span style="color:#ff636f"&gt;..=&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;80&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;2&lt;/span&gt;, &lt;span style="color:#828b96;font-style:italic"&gt;// OAM Scan.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;81&lt;/span&gt;&lt;span style="color:#ff636f"&gt;..=&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;252&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;3&lt;/span&gt;, &lt;span style="color:#828b96;font-style:italic"&gt;// Drawing.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;_&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#828b96;font-style:italic"&gt;// HBlank.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;};
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The background is made up of 8x8 pixel tiles stored in VRAM. Each tile uses 2 bits per pixel, giving you four shades of gray (or colors if you apply a palette). There are two tile data areas (at &lt;code&gt;0x8000&lt;/code&gt; and &lt;code&gt;0x8800&lt;/code&gt;) and two tile maps that tell you which tiles go where. The LCDC register controls which areas to use, and you can think of it as configuring how the PPU interprets the data in VRAM.&lt;/p&gt;
&lt;p&gt;Fetching tile data means reading two bytes per tile row and combining them to get the color IDs:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;tile_addr&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;tile_addr_base&lt;/span&gt; &lt;span style="color:#ff636f"&gt;+&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;line&lt;/span&gt; &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;2&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;low_byte&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;read&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;tile_addr&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;high_byte&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;read&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;tile_addr&lt;/span&gt; &lt;span style="color:#ff636f"&gt;+&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#ff636f"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;pixels&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; [&lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;&lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;; &lt;span style="color:#a6be9d"&gt;8&lt;/span&gt;];
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;for&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;i&lt;/span&gt; &lt;span style="color:#ff636f"&gt;in&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;&lt;span style="color:#ff636f"&gt;..&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;8&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;color_id&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; ((&lt;span style="color:#58a1dd"&gt;high_byte&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;gt;&amp;gt;&lt;/span&gt; (&lt;span style="color:#a6be9d"&gt;7&lt;/span&gt; &lt;span style="color:#ff636f"&gt;-&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;i&lt;/span&gt;)) &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x1&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt; &lt;span style="color:#ff636f"&gt;|&lt;/span&gt; ((&lt;span style="color:#58a1dd"&gt;low_byte&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;gt;&amp;gt;&lt;/span&gt; (&lt;span style="color:#a6be9d"&gt;7&lt;/span&gt; &lt;span style="color:#ff636f"&gt;-&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;i&lt;/span&gt;)) &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x1&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;pixels&lt;/span&gt;[&lt;span style="color:#58a1dd"&gt;i&lt;/span&gt;] &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;color_id&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Windows work similarly to backgrounds but ignore scrolling. They&amp;rsquo;re positioned absolutely on screen using WX and WY registers. My implementation tracks an internal window line counter (WLY) that only increments when the window actually renders on a scanline, which is important for games that turn the window on and off mid-frame.&lt;/p&gt;
&lt;p&gt;Sprites (or objects) are 8x8 or 8x16 pixels that sit on top of the background. The OAM holds up to 40 sprite definitions, each with position, tile index, and attributes like palette selection, flip flags, and priority. The hardware can only show 10 sprites per scanline, and when more than 10 overlap, priority goes to sprites with lower X coordinates first, then lower OAM index:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;// Game Boy priority: X asc, then OAM index asc.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;sprites&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;sort_by&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;|&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;a&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;b&lt;/span&gt;&lt;span style="color:#ff636f"&gt;|&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;a&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;x&lt;/span&gt; &lt;span style="color:#ff636f"&gt;!=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;b&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;x&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;a&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;x&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;cmp&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;b&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;x&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; } &lt;span style="color:#ff636f"&gt;else&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;a&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;oam_index&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;cmp&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;b&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;oam_index&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;});
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;One of the trickier parts was getting sprite priority right. The priority bit in the sprite attributes controls whether a sprite appears above all background pixels or only above background color 0. I store the background color IDs in a separate buffer as I render the background, then check against those values when drawing sprites:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;bg_color_id&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;priorities&lt;/span&gt;[&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ly&lt;/span&gt; &lt;span style="color:#ff636f"&gt;as&lt;/span&gt; &lt;span style="color:#ff636f"&gt;usize&lt;/span&gt; &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;constants&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;DISPLAY_WIDTH&lt;/span&gt; &lt;span style="color:#ff636f"&gt;+&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;x_pos&lt;/span&gt;];
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;obj_behind_bg&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;sprite&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;attributes&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x80&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;!=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;sprite_has_priority&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;obj_behind_bg&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Priority bit set: only draw sprite over BG color 0.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;bg_color_id&lt;/span&gt; &lt;span style="color:#ff636f"&gt;==&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;} &lt;span style="color:#ff636f"&gt;else&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Priority bit clear: always draw sprite (over any BG color).
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;};
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This lets me respect the priority rules properly without having to re-read background data.&lt;/p&gt;
&lt;p&gt;I cache tile data while rendering each scanline to avoid repeatedly decoding the same tiles. For backgrounds and windows, I cache up to 32 tiles (one row). For sprites, I use a &lt;code&gt;HashMap&lt;/code&gt; keyed by tile ID, line number, and flip flags.&lt;/p&gt;
&lt;p&gt;The PPU&amp;rsquo;s timing had to be cycle-accurate. Each frame needs exactly 70224 T-cycles (154 scanlines × 456 dots). The main loop advances the PPU by the number of cycles the CPU just executed, and the PPU updates its mode and LY register (current scanline) accordingly:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#ff636f"&gt;fn&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;cycle&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;t_cycles&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u32&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#ff636f"&gt;!&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;is_ppu_enabled&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;return&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;fdot&lt;/span&gt; &lt;span style="color:#ff636f"&gt;+=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;t_cycles&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Update mode if it changed.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;new_mode&lt;/span&gt; &lt;span style="color:#ff636f"&gt;!=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;mode&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;update_mode&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;new_mode&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Handle transition to next scanline.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;fdot&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;456&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;fdot&lt;/span&gt; &lt;span style="color:#ff636f"&gt;-=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;456&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ly&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ly&lt;/span&gt; &lt;span style="color:#ff636f"&gt;+&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;%&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;154&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Getting the timing right was kind of important because some games rely on precise V-Blank and H-Blank timing. Otherwise, you get visual glitches, and good luck debugging them.&lt;/p&gt;
&lt;p&gt;In order to test my PPU implementation, I turned to the &lt;a href="https://github.com/mattcurrie/dmg-acid2"&gt;DMG Acid2&lt;/a&gt; test. This is a rather exhaustive PPU test in the form of a ROM. If you see a happy face with &lt;code&gt;HELLO WORLD!&lt;/code&gt; above, and &lt;code&gt;dmg-acid2 by Matt Currie&lt;/code&gt; below, you&amp;rsquo;re good. In my case, the right chin was missing. I spent an ginormous amount of time trying to get it to render properly. This is what I was getting:&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/playkid/acid2-chin.jpg"
alt="Oh no, the right chin is missing!" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption&gt;
&lt;p class="fig-attribution"&gt;Oh no, the right chin is missing!&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;I was so close. The &lt;a href="https://github.com/mattcurrie/dmg-acid2?tab=readme-ov-file#failure-examples"&gt;README&lt;/a&gt; has an entry that looks exactly like it. The right chin is rendered using the window, and the window tile map, so I kept looking at the window code. It was a pain, and for the life of me, I could not find the issue even though I looked incessantly. In the end, I fed my code to an LLM and it found the error right away: In my read and write VRAM operations, I had WX and WY backwards!&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// This is NOT correct!
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// WY.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0xFF4A&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;wx&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// WX.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0xFF4B&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;wy&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This was frustrating, but it really goes to show what random side effects such a stupid mistake can have.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/playkid/acid2-good.jpg"
alt="After countless hours, the stupid bug was mended and Acid2 was good!" loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption&gt;
&lt;p class="fig-attribution"&gt;After countless hours, the stupid bug was mended and Acid2 was good!&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Now that my PPU was more or less feature-complete, I tested it with other games. Seeing the first sprite render correctly, or watching the background scroll smoothly in Mario for the first time made it worth it. The PPU is obviously the most (only?) visible part of the emulator, so getting it to a state where it was able to run all the games I wanted to run felt good.&lt;/p&gt;
&lt;p&gt;At the very end, when the graphics were already in an excellent shape, I implemented multiple color palettes. The original Game Boy was monochrome, but you can map those four gray shades to any colors you want. IIRC, the Super Game Boy did something similar, and it allowed you to create your own too! I included a few classic palettes (DMG Classic, Pocket, etc.) and some more creative ones. You can cycle through them while playing with &lt;kbd&gt;p&lt;/kbd&gt;, which makes the games look fresh.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/playkid/grid.avif"
alt="Some of the palettes in Play Kid." loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption&gt;
&lt;p class="fig-attribution"&gt;Some of the palettes in Play Kid.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;At this point, I could play lots of games, but I had no audio. This had to be mended.&lt;/p&gt;
&lt;h2 id="the-apu"&gt;The APU&lt;/h2&gt;
&lt;p&gt;I consider the Audio Processing Unit (APU) to be one of the most boring parts of this project. It synthesizes sound through four channels: two pulse wave channels (square waves), one wave channel (custom waveform), and one noise channel (pseudo-random). Each channel has its own registers for frequency, volume, envelope, and duty cycle.&lt;/p&gt;
&lt;p&gt;The APU is fully synchronized with the CPU and the PPU, and tus runs at 4.19 MHz. This is the master clock that we&amp;rsquo;ve seen previously. However, the APU doesn&amp;rsquo;t do everything at 4.19 MHz. Internally, it uses a frame sequencer that divides this master clock down to 512 Hz to manage high-level audio events:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Length Counters: Clocked at 256 Hz (every 2nd step).&lt;/li&gt;
&lt;li&gt;Frequency Sweep: Clocked at 128 Hz (every 4th step).&lt;/li&gt;
&lt;li&gt;Volume Envelopes: Clocked at 64 Hz (every 8th step).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It steps through eight different phases, some updating length counters, some update the sweep function, and some updating volume envelopes. My implementation tracks this with a frame timer that increments with each T-cycle and runs at 512 Hz (4194304 Hz / 8192 = 512 Hz):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;frame_timer&lt;/span&gt; &lt;span style="color:#ff636f"&gt;+=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;t_cycles&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;frame_timer&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;8192&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// 512Hz is 4.19 Mhz / 8192.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;frame_timer&lt;/span&gt; &lt;span style="color:#ff636f"&gt;-=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;8192&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Phases from 0 to 7.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;match&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;frame_sequencer&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt; &lt;span style="color:#ff636f"&gt;|&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;2&lt;/span&gt; &lt;span style="color:#ff636f"&gt;|&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;4&lt;/span&gt; &lt;span style="color:#ff636f"&gt;|&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;6&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;step_length&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;frame_sequencer&lt;/span&gt; &lt;span style="color:#ff636f"&gt;==&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;2&lt;/span&gt; &lt;span style="color:#ff636f"&gt;||&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;frame_sequencer&lt;/span&gt; &lt;span style="color:#ff636f"&gt;==&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;6&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;step_sweep&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;7&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;step_envelope_ch1&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;step_envelope_ch2&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;step_envelope_ch4&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;_&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;frame_sequencer&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;frame_sequencer&lt;/span&gt; &lt;span style="color:#ff636f"&gt;+&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;%&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;8&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each pulse channel (1 and 2) generates square waves with four different duty cycles. The duty cycle controls the waveform shape and is stored as patterns:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;duty_idx&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;read&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;0xFF11&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;6&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;as&lt;/span&gt; &lt;span style="color:#ff636f"&gt;usize&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;patterns&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; [
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; [&lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;], &lt;span style="color:#828b96;font-style:italic"&gt;// 12.5%
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; [&lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;], &lt;span style="color:#828b96;font-style:italic"&gt;// 25%
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; [&lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;], &lt;span style="color:#828b96;font-style:italic"&gt;// 50%
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; [&lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;, &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;], &lt;span style="color:#828b96;font-style:italic"&gt;// 75%
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;];
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;signal&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;patterns&lt;/span&gt;[&lt;span style="color:#58a1dd"&gt;duty_idx&lt;/span&gt;][&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ch1_duty_step&lt;/span&gt;];
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;volume_multiplier&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ch1_volume&lt;/span&gt; &lt;span style="color:#ff636f"&gt;as&lt;/span&gt; &lt;span style="color:#ff636f"&gt;f32&lt;/span&gt; &lt;span style="color:#ff636f"&gt;/&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;15.0&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0.05&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The wave channel (channel 3) is different. It plays back samples from a 32-nibble waveform stored in Wave RAM. Games can write custom waveforms here, which makes this channel super flexible:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;// Get 4-bit sample from Wave RAM (32 samples total, 2 per byte).
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;byte&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;wave_ram&lt;/span&gt;[&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ch3_sample_idx&lt;/span&gt; &lt;span style="color:#ff636f"&gt;/&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;2&lt;/span&gt;];
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#ff636f"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;sample&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ch3_sample_idx&lt;/span&gt; &lt;span style="color:#ff636f"&gt;%&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;2&lt;/span&gt; &lt;span style="color:#ff636f"&gt;==&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;byte&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;4&lt;/span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// High nibble.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;} &lt;span style="color:#ff636f"&gt;else&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;byte&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x0F&lt;/span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Low nibble.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;};
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#828b96;font-style:italic"&gt;// Volume shift based on NR32 register.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;sample&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;gt;&amp;gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;volume_shift&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The noise channel (channel 4) uses a &lt;abbr title="Linear Feedback Shift Register"&gt;LFSR&lt;/abbr&gt; to generate pseudo-random noise. The LFSR is a 15-bit shift register that &lt;abbr title="eXclusive OR"&gt;XOR&lt;/abbr&gt;s bits 0 and 1, then shifts the result back in:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;bit0&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ch4_lfsr&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x01&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;bit1&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ch4_lfsr&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x01&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;result&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;bit0&lt;/span&gt; &lt;span style="color:#ff636f"&gt;^&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;bit1&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ch4_lfsr&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ch4_lfsr&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;|&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;result&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;14&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;if&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;nr43&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x08&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;!=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Short mode (7-bit)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ch4_lfsr&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ch4_lfsr&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#ff636f"&gt;!&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;1&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;6&lt;/span&gt;)) &lt;span style="color:#ff636f"&gt;|&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;result&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;6&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This creates the characteristic noise used for drums, explosions, and other effects.&lt;/p&gt;
&lt;p&gt;The sweep function on channel 1 was particularly tricky. It modulates the frequency over time by shifting the current frequency right and either adding or subtracting the result. The overflow check happens both when sweep is triggered and when calculating the new frequency:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;fn&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;calculate_sweep_freq&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;) -&amp;gt; &lt;span style="color:#ff636f"&gt;u16&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;nr10&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;read&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;0xFF10&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;sweep_step&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;nr10&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x07&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;descending&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;nr10&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x08&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;!=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;delta&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ch1_sweep_shadow_freq&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;sweep_step&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;new_freq&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;descending&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ch1_sweep_shadow_freq&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;saturating_sub&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;delta&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; } &lt;span style="color:#ff636f"&gt;else&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ch1_sweep_shadow_freq&lt;/span&gt; &lt;span style="color:#ff636f"&gt;+&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;delta&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; };
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Overflow check.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;new_freq&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;2047&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ch1_enabled&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;false&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;new_freq&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Getting the timing and overflow behavior right required reading Pan Docs extensively, and I also looked at RBoy&amp;rsquo;s implementation.&lt;/p&gt;
&lt;p&gt;Sound samples are generated at 44100 Hz and mixed into stereo output based on the NR51 register, which controls left/right panning for each channel. SDL2&amp;rsquo;s audio queue system fit naturally with the APU design. I generate samples as the emulator runs and queue them for SDL to play back:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;let&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;l_sample&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;r_sample&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;generate_sample&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;buffer&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;push&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;l_sample&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;buffer&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;push&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;r_sample&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;buffer&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;len&lt;/span&gt;() &lt;span style="color:#ff636f"&gt;&amp;gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;2048&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Throttle if queue is getting too full
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;while&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;device&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;size&lt;/span&gt;() &lt;span style="color:#ff636f"&gt;&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;8192&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;std&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;thread&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;sleep&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;std&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;time&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;Duration&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;from_millis&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;));
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;device&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;queue_audio&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;buffer&lt;/span&gt;).&lt;span style="color:#58a1dd"&gt;unwrap&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;buffer&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;clear&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Keeping audio and video synchronized without drift required careful management of cycle counts and buffer sizes. The throttling mechanism prevents the emulator from running too fast when the audio queue backs up.&lt;/p&gt;
&lt;p&gt;I implemented channel 1 first, and then ran Super Mario Land. I could hear some sounds (jumps, parts of the music), but the experience was underwhelming. Since all channels follow a similar structure, I had channel 2 working in no time. Now I could hear the iconic melody of the first stage, all the jumps, coins, and enemies. I implemented the other channels soon after.&lt;/p&gt;
&lt;p&gt;Interestingly, in Super Mario Land, the music is mostly in channels 2 and 3. Channel 1 is for things like jumps, coins, and some high-pitched parts of melodies. Channel 4 is used in the random noise that some enemies produce when they die (like the bomb turtles), and some percussion beats in the background.&lt;/p&gt;
&lt;p&gt;I still have no idea whether my APU contains any bugs, or if it sounds exactly as it&amp;rsquo;s supposed to. I have no way to tell. To my ears, it sounds amazing.&lt;/p&gt;
&lt;h2 id="cartridges"&gt;Cartridges&lt;/h2&gt;
&lt;p&gt;Most Game Boy cartridges aren&amp;rsquo;t just ROM chips. They contain &lt;abbr title="Memory Bank Controller"&gt;MBC&lt;/abbr&gt;s that expand the addressable memory beyond the CPU&amp;rsquo;s 64 KB limit. Different games use different MBCs, each with its own banking scheme.&lt;/p&gt;
&lt;p&gt;Every cartridge has a header header that contains essential information about the game, stored in the first few bytes of the cartridge&amp;rsquo;s ROM. This information helps the system identify and load the game properly. The header includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Logo&lt;/strong&gt; &amp;ndash; the first 48 bytes (&lt;code&gt;0x00&lt;/code&gt;-&lt;code&gt;0x2F&lt;/code&gt;) contain the Nintendo logo, ensuring the authenticity of the cartridge.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Title&lt;/strong&gt; &amp;ndash; the next 16 bytes (&lt;code&gt;0x34&lt;/code&gt;-&lt;code&gt;0x43&lt;/code&gt;) store the 8-character game title, which is displayed when the game is loaded.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Game Boy/GBC Flag&lt;/strong&gt; &amp;ndash; the byte at &lt;code&gt;0x143&lt;/code&gt; indicates whether the game is for the original Game Boy (&lt;code&gt;0x00&lt;/code&gt;), or a Game Boy Color-compatible game (&lt;code&gt;0x80&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cartridge Type&lt;/strong&gt; &amp;ndash; the byte at &lt;code&gt;0x147&lt;/code&gt; specifies the cartridge type, such as ROM-only or with memory banks. Here is a non-exhaustive list of common values:
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;0x00&lt;/code&gt; &amp;ndash; ROM only&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0x01&lt;/code&gt; &amp;ndash; MBC1&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0x02&lt;/code&gt; &amp;ndash; MBC1+RAM&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0x03&lt;/code&gt; &amp;ndash; MBC1+RAM+BATTERY&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0x05&lt;/code&gt; &amp;ndash; MBC2&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0x06&lt;/code&gt; &amp;ndash; MBC2+BATTERY&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0x11&lt;/code&gt; &amp;ndash; MBC3&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0x12&lt;/code&gt; &amp;ndash; MBC3+RAM&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0x13&lt;/code&gt; &amp;ndash; MBC3+RAM+BATTERY&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ROM Size&lt;/strong&gt; &amp;ndash; the byte at &lt;code&gt;0x148&lt;/code&gt; specifies the ROM size in powers of 2. For example, &lt;code&gt;0x00&lt;/code&gt; is 32KB, &lt;code&gt;0x01&lt;/code&gt; is 64KB, &lt;code&gt;0x02&lt;/code&gt; is 128KB, etc.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RAM Size&lt;/strong&gt; &amp;ndash; the byte at &lt;code&gt;0x149&lt;/code&gt; specifies the RAM size in kilobytes. For example, &lt;code&gt;0x00&lt;/code&gt; means no RAM, &lt;code&gt;0x03&lt;/code&gt; means 32KB, &lt;code&gt;0x04&lt;/code&gt; means 128KB, etc.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Destination Code&lt;/strong&gt; &amp;ndash; located at &lt;code&gt;0x14A&lt;/code&gt;, this byte indicates the region the game is intended for. &lt;code&gt;0x00&lt;/code&gt; is for Japan, &lt;code&gt;0x01&lt;/code&gt; for Overseas.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Licensee Code&lt;/strong&gt; &amp;ndash; the byte at &lt;code&gt;0x14B&lt;/code&gt; contains a code identifying the licensed company that produced the game.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mask ROM Version&lt;/strong&gt; &amp;ndash; the byte at &lt;code&gt;0x14C&lt;/code&gt; is the version number of the game’s mask ROM (usually set to &lt;code&gt;0x00&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Complement Checksum&lt;/strong&gt; &amp;ndash; the byte at &lt;code&gt;0x14D&lt;/code&gt; is used for the checksum calculation to verify the integrity of the ROM data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Global Checksum&lt;/strong&gt; &amp;ndash; the last 2 bytes (&lt;code&gt;0x14E&lt;/code&gt;-&lt;code&gt;0x14F&lt;/code&gt;) store a checksum value that ensures the data integrity of the entire ROM.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This header allows the Game Boy to load and display the game properly by checking for compatibility and loading the correct resources for the specified hardware.&lt;/p&gt;
&lt;p&gt;When it comes to MBCs, I implemented ROM-only and MBC1/2/3 into Play Kid.&lt;/p&gt;
&lt;dl&gt;
&lt;dt&gt;&lt;strong&gt;ROM-only&lt;/strong&gt;&lt;/dt&gt;
&lt;dd&gt;ROM-only cartridges are the simplest, with no banking at all. These are limited to 32 KB and include simple games like Tetris. No mapper logic needed, just direct reads from the ROM data.&lt;/dd&gt;
&lt;dd&gt;
&lt;p&gt;Tetris and Dr. Mario use ROM-only cartridges.&lt;/p&gt;
&lt;/dd&gt;
&lt;dt&gt;&lt;strong&gt;MBC1&lt;/strong&gt;&lt;/dt&gt;
&lt;dd&gt;MBC1 is the most common mapper, supporting up to 2 MB of ROM and 32 KB of RAM. It uses banking to switch between ROM and RAM banks by writing to specific address ranges. Understanding that writes to ROM addresses don&amp;rsquo;t actually write to ROM but instead configure the mapper took some mental adjustment.&lt;/dd&gt;
&lt;dd&gt;
&lt;p&gt;MBC1 has two banking modes controlled by a mode register. In ROM banking mode (mode 0), you get access to more ROM banks but only one RAM bank. In RAM banking mode (mode 1), you can switch between multiple RAM banks but lose access to the upper ROM banks:&lt;/p&gt;
&lt;/dd&gt;
&lt;dd&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#ff636f"&gt;fn&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;write&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;address&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u16&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;value&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;match&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;address&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// RAM enable.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x0000&lt;/span&gt;&lt;span style="color:#ff636f"&gt;..=&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;0x1FFF&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ram_enabled&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;value&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x0F&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;==&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x0A&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// ROM bank number (lower 5 bits).
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x2000&lt;/span&gt;&lt;span style="color:#ff636f"&gt;..=&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;0x3FFF&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#ff636f"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;bank&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;value&lt;/span&gt; &lt;span style="color:#ff636f"&gt;as&lt;/span&gt; &lt;span style="color:#ff636f"&gt;usize&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x1F&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;bank&lt;/span&gt; &lt;span style="color:#ff636f"&gt;==&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;bank&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;; &lt;span style="color:#828b96;font-style:italic"&gt;// Bank 0 becomes bank 1.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rom_bank&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rom_bank&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x60&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;|&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;bank&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// RAM bank or upper ROM bank bits.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x4000&lt;/span&gt;&lt;span style="color:#ff636f"&gt;..=&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;0x5FFF&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;bits&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;value&lt;/span&gt; &lt;span style="color:#ff636f"&gt;as&lt;/span&gt; &lt;span style="color:#ff636f"&gt;usize&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x03&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;banking_mode&lt;/span&gt; &lt;span style="color:#ff636f"&gt;==&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rom_bank&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;bits&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;5&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;|&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rom_bank&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x1F&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; } &lt;span style="color:#ff636f"&gt;else&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ram_bank&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;bits&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Banking mode select.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x6000&lt;/span&gt;&lt;span style="color:#ff636f"&gt;..=&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;0x7FFF&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;banking_mode&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;value&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x01&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;_&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/dd&gt;
&lt;dd&gt;
&lt;p&gt;Super Mario Land (1 and 2), Wario Land, Kirby&amp;rsquo;s Dream Land, and The Amazing Spider-Man all use MBC1.&lt;/p&gt;
&lt;/dd&gt;
&lt;dt&gt;&lt;strong&gt;MBC2&lt;/strong&gt;&lt;/dt&gt;
&lt;dd&gt;MBC2 has a quirky design. It includes 512×4 bits of built-in RAM, and bit 8 of the write address determines whether you&amp;rsquo;re configuring ROM banking or RAM enable. Both operations share the same address range (&lt;code&gt;0x0000&lt;/code&gt;-&lt;code&gt;0x1FFF&lt;/code&gt;):&lt;/dd&gt;
&lt;dd&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;match&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;addr&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x0000&lt;/span&gt;&lt;span style="color:#ff636f"&gt;..=&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;0x1FFF&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;addr&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;8&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x01&lt;/span&gt; &lt;span style="color:#ff636f"&gt;==&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Bit 8 is 0: RAM enable.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ram_enabled&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;val&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x0F&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;==&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x0A&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; } &lt;span style="color:#ff636f"&gt;else&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Bit 8 is 1: ROM bank select.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rom_bank&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;val&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x0F&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rom_bank&lt;/span&gt; &lt;span style="color:#ff636f"&gt;==&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rom_bank&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;1&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// ...
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/dd&gt;
&lt;dd&gt;
&lt;p&gt;Since MBC2&amp;rsquo;s RAM is only 4 bits per byte, reads return the value OR&amp;rsquo;d with 0xF0, and writes mask to the lower 4 bits.&lt;/p&gt;
&lt;/dd&gt;
&lt;dd&gt;
&lt;p&gt;I think I have not tested any game that uses MBC2.&lt;/p&gt;
&lt;/dd&gt;
&lt;dt&gt;&lt;strong&gt;MBC3&lt;/strong&gt;&lt;/dt&gt;
&lt;dd&gt;MBC3 adds a real-time clock (RTC) alongside standard ROM/RAM banking. The RTC includes seconds, minutes, hours, and days counters that can be accessed by setting the RAM bank register to values &lt;code&gt;0x08&lt;/code&gt;-&lt;code&gt;0x0C&lt;/code&gt;. I understand that some games use the RTC to track time-based events, but I have not tested this:&lt;/dd&gt;
&lt;dd&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#ff636f"&gt;fn&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;read_ram&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;address&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;u16&lt;/span&gt;) -&amp;gt; &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#ff636f"&gt;!&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ram_enabled&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;return&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0xFF&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;match&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ram_bank&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x00&lt;/span&gt;&lt;span style="color:#ff636f"&gt;..=&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;0x03&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Standard RAM banks.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;offset&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ram_bank&lt;/span&gt; &lt;span style="color:#ff636f"&gt;*&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x2000&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;+&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;address&lt;/span&gt; &lt;span style="color:#ff636f"&gt;as&lt;/span&gt; &lt;span style="color:#ff636f"&gt;usize&lt;/span&gt; &lt;span style="color:#ff636f"&gt;-&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0xA000&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ram&lt;/span&gt;[&lt;span style="color:#58a1dd"&gt;offset&lt;/span&gt; &lt;span style="color:#ff636f"&gt;%&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;ram&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;len&lt;/span&gt;()]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x08&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rtc_seconds&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x09&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rtc_minutes&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x0A&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rtc_hours&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x0B&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rtc_days_low&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;0x0C&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rtc_days_high&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;_&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0xFF&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/dd&gt;
&lt;dd&gt;
&lt;p&gt;The RTC is latched by writing 0 then 1 to address range &lt;code&gt;0x6000&lt;/code&gt;-&lt;code&gt;0x7FFF&lt;/code&gt;. This copies the current time into the registers so the game can read a consistent value. My implementation updates the RTC with system time:&lt;/p&gt;
&lt;/dd&gt;
&lt;dd&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff636f"&gt;fn&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;update_rtc&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;now&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;SystemTime&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;now&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; .&lt;span style="color:#58a1dd"&gt;duration_since&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;UNIX_EPOCH&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; .&lt;span style="color:#58a1dd"&gt;unwrap&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; .&lt;span style="color:#58a1dd"&gt;as_secs&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rtc_seconds&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;now&lt;/span&gt; &lt;span style="color:#ff636f"&gt;%&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;60&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;as&lt;/span&gt; &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rtc_minutes&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; ((&lt;span style="color:#58a1dd"&gt;now&lt;/span&gt; &lt;span style="color:#ff636f"&gt;/&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;60&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;%&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;60&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;as&lt;/span&gt; &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rtc_hours&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; ((&lt;span style="color:#58a1dd"&gt;now&lt;/span&gt; &lt;span style="color:#ff636f"&gt;/&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;3600&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;%&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;24&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;as&lt;/span&gt; &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;days&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;now&lt;/span&gt; &lt;span style="color:#ff636f"&gt;/&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;86400&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;as&lt;/span&gt; &lt;span style="color:#ff636f"&gt;u16&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rtc_days_low&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;days&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0xFF&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;as&lt;/span&gt; &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Day MSB and carry flag.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;day_msb&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; ((&lt;span style="color:#58a1dd"&gt;days&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;8&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x01&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;as&lt;/span&gt; &lt;span style="color:#ff636f"&gt;u8&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rtc_days_high&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; (&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rtc_days_high&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0xFE&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;|&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;day_msb&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;days&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;gt;&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;511&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;rtc_days_high&lt;/span&gt; &lt;span style="color:#ff636f"&gt;|=&lt;/span&gt; &lt;span style="color:#a6be9d"&gt;0x80&lt;/span&gt;; &lt;span style="color:#828b96;font-style:italic"&gt;// Set overflow flag.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/dd&gt;
&lt;dd&gt;
&lt;p&gt;Pokémon and Wario Land II use MBC3.&lt;/p&gt;
&lt;/dd&gt;
&lt;/dl&gt;
&lt;p&gt;Finally, I implemented battery-backed SRAM. It meant saving the contents of cartridge RAM to &lt;code&gt;.sav&lt;/code&gt; files when the emulator exits and loading them on startup, if they exist. You can also trigger the save operation at any time with &lt;kbd&gt;w&lt;/kbd&gt;. I get the RAM from the cartridge, and then persist it (if not empty):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#1d2432;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;/// Save SRAM of current cartridge to `.sav` file.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;pub&lt;/span&gt; &lt;span style="color:#ff636f"&gt;fn&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;save_sram&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;rom_path&lt;/span&gt;: &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#ff636f"&gt;str&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;save_path&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Path&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;new&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;rom_path&lt;/span&gt;).&lt;span style="color:#58a1dd"&gt;with_extension&lt;/span&gt;(&lt;span style="color:#a6be9d"&gt;&amp;#34;sav&amp;#34;&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#828b96;font-style:italic"&gt;// Only save if the mapper actually has RAM.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;ram_data&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#ff636f"&gt;match&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;cart_type&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;CartridgeType&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;MBC1&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;mbc&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;mbc&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;get_ram&lt;/span&gt;(),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;CartridgeType&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;MBC2&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;mbc&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;mbc&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;get_ram&lt;/span&gt;(),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;CartridgeType&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;MBC3&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;mbc&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;mbc&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;get_ram&lt;/span&gt;(),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;_&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&amp;gt;&lt;/span&gt; &lt;span style="color:#ff636f"&gt;return&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; };
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#ff636f"&gt;!&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;ram_data&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;is_empty&lt;/span&gt;() &lt;span style="color:#ff636f"&gt;||&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;matches!&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;self&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;cart_type&lt;/span&gt;, &lt;span style="color:#58a1dd"&gt;CartridgeType&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;MBC2&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;_&lt;/span&gt;)) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;if&lt;/span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;Ok&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;mut&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;file&lt;/span&gt;) &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;File&lt;/span&gt;::&lt;span style="color:#58a1dd"&gt;create&lt;/span&gt;(&lt;span style="color:#ff636f"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#58a1dd"&gt;save_path&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff636f"&gt;let&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;_&lt;/span&gt; &lt;span style="color:#ff636f"&gt;=&lt;/span&gt; &lt;span style="color:#58a1dd"&gt;file&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;write_all&lt;/span&gt;(&lt;span style="color:#58a1dd"&gt;ram_data&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;println!&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;: Save data written to disk: &lt;/span&gt;&lt;span style="color:#a6be9d"&gt;{}&lt;/span&gt;&lt;span style="color:#a6be9d"&gt;&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6be9d"&gt;&amp;#34;WR&amp;#34;&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;magenta&lt;/span&gt;(),
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#58a1dd"&gt;save_path&lt;/span&gt;.&lt;span style="color:#58a1dd"&gt;display&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; );
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The load operation checks for the file, loads it if it exists, and passes it to each MBC&amp;rsquo;s &lt;code&gt;set_ram()&lt;/code&gt; method.&lt;/p&gt;
&lt;p&gt;This feature is essential for saving progress in many games. Currently, the &lt;code&gt;.sav&lt;/code&gt; files are written to the same directory where the ROM file is, with the same name and &lt;code&gt;sav&lt;/code&gt; extension.&lt;/p&gt;
&lt;h2 id="ui-and-debug-mode"&gt;UI and debug mode&lt;/h2&gt;
&lt;p&gt;In order to help me debug the emulator as development progressed, I implemented a terminal-based debug interface that let me look under the hood and inspect the internal state of the machine. This is implemented in the &lt;code&gt;src/debugcli.rs&lt;/code&gt; file in the repository, but it is now unused.&lt;/p&gt;
&lt;p&gt;When the emulator was already working quite well, I implemented a GUI debug mode with a custom immediate-mode UI library built on top of SDL2 rendering. You can enable it at any time with &lt;kbd&gt;d&lt;/kbd&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;abbr title="User Interface"&gt;UI&lt;/abbr&gt; library supports horizontal and vertical layouts, labels, buttons, and text fields. Enough to build a useful debug interface without pulling in a fat dependency. It&amp;rsquo;s minimal but effective. Writing it was not trivial. I knew that UI libraries are complex and take a lot of work to get right, and this little spin-off sub-project only confirmed this knowledge.&lt;/p&gt;
&lt;figure class="fig-center"&gt;&lt;picture&gt;&lt;img src="https://tonisagrista.com/img/playkid/debug-mode-sdl2.avif"
alt="Super Mario Land 2 running in Play Kid with the debug UI." loading="lazy"decoding="async" /&gt;
&lt;/picture&gt;&lt;figcaption&gt;
&lt;p class="fig-attribution"&gt;Super Mario Land 2 running in Play Kid with the debug UI.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The debug mode features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Current instruction, operand, and PC&lt;/li&gt;
&lt;li&gt;Opcode&lt;/li&gt;
&lt;li&gt;CPU status (either RUNNING or HALTED)&lt;/li&gt;
&lt;li&gt;M- and T-cycles&lt;/li&gt;
&lt;li&gt;CPU registers and flags&lt;/li&gt;
&lt;li&gt;PPU state (LCDC, STAT, LYC, LY, LX)&lt;/li&gt;
&lt;li&gt;Joypad state for all inputs (U, D, L, R, A, B, START, SELECT)&lt;/li&gt;
&lt;li&gt;Breakpoints&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can step through execution one instruction (&lt;kbd&gt;F6&lt;/kbd&gt;) or one scanline (&lt;kbd&gt;F7&lt;/kbd&gt;) at a time, pause and continue (&lt;kbd&gt;F9&lt;/kbd&gt;), and set breakpoints at specific addresses. You can also reset the CPU with &lt;kbd&gt;r&lt;/kbd&gt;. I also included an FPS counter (&lt;kbd&gt;f&lt;/kbd&gt;). You can enable and disable the debug mode at any time with &lt;kbd&gt;d&lt;/kbd&gt;.&lt;/p&gt;
&lt;p&gt;One quality-of-life feature I&amp;rsquo;m proud of is automatic &lt;abbr title="Dots per Inch"&gt;DPI&lt;/abbr&gt; scaling. On high-DPI displays, the UI scales appropriately so everything remains readable without manual configuration. This was a pain to handle manually. The emulator also respects the Game Boy&amp;rsquo;s 160:144 aspect ratio by letterboxing the display. It&amp;rsquo;s a small detail, but it ensures games look correct rather than stretched or squashed.&lt;/p&gt;
&lt;h2 id="future-work"&gt;Future work&lt;/h2&gt;
&lt;p&gt;Here are some items I would like to do at some point:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Move away from SDL2 in favor of Rust-native libraries like &lt;code&gt;winit&lt;/code&gt;+&lt;code&gt;pixels&lt;/code&gt;+&lt;code&gt;rodio&lt;/code&gt; for easy cross-compilation, and web. I actually have a branch where I implemented the APU using &lt;code&gt;rodio&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Implement more MBCs if I stumble upon games that need them.&lt;/li&gt;
&lt;li&gt;Add Game Boy Color support. As I understand it, it is not too complicated.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="conclusions"&gt;Conclusions&lt;/h2&gt;
&lt;p&gt;Building Play Kid taught me more about computer architecture, bit manipulation, and systems programming than any tutorial could. Every component of the Game Boy (CPU, memory, graphics, audio) works in concert, and understanding their interactions required patience, testing, and a lot of debugging.&lt;/p&gt;
&lt;p&gt;Rust was an excellent choice for this project. My CHIP-8 emulator was already written in Rust, so this was a safe choice for me. I think the language has been praised enough at this time, so I&amp;rsquo;ll spare this part. SDL2 provided a simple, cross-platform foundation for rendering, input, and audio without unnecessary complexity.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;re interested in emulation, I think the Game Boy is a solid choice as first project if you have previous programming experience. The documentation is excellent and the hardware is approachable.&lt;/p&gt;
&lt;p&gt;Play Kid&amp;rsquo;s source code is on &lt;a href="https://codeberg.org/langurmonkey/playkid"&gt;Codeberg&lt;/a&gt; and &lt;a href="https://github.com/langurmonkey/playkid"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;</description></item></channel></rss>