<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xml" href="/feed.xslt.xml"?>

<feed xmlns="http://www.w3.org/2005/Atom">
    <generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator>
    <link href="https://blog.omgmog.net/feed.xml" rel="self" type="application/atom+xml" />
    <link href="https://blog.omgmog.net/" rel="alternate" type="text/html" />
    <updated>2026-06-17T14:03:46+00:00</updated>
    <id>https://blog.omgmog.net/feed.xml</id>
    <title type="html">Max Glenister</title>
    
    <subtitle>Max Glenister is an interface designer and senior full-stack developer from Oxfordshire. He writes mostly about front-end development and technology.</subtitle>
    <author>
        <name>Max Glenister</name>
    </author>
    
    <icon>https://omgmog.net/faceicon.png</icon>
    
    <entry>
        <title type="html">Sending webmentions on deploy</title>
        <link href="https://blog.omgmog.net/post/sending-webmentions-on-deploy/" rel="alternate" type="text/html" title="Sending webmentions on deploy" />
        <link href="https://blog.omgmog.net/post/sending-webmentions-on-deploy/.txt" rel="alternate" type="text/plain" title="Sending webmentions on deploy (Plain Text)" />
        <published>2026-06-17T00:00:00+00:00</published>
        <updated>2026-06-17T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/sending-webmentions-on-deploy/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="indieweb" />
        
        <category term="jekyll" />
        
        <category term="programming" />
        
        <summary type="html"><![CDATA[I’ve spent a fair bit of time on the receiving end of webmentions, from adding support for them in the first place through to fetching them at build time and rendering them server-side so the discussion section doesn’t flicker in over JS. What I’d never actually done was send the other half. Every link I’ve ever made out to another IndieWeb site has just sat there, silently not telling anyone I’d linked to them.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/sending-webmentions-on-deploy/"><![CDATA[<p>I’ve spent a fair bit of time on the receiving end of webmentions, from <a href="/post/adding-support-for-webmentions/">adding support for them</a> in the first place through to <a href="/post/baking-webmentions-into-the-build/">fetching them at build time</a> and rendering them server-side so the discussion section doesn’t flicker in over JS. What I’d never actually done was send the other half. Every link I’ve ever made out to another IndieWeb site has just sat there, silently not telling anyone I’d linked to them.</p>

<!-- more -->

<p>Webmentions are two-sided. When I publish a post that links to someone else’s site, I’m supposed to ping their webmention endpoint to say “hey, I mentioned you”. That’s how IndieWeb replies and references actually close the loop, rather than just being a one-way pull. I’d been shouting into the void for years. Hopefully now some of it gets heard.</p>

<h2 id="hooking-into-the-deploy">Hooking into the deploy</h2>

<p>I’m already using <a href="https://webmention.io">webmention.io</a> to receive mentions, but sending them needed something else. <a href="https://webmention.app">webmention.app</a> has a simple <code class="language-plaintext highlighter-rouge">/check</code> endpoint that, given a URL, crawls the page, finds any outbound links, and sends webmentions to whichever of them support it. Point it at a post and it does the discovery and delivery for you.</p>

<p>So the new piece is a small Ruby script, <code class="language-plaintext highlighter-rouge">scripts/send_webmentions.rb</code>, that takes a list of post paths and pings webmention.app for each one:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">paths</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">path</span><span class="o">|</span>
  <span class="n">url</span> <span class="o">=</span> <span class="s2">"</span><span class="si">#{</span><span class="no">SITE_URL</span><span class="si">}</span><span class="s2">/post/</span><span class="si">#{</span><span class="n">slug_for</span><span class="p">(</span><span class="n">path</span><span class="p">)</span><span class="si">}</span><span class="s2">/"</span>
  <span class="n">uri</span> <span class="o">=</span> <span class="no">URI</span><span class="p">(</span><span class="s1">'https://webmention.app/check'</span><span class="p">)</span>
  <span class="n">params</span> <span class="o">=</span> <span class="p">{</span> <span class="ss">url: </span><span class="n">url</span> <span class="p">}</span>
  <span class="n">params</span><span class="p">[</span><span class="ss">:token</span><span class="p">]</span> <span class="o">=</span> <span class="no">TOKEN</span> <span class="k">if</span> <span class="no">TOKEN</span>
  <span class="n">uri</span><span class="p">.</span><span class="nf">query</span> <span class="o">=</span> <span class="no">URI</span><span class="p">.</span><span class="nf">encode_www_form</span><span class="p">(</span><span class="n">params</span><span class="p">)</span>

  <span class="n">res</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="p">.</span><span class="nf">post</span><span class="p">(</span><span class="n">uri</span><span class="p">,</span> <span class="kp">nil</span><span class="p">)</span>
  <span class="nb">puts</span> <span class="s2">"</span><span class="si">#{</span><span class="n">url</span><span class="si">}</span><span class="s2"> -&gt; </span><span class="si">#{</span><span class="n">res</span><span class="p">.</span><span class="nf">code</span><span class="si">}</span><span class="s2">"</span>
<span class="k">end</span>
</code></pre></div></div>

<p>I’ve added this as a new job in the existing <code class="language-plaintext highlighter-rouge">jekyll.yml</code> workflow, running after the deploy job finishes. It diffs <code class="language-plaintext highlighter-rouge">_posts</code> between the previous and current commit, and only sends webmentions for posts that actually changed in that push.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Send webmentions for changed posts</span>
  <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">changed=$(git diff --name-only "$" "$" -- _posts | tr '\n' ' ')</span>
    <span class="s">if [ -n "$changed" ]; then</span>
      <span class="s">bundle exec ruby scripts/send_webmentions.rb $changed</span>
    <span class="s">else</span>
      <span class="s">echo "No changed posts in this push"</span>
    <span class="s">fi</span>
</code></pre></div></div>

<figure class="image ">

<picture>

<source srcset="/images/2026-06/pipeline-webmention-send.png.webp" type="image/webp" />

<img src="/images/2026-06/pipeline-webmention-send.png" loading="lazy" alt="" />
</picture>






</figure>

<p>There’s also a manual <code class="language-plaintext highlighter-rouge">workflow_dispatch</code> input to send webmentions for every post in one go, for backfilling everything I’ve published without ever notifying anyone. The usual build takes about a minute. The backfill run took twelve, working through all 268 posts one at a time and waiting on webmention.app to crawl each page and chase down its outbound links. Sorry, webmention.app, for landing 268 URLs on you in one go.</p>

<p>I’m now relying on three separate free services to make this all work. webmention.io receives mentions, brid.gy bridges Mastodon and other social replies into webmentions in the first place, and webmention.app sends them out on my behalf. None of it is self-hosted, none of it has an SLA, and if any one of the three quietly shuts down or changes its API I won’t notice until mentions stop showing up or my GitHub Actions run starts failing. I touched on this same worry in the <a href="/post/baking-webmentions-into-the-build/">previous post</a>, and adding a third dependency hasn’t made it go away. It’s still better than the alternative of not bothering at all, but the whole thing is built on infrastructure I don’t control.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">How I take my coffee</title>
        <link href="https://blog.omgmog.net/post/how-i-take-my-coffee/" rel="alternate" type="text/html" title="How I take my coffee" />
        <link href="https://blog.omgmog.net/post/how-i-take-my-coffee/.txt" rel="alternate" type="text/plain" title="How I take my coffee (Plain Text)" />
        <published>2026-06-16T00:00:00+00:00</published>
        <updated>2026-06-16T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/how-i-take-my-coffee/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="coffee" />
        
        <category term="indieweb" />
        
        <summary type="html"><![CDATA[My default for making coffee for two is a cafetière. French press if you insist. It requires almost no thought, produces a consistent result, and has no moving parts to break. When I’ve got a bit more time and want a cleaner cup for two or more, I’ll reach for a Bodum pour-over instead. When it’s just me, I’ve been using a Clever Dripper more lately (it suits the days where I want slightly more control without committing to the full V60 ritual). The Hario V60 was my main single-cup pour-over for a long time before that.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/how-i-take-my-coffee/"><![CDATA[<p>My default for making coffee for two is a cafetière. French press if you insist. It requires almost no thought, produces a consistent result, and has no moving parts to break. When I’ve got a bit more time and want a cleaner cup for two or more, I’ll reach for a <a href="https://www.bodum.com/gb/en/11571-109-pour-over">Bodum pour-over</a> instead. When it’s just me, I’ve been using a <a href="https://cleverbrewing.coffee/products/clever-dripper">Clever Dripper</a> more lately (it suits the days where I want slightly more control without committing to the full V60 ritual). The <a href="https://www.hario.co.uk/collections/hario-v60-coffee-drippers">Hario V60</a> was my main single-cup pour-over for a long time before that.</p>

<!-- more -->

<p>I’ve been on <a href="https://aeropress.com/products/aeropress-coffee-maker">AeroPress</a> since 2011. I’m probably on my third or fourth one at this point, as the rubber seals don’t last and the hard plastic eventually cracks. Mine is a bit of a <a href="https://youtu.be/56yN2zHtofM?t=128">Trigger’s broom</a> at this stage (replacement seals, a metal mesh filter, a <a href="https://fellowproducts.com/products/prismo">Fellow Prismo</a> cap). I’m not sure how much of the original unit remains.</p>

<p>For cold brew I have a <a href="https://www.hario.co.uk/products/hario-mizudashi-cold-brew-coffee-maker-brown-1l">Hario Mizudashi</a> that I can never quite seem to get organised enough to use. By the time I remember it exists, the weather has already turned.</p>

<p>Medium roasts. South American as a default, particularly Colombian blends. I have a soft spot for single origin Guatemalan (Huehuetenango specifically, if I can get it).</p>

<p>Dark roasts are a hard no. French roast, Italian roast. Both taste like someone set fire to the beans and then bottled the smoke. Starbucks tastes like cigarette ash.</p>

<p>When I’m out I’ll go for a flat white. At home it’s black or with a small splash of milk depending on the method. I have a <a href="https://www.lavazzamena.com/en/milk-frother/milkeasy">Lavazza A Modo Mio milk frother</a> which I use mainly for the kids’ hot chocolates rather than anything for myself.</p>

<p>For the cafetière it’s about 3 scoops for two people, three minutes steep, then pour with a small splash of milk.</p>

<p>That’s it. I used to weigh beans to the gram, dial in grind size, track bloom times. These days, convenience wins. Life, work, kids. I mostly buy pre-ground coffee I can rely on rather than beans I have to think about.</p>

<p>Three to four mugs a day. More if it’s a bad sprint, less if it’s a holiday.</p>

<blockquote>
  <p>This post is in response to an IndieWeb thread that started with <a href="https://hakkerman.eu/blog/i-wont-buy-you-a-coffee/">Hakkerman</a> arguing that “buy me a coffee” links are a symptom of the internet’s rampant monetisation creep. <a href="https://www.gordonmclean.co.uk/2026/06/11/yes-buy-me-a-coffee/">Gordon McLean</a>, <a href="https://michaelharley.net/posts/2026/06/11/re-no-i-wont-buy-you-a-coffee/">Michael Harley</a>, <a href="https://gurupanguji.com/blog/2026/06/11/no-i-wont-buy-you-a-coffee/">Gurupanguji</a>, and <a href="https://brennan.day/yes-buy-them-a-coffee-support-and-mutual-aid-on-the-indieweb/">Brennan Brown</a> all weighed in. Then Nathan Ferrell <a href="https://social.emucafe.org/naferrell/how-ill-take-my-coffee-06-15-26/">wrote about his coffee habits</a> and I decided that was the angle worth pursuing.</p>

  <p>On the monetisation question: I’ve tried it. Google Ads, a Ko-fi link, a PayPal donate button at various points over the years. It was more hassle than it was worth. What I get from writing here is not money. It’s a bit of kudos, the occasional interesting conversation, and mostly just getting things out of my head. I write about things I find interesting because I want to document them, not because I’m writing for an audience. If you get something out of it too, great.</p>
</blockquote>]]></content>
    </entry>
    
    <entry>
        <title type="html">Inside the BBC&apos;s 3D World Cup viewer</title>
        <link href="https://blog.omgmog.net/post/bbc-3d-world-cup-viewer/" rel="alternate" type="text/html" title="Inside the BBC&apos;s 3D World Cup viewer" />
        <link href="https://blog.omgmog.net/post/bbc-3d-world-cup-viewer/.txt" rel="alternate" type="text/plain" title="Inside the BBC&apos;s 3D World Cup viewer (Plain Text)" />
        <published>2026-06-15T00:00:00+00:00</published>
        <updated>2026-06-15T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/bbc-3d-world-cup-viewer/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="web-development" />
        
        <category term="world-cup" />
        
        <summary type="html"><![CDATA[While updating scores for AIWC26 this week I came across BBC Sport’s 3D World Cup viewer, which launched on 12 June for matches broadcast on the BBC. Every match played so far is sitting there to replay in full, not just the live ones. It’s UK-only, it’s a beta, and somehow this is my second football post in a row, despite still having zero interest in the sport itself. The engineering underneath it is another matter.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/bbc-3d-world-cup-viewer/"><![CDATA[<p>While updating scores for <a href="https://blog.omgmog.net/AIWC26/">AIWC26</a> this week I came across <a href="https://www.3d-bbc.co.uk/">BBC Sport’s 3D World Cup viewer</a>, which launched on 12 June for matches broadcast on the BBC. Every match played so far is sitting there to replay in full, not just the live ones. It’s UK-only, it’s a beta, and somehow this is my second football post in a row, despite still having <em>zero</em> interest in the sport itself. The engineering underneath it is another matter.</p>

<!-- more -->

<figure class="image  center">

<picture>

<source srcset="/images/2026-06/bbc-football/overview.png.webp" type="image/webp" />

<img src="/images/2026-06/bbc-football/overview.png" loading="lazy" alt="In-game overview of Belgium vs Egypt, first half" />
</picture>






</figure>

<p>The viewer is a Unity WebGL build, compiled to WebAssembly and running natively in the browser. The technology comes from XR company <a href="https://www.immersiv.io/">Immersiv.io</a>, consuming official FIFA EPTS telemetry, the <a href="https://inside.fifa.com/innovation/standards/epts/research-development-epts-standard-data-format">standardised Electronic Performance and Tracking Systems format</a> developed after <a href="https://www.theifab.com/laws/latest/the-players-equipment/">IFAB’s 2015 mandate</a> to allow wearable tracking in official matches. Every player’s position and skeletal joint data is captured at 25Hz and streamed to the browser, where the Unity runtime drives character transforms directly from the feed. That’s what makes the first-person view work (the viewer occupies a skeletal position in the data rather than watching from a camera).</p>

<p>I had a go at the first-person view, and it works far better than I expected (<a href="https://www.creativebloq.com/3d/i-tried-the-bbcs-new-3d-world-cup-app-and-football-suddenly-felt-like-a-video-game">Creative Bloq had much the same reaction</a>). It’s got the pitch and player fidelity of a FIFA game from the 90s, running on a rendering engine from the early 2000s, decent shadows and all, and the fact that it’s all running in a browser tab on whatever device you’ve got to hand is the genuinely impressive part. I dabbled in a Unity-to-WebGL pipeline in a past role (at <a href="https://scriptic.com/">Scriptic</a>), so I’ve got some appreciation for how much pain goes into shipping something like this, never mind the live-data plumbing on top. Aside from the players and ball occasionally teleporting around or snapping between poses, it holds together well.</p>

<figure class="image  center">

<picture>

<img src="/images/2026-06/bbc-football/first-person.gif" loading="lazy" alt="Player and ball positions snapping between poses in the first-person view" />
</picture>






</figure>

<p>None of this comes from trackers on the players. The skeletal data is derived from <a href="https://www.gearbrain.com/2026-world-cup-sports-tech-2676763817.html">16 optical tracking cameras installed in every World Cup stadium</a>, the same setup behind semi-automated offside, watching the pitch and working out where every limb is, 50 times a second. Wearable sensors exist too, but those are for fitness data like heart rate and sprint load, not the feed driving this viewer.</p>

<p>Each player has a full set of licensed kit textures served from Immersiv.io’s Cloudflare R2 CDN, laid out as UV maps across separate mesh components for top, bottom and shoes. The player ID, team, kit colour and resolution tier are all encoded in the asset path, so the runtime constructs texture URLs dynamically from the roster data it’s already receiving.</p>

<div class="inline-grid three-columns">
<figure class="image ">

<picture>

<source srcset="/images/2026-06/bbc-football/shirt.png.webp" type="image/webp" />

<img src="/images/2026-06/bbc-football/shirt.png" loading="lazy" alt="Egypt shirt UV map, M. Salah #10" />
</picture>






</figure>

<figure class="image ">

<picture>

<source srcset="/images/2026-06/bbc-football/shorts.png.webp" type="image/webp" />

<img src="/images/2026-06/bbc-football/shorts.png" loading="lazy" alt="Egypt shorts UV map, #10" />
</picture>






</figure>

<figure class="image ">

<picture>

<source srcset="/images/2026-06/bbc-football/shoes.png.webp" type="image/webp" />

<img src="/images/2026-06/bbc-football/shoes.png" loading="lazy" alt="Boot UV map" />
</picture>






</figure>

</div>

<p>At half time the score and team flags appear as cards rendered in the same 3D space as the pitch, rather than a flat overlay.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-06/bbc-football/half-time.png.webp" type="image/webp" />

<img src="/images/2026-06/bbc-football/half-time.png" loading="lazy" alt="Half-time overview, Belgium 0-1 Egypt" />
</picture>






</figure>

<p>The timeline UI is worth a look. The bar chart running along the bottom is a per-team shot/danger heatmap across the match, with event markers below it for cards, chances and goals. That’s a structured event feed separate from the positional telemetry, rendered as a scrubbable timeline with the full match loaded even during a live game.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-06/bbc-football/timeline.png.webp" type="image/webp" />

<img src="/images/2026-06/bbc-football/timeline.png" loading="lazy" alt="The timeline view, showing the shot heatmap and event markers" />
</picture>






</figure>

<p>There’s an audio toggle in the top bar which pulls in a live BBC commentary stream over CMAF/HLS, with a latency offset that keeps the commentary locked to the positional data. It works well.</p>

<p>Drilling into a goal from the shotmap is where it gets fun. Pick “20’ Goal” and the viewer replays the shot from pitch level, ball trajectory traced in green, with the shot stats (total, on target, missed) pulled from the same event feed as the timeline. It’s the sort of feature I’d expect from a professional match analysis tool, not a casual viewing feature.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-06/bbc-football/goal-replay.png.webp" type="image/webp" />

<img src="/images/2026-06/bbc-football/goal-replay.png" loading="lazy" alt="Shotmap replay of E. Ashour's 20th-minute goal for Egypt against Belgium" />
</picture>






</figure>

<p>It’s a lot of engineering for what’s ostensibly just an extra bolted onto the BBC’s coverage, and if my TV licence is helping pay for stuff like this, I’m not complaining. I’ve still got no real reason to use it (see: zero interest in football), but I’ll be straight back into devtools next time there’s a match on.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">Five AIs predict the World Cup</title>
        <link href="https://blog.omgmog.net/post/five-ais-predict-the-world-cup/" rel="alternate" type="text/html" title="Five AIs predict the World Cup" />
        <link href="https://blog.omgmog.net/post/five-ais-predict-the-world-cup/.txt" rel="alternate" type="text/plain" title="Five AIs predict the World Cup (Plain Text)" />
        <published>2026-06-11T00:00:00+00:00</published>
        <updated>2026-06-11T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/five-ais-predict-the-world-cup/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="ai" />
        
        <category term="llm" />
        
        <category term="world-cup" />
        
        <summary type="html"><![CDATA[The World Cup rolls around every four years, and with it the office sweepstake, the ritual where everyone gets handed a team at random and pretends to care how it does. I have no interest in football (I had to read a beginner’s guide to how it all works just to follow what’s going on), and I definitely don’t know enough to make an informed prediction of my own. So I thought I’d hand the job to five AI models instead, get them to commit their predictions in public, and keep score. The result is AIWC26, a page that shows every group-stage prediction from five models side by side, scores them against the real results as they come in, and ranks the models in a league table. It looks like Ceefax, because of course it does.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/five-ais-predict-the-world-cup/"><![CDATA[<p>The World Cup rolls around every four years, and with it the office sweepstake, the ritual where everyone gets handed a team at random and pretends to care how it does. I have no interest in football (I had to read <a href="https://www.theguardian.com/football/2026/jun/09/a-very-beginners-guide-to-the-world-cup-how-does-it-work-and-the-players-to-look-out-for">a beginner’s guide to how it all works</a> just to follow what’s going on), and I definitely don’t know enough to make an informed prediction of my own. So I thought I’d hand the job to five AI models instead, get them to commit their predictions in public, and keep score.</p>

<p>The result is <a href="https://blog.omgmog.net/AIWC26/">AIWC26</a>, a page that shows every group-stage prediction from five models side by side, scores them against the real results as they come in, and ranks the models in a league table. It looks like <a href="https://en.wikipedia.org/wiki/Ceefax">Ceefax</a>, because of course it does.</p>

<!-- more -->

<figure class="image  center">

<picture>

<img src="/images/2026-06/ludicrous-display-ezgif.com-optimize.gif" loading="lazy" alt="" />
</picture>






</figure>

<h2 id="the-setup">The setup</h2>

<p>Each model got <a href="https://github.com/omgmog/AIWC26/blob/main/prompt.md">an identical prompt</a>. Here are the 72 group-stage fixtures, predict every scoreline, consider historical World Cup performance, recent form, squad make-up and venue factors, and return strict JSON. The AI entrants were Claude Fable 5 and Claude Sonnet 4.6 (Anthropic), GPT-5.5 (OpenAI), and Gemini 3.1 Pro and Gemini 3.5 Flash (Google).</p>

<p>There’s also a sixth entry that isn’t an AI at all, the Ranking Bot. It knows two things, the <a href="https://www.fifa.com/en/fifa-world-ranking">FIFA world rankings</a> from April and one rule. Teams within ten places of each other draw 1-1, otherwise the higher-ranked team wins 2-1. Twenty lines of Python, no model, no judgement, fully deterministic, anyone can regenerate its picks and get an identical file. It exists to answer the question I actually cared about. Can five models beat a lookup table?</p>

<p>Scoring is the classic sweepstake format, 3 points for the exact score, 1 point for the right result, nothing otherwise.</p>

<p>All six entries were <a href="https://github.com/omgmog/AIWC26/tree/main/_data/predictions">committed to the repo</a> before today’s opening match, and the git history is the audit trail. Nobody gets to quietly revise their picks after Mexico go 2-0 up.</p>

<h2 id="the-build">The build</h2>

<p>The site is Jekyll, deployed with GitHub Actions. A custom plugin does the scoring at build time, so the served HTML contains the full tables and works with JavaScript disabled (JS only handles the clock and the group filter).</p>

<p>Entering a result is a one-liner:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ tools/results.py set A1 2 0
</code></pre></div></div>

<p>Then commit, push, and the Actions workflow rebuilds and deploys. The raw JSON stays published under <a href="https://blog.omgmog.net/AIWC26/data/fixtures.json">/data/</a> for anyone who fancies their own analysis.</p>

<h2 id="what-the-picks-already-tell-us">What the picks already tell us</h2>

<p>The predictions are locked, so before a ball is kicked I can say what the models think is going to happen. The short version is nothing surprising, ever.</p>

<p>Across all 360 predictions there is not a single major upset. No seed loses to a debutant. Nothing on the scale of Saudi Arabia beating Argentina in <a href="https://en.wikipedia.org/wiki/2022_FIFA_World_Cup">2022</a>, which actually happened, in the opening round, to the eventual champions. The boldest call anywhere on the page is both Gemini models taking Ghana, ranked 74th, to beat 33rd-ranked Panama, which is roughly as spicy as preferring one flavour of rice cake to another.</p>

<p>The strangest consensus on the page is Belgium. All five models hand them a perfect nine points, and so does the Ranking Bot, working purely off the April list that still has them ninth in the world. The press is less sure: previews tend to file Belgium under faded golden generation, big names past their best, a dark horse at most. The gap is the interesting part. The models are not arguing with the rankings, they are inheriting them, and the rankings have been slow to mark down a side that sat at the top of them for years. Five models sounding certain about Belgium is less five opinions than one lagging dataset in five voices, which is the consensus problem the whole page is about.</p>

<p>The consensus runs deeper than individual fixtures. Because the knockout bracket is predetermined, each model’s 72 picks imply complete group tables, and from there an entire <a href="https://en.wikipedia.org/wiki/2026_FIFA_World_Cup_knockout_stage">last 32</a> (an extra knockout round added by this year’s 48-team expansion, feeding into the round of 16 as before). Across the five models, 30 of the 32 implied qualifiers are identical. Three teams, Bosnia, Paraguay and Türkiye, are left arguing over the last two seats. Whatever these models are doing, “five independent opinions” isn’t it.</p>

<p>Then there’s the draws problem. A football fan friend looked at the picks and said there’s no way there are enough draws in them. His reasoning is that with the <a href="https://en.wikipedia.org/wiki/2026_FIFA_World_Cup">new 48-team format</a>, the top two from each group <em>plus</em> the eight best third-placed teams advance. Two thirds of the field survives the groups. A draw is almost never a bad result, and in the final round it can suit both teams at once. He expects a cagey, draw-heavy group stage, like <a href="https://en.wikipedia.org/wiki/UEFA_Euro_2016">Euro 2016</a> produced under the same kind of safety net.</p>

<p>The models, fixture by fixture, mostly disagree.</p>

<table class="massive">
  <thead>
    <tr>
      <th>Model</th>
      <th>Predicted draws (of 72)</th>
      <th>Rate</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Ranking Bot</td>
      <td>10</td>
      <td>14%</td>
    </tr>
    <tr>
      <td>Claude Fable 5</td>
      <td>12</td>
      <td>17%</td>
    </tr>
    <tr>
      <td>Claude Sonnet 4.6</td>
      <td>13</td>
      <td>18%</td>
    </tr>
    <tr>
      <td>GPT-5.5</td>
      <td>21</td>
      <td>29%</td>
    </tr>
    <tr>
      <td>Gemini 3.1 Pro</td>
      <td>10</td>
      <td>14%</td>
    </tr>
    <tr>
      <td>Gemini 3.5 Flash</td>
      <td>12</td>
      <td>17%</td>
    </tr>
    <tr>
      <td>Human 1.0</td>
      <td>24</td>
      <td>33%</td>
    </tr>
  </tbody>
</table>

<p>World Cup group stages have historically run at roughly a quarter draws, before you factor in the new format’s incentives. So most of the models are underweight. The odd part is that the models clearly know draws are common (1-1 is joint-top of all 360 predicted scorelines, level with 2-0), they just don’t apply that base rate when you ask about matches one at a time.</p>

<p>The friend who spotted the draws problem initially declined to put his own picks on the record, which seemed fair enough, it’s my website and his reputation.</p>

<p>The bot disagrees with all five models on exactly five fixtures, including calling Brazil v Morocco a draw (they are ranked 6th and 8th, two places apart) and backing Senegal to beat Norway outright. And in a twist I enjoyed, no AI model predicts fewer draws than the bot does: its 10 of 72 (14%) ties Gemini 3.1 Pro for the lowest rate on the page. So if the draw-heavy theory was right, everyone on this page was wrong together, and the theory’s author was watching from the safety of not having entered.</p>

<h2 id="human-control">Human Control</h2>

<p>Then he changed his mind. He’s submitted picks of his own, filed as the seventh column on the page under Human 1.0. Unlike the other six, these didn’t arrive before kickoff, they landed about twenty minutes after Mexico’s opening match started, including his 2-1 for that very game. He says he wrote the whole lot down beforehand and was just slow sending them over. I believe him, so it’s in and it counts, but it doesn’t get the git-history alibi the rest of the page has.</p>

<p>His picks are everything you could have wanted from a human control. 24 of his 72 picks are draws, 33%, clear of GPT-5.5’s 29% and more than double the bot’s 14%. He hasn’t just stated the thesis, he’s bet the coupon on it.</p>

<p>His most-picked scoreline is 1-1 by a mile, and he’s contributed the only 0-0 in the entire dataset (Uzbekistan v DR Congo), a scoreline five models and a lookup table collectively refused to imagine. He also owns the boldest pick on the page by a distance: New Zealand, ranked 85th, to beat Iran, ranked 21st, a 64-place upset that makes the Geminis’ Ghana call look timid. Then there’s South Korea winning at the Azteca, and France held to draws by both Senegal and Norway, which leaves his Group I in a three-way tie on five points.</p>

<p>Where the models predicted a tournament without surprises, he’s predicted one made of almost nothing else.</p>

<h2 id="what-happens-next">What happens next</h2>

<p>Results go in as the group stage plays out, the table updates, and the colours fill in. Predictions for fixtures that haven’t kicked off yet stay white, exact scores go green, right results go cyan, and wrong ones go red, which I expect to be the dominant colour by the end of June.</p>

<p>I’ll write up the verdict after the group stage finishes on the 27th. Until then the table is at <a href="https://blog.omgmog.net/AIWC26/">blog.omgmog.net/AIWC26</a>, updating slightly slower than the <a href="https://en.wikipedia.org/wiki/Vidiprinter">vidiprinter</a>.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">The Anbernic RG DS</title>
        <link href="https://blog.omgmog.net/post/anbernic-rg-ds-review/" rel="alternate" type="text/html" title="The Anbernic RG DS" />
        <link href="https://blog.omgmog.net/post/anbernic-rg-ds-review/.txt" rel="alternate" type="text/plain" title="The Anbernic RG DS (Plain Text)" />
        <published>2026-06-10T00:00:00+00:00</published>
        <updated>2026-06-10T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/anbernic-rg-ds-review/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="android" />
        
        <category term="review" />
        
        <category term="hardware" />
        
        <category term="handheld" />
        
        <summary type="html"><![CDATA[Anbernic sent me an RG DS to review. It’s a clamshell Android handheld designed to evoke the Nintendo DS, running Android 14 on a Rockchip RK3568 with dual 4-inch screens. At around $95 (roughly £75), it sits in the same budget bracket as the Mangmi Air X I reviewed last year, but where that was a capable general-purpose handheld, this one has a much narrower brief.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/anbernic-rg-ds-review/"><![CDATA[<p>Anbernic sent me an <a href="https://anbernic.com/products/rgds">RG DS</a> to review. It’s a clamshell Android handheld designed to evoke the Nintendo DS, running Android 14 on a Rockchip RK3568 with dual 4-inch screens. At around $95 (roughly £75), it sits in the same budget bracket as the <a href="/post/review-mangmi-air-x/">Mangmi Air X I reviewed last year</a>, but where that was a capable general-purpose handheld, this one has a much narrower brief.</p>

<!-- more -->

<figure class="image  center">

<picture>

<source srcset="/images/2026-06/rg-ds/rgds-screens.png.webp" type="image/webp" />

<img src="/images/2026-06/rg-ds/rgds-screens.png" loading="lazy" alt="" />
</picture>






</figure>

<p>The form factor is immediately recognisable. At 160 x 91 x 22mm it’s a near-match for the 3DS XL (156 x 93 x 22mm), similar button layout and hinge feel, two screens stacked vertically. The corners are sharper than the XL’s rounded edges though, which I noticed in my hands after longer sessions. Both screens are the same size and resolution, which wasn’t the case on the 3DS XL.</p>

<h2 id="specifications">Specifications</h2>

<table class="massive">
  <thead>
    <tr>
      <th>Feature</th>
      <th>Details</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Processor</strong></td>
      <td>Rockchip RK3568, Quad-core Cortex-A55, 2.0GHz</td>
    </tr>
    <tr>
      <td><strong>GPU</strong></td>
      <td>ARM Mali-G52 2EE</td>
    </tr>
    <tr>
      <td><strong>RAM</strong></td>
      <td>3GB</td>
    </tr>
    <tr>
      <td><strong>Storage</strong></td>
      <td>32GB internal, microSD up to 2TB</td>
    </tr>
    <tr>
      <td><strong>Displays</strong></td>
      <td>Dual 4-inch IPS, 640x480 each, OCA laminated, 60Hz</td>
    </tr>
    <tr>
      <td><strong>Touch</strong></td>
      <td>Capacitive multi-touch with stylus</td>
    </tr>
    <tr>
      <td><strong>OS</strong></td>
      <td>Android 14 (firmware V1.18 at time of writing)</td>
    </tr>
    <tr>
      <td><strong>Battery</strong></td>
      <td>4000mAh Li-ion, 5V/1.6A charging</td>
    </tr>
    <tr>
      <td><strong>Connectivity</strong></td>
      <td>Wi-Fi 802.11 a/b/g/n/ac (2.4GHz and 5GHz), Bluetooth 4.2</td>
    </tr>
    <tr>
      <td><strong>Camera</strong></td>
      <td>None</td>
    </tr>
    <tr>
      <td><strong>Sensors</strong></td>
      <td>Six-axis gyroscope, microphone</td>
    </tr>
    <tr>
      <td><strong>Dimensions</strong></td>
      <td>160 x 91 x 22mm, 311g</td>
    </tr>
    <tr>
      <td><strong>Price</strong></td>
      <td>~$95 (~£75)</td>
    </tr>
  </tbody>
</table>

<h2 id="in-the-box">In the box</h2>

<p>The console, a capacitive stylus, screen protectors, a wrist loop, and a 64GB microSD. Given there are two screens to scratch, the screen protectors are at least practical. There’s no slot for the stylus, so it lives loose in a bag or you just use your fingers.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-06/rg-ds/accessories.png.webp" type="image/webp" />

<img src="/images/2026-06/rg-ds/accessories.png" loading="lazy" alt="" />
</picture>






</figure>

<p>The 64GB card turns out to be the official Linux firmware, with multiple partitions, one of which is a ROMs volume pre-loaded with a starter library (160 NDS files, 206 GBA files, and various other systems). With the card inserted, the device boots straight into Linux rather than Android.</p>

<h2 id="the-screens">The screens</h2>

<p>Both screens are 4 inches, matched in size and resolution. The 3DS XL ran 4.88 inches on top and 4.18 at the bottom, at different resolutions; the RG DS goes for symmetry instead. Coming from smaller devices like the Miyoo Mini or TrimUI Brick, the difference is immediately noticeable. Both panels are well-matched in brightness and colour temperature, which isn’t a given at this price. Two independent screens make room for things the original DS couldn’t do. A game guide on the top, the game on the bottom works well in practice.</p>

<p>The resolution is 640x480 per screen. The Nintendo DS ran at 256x192, so the scaling factor is 2.5x. That’s not a clean integer, so pixel-perfect rendering isn’t possible without borders. DraStic offers scaling filter options to take the edge off: Scale2X and HQ2X both soften things up well enough, and integer scaling with borders keeps pixels clean at the cost of black bars. Easy to stop noticing either way.</p>

<p>The stylus is capacitive, not resistive. The original DS’s resistive panel gave more precise single-point input; capacitive is smoother for general use but hurts games that depend on drawing or tapping.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-06/rg-ds/rgds-and-ds.png.webp" type="image/webp" />

<img src="/images/2026-06/rg-ds/rgds-and-ds.png" loading="lazy" alt="" />
</picture>






</figure>

<h2 id="the-rest-of-the-hardware">The rest of the hardware</h2>

<p>The RK3568 handles DS emulation and most 2D systems comfortably, but loses convincing ground past N64, Dreamcast, or anything with heavier 3D.</p>

<p>Two analogue sticks, recessed further than feels natural given the limited space. They work, but they’re clearly an afterthought. Anyone coming from a flat handheld will notice.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-06/rg-ds/hardware.png.webp" type="image/webp" />

<img src="/images/2026-06/rg-ds/hardware.png" loading="lazy" alt="" />
</picture>






</figure>

<p>Closing the lid sleeps the device; opening it wakes cleanly. If the screens time out while it’s open, only the power button brings them back. Touching the top screen doesn’t register as activity for Android’s idle detection either, so the device can go to sleep on you even while you’re actively using it. Mildly annoying if your hands are already on the controls.</p>

<p>The hinge is solid, tight enough that the device stays open at whatever angle you set it, with a firm linear advance all the way to 180 degrees flat. Build quality is fine for the price. Mine is Black &amp; Crimson Red; it also comes in Turquoise Blue and Polar White, the names nodding at that DS inspiration.</p>

<p>The bottom is completely plain bar four 1.5mm hex screws, which is a welcome change from Nintendo’s tri-wing. The top edge has two USB-C ports, one for power and data transfer, one for OTG. Both are labelled, but nothing physically stops you plugging into the wrong one.</p>

<p>At 311g and about twice as thick as a modern phone, it doesn’t fit in a front pocket but slides into a back pocket.</p>

<p>No camera, which is a bit odd when budget smartwatches manage to include one, but since none of the emulators need it the omission is hard to argue with.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-06/rg-ds/rgds.png.webp" type="image/webp" />

<img src="/images/2026-06/rg-ds/rgds.png" loading="lazy" alt="" />
</picture>






</figure>

<p>The speakers are loud enough and clear, but thin on bass. Bluetooth works fine. I haven’t tested the 3.5mm jack; other reviewers report a fair bit of interference hum through it.</p>

<p>The face buttons and D-pad are clicky micro switches. The shoulder buttons are springy and clicky. Fine when gaming alone, but the noise carries (you sort of wish they were squashier if you’re playing near others).</p>

<p>The vibration motor is far more powerful than it needs to be. It goes off when plugging in to charge, connecting via USB, and on shutdown (a full rattling buzz each time). It’s all controllable from Android’s sound settings though; I turned it off immediately.</p>

<h2 id="battery-life">Battery life</h2>

<p>The 4000mAh battery is rated at around six hours. DS emulation at reasonable brightness gets closer to three to four. Well below the marketing figure.</p>

<p>Charging is at 5V/1.6A, which is slow. Expect around three and a half hours from empty.</p>

<p>The battery indicator is unreliable, staying green well past what the charge warrants. The percentage is more reliable than the icon.</p>

<h2 id="emulation">Emulation</h2>

<p>The obvious target is DraStic for DS games. Every DS title I tried ran at full speed. Games fill both screens as they’re supposed to, no awkward hotkeys or workarounds. The stylus works, though it’s noticeably laggier than on actual DS hardware (the capacitive panel is the culprit). For most games that’s fine; for anything that depends on precise or rhythmic stylus input (Rhythm Heaven, Elite Beat Agents, Kirby Canvas Curse) it’s a problem.</p>

<p>Running Mario Kart DS with Scale2X on for a couple of grand prix cups does make it run warm, but it keeps going without complaint.</p>

<p>DSiWare is a gap. DraStic doesn’t support it, and melonDS (the only option that does) runs DSiWare titles very poorly on this hardware.</p>

<p>One DraStic annoyance is that the L2 and R2 buttons are bound to save and load state by default. On a clamshell, those sit where my fingers naturally rest, so I kept accidentally triggering a save or load mid-game until I remapped them. Easily fixed, but surprising it shipped that way.</p>

<p>GBA via GBA.emu is flawless. Zelda: The Minish Cap ran perfectly, and R2 fast-forward is handy for cutscenes. The hardware is massively overpowered for anything in the 16-bit era and below.</p>

<p>PPSSPP handles most PSP games well. GTA Liberty City Stories and Vice City Stories both ran fine via the standalone app. The RetroArch core was a <em>different</em> story, with audio stuttering in cutscenes and lag when driving fast. Standalone PPSSPP is the better choice. The heavier 3D titles (God of War: Chains of Olympus, Crisis Core) I haven’t tested, but RK3568-class hardware is generally not their friend.</p>

<p>PS1 via DuckStation is great. Metal Gear Solid ran without hiccups, 2x upscaling held steady. DuckStation on Android hasn’t had an update since May 2025 and the developer has stepped back. It still works, but the maintained alternative is RetroArch’s SwanStation core, a DuckStation fork. Save files transfer across directly.</p>

<p>N64 through Mupen64Plus FZ Pro standalone runs well; it handles compatibility better than the RetroArch core for a handful of games.</p>

<p>3DS via the stock Azahar build is mostly disappointing. The preinstalled version undersells what’s possible though. Swapping to <a href="https://github.com/AzaharPlus/AzaharPlus/releases">AzaharPlus</a>, a community fork with extra optimisations, is worth it. New Super Mario Bros 2 Special Edition runs at 85%+ speed, which is very playable. Getting the dual-screen layout configured wasn’t obvious, but once sorted it works nicely. Not every 3DS title will fare as well, but it’s worth trying before writing 3DS off entirely.</p>

<h2 id="stock-firmware">Stock firmware</h2>

<p>Most early reviews got a device in a rough state. The headline problem was screen desync (top panel at 40Hz, bottom at 60Hz), which is hard to ignore. DraStic had noticeable input lag, the D-pad misfired on diagonals, sleep/wake was unreliable, and the launcher was sluggish. That coverage stuck, and the device’s reputation took a hit before most retail units shipped. V1.15 fixed the desync. V1.18 smoothed out more of the rough edges. What most buyers get now is a different device to the one those reviews described.</p>

<p>Firmware updates are listed on <a href="https://anbernic.com/pages/ambernic-system-update-links-for-various-models">Anbernic’s update page</a>, though the hosting is unreliable. V1.18 is on WeTransfer, V1.15 and the Linux build are on Google Drive.</p>

<p>The stock OS is workable but not especially clean. It’s Android 14 with custom launcher changes. The RG button earns its place: a short press switches input focus between screens, a long press opens Anbernic’s game frontend, and mid-game there’s no need to reach for the touchscreen. Standard frontends like Daijisho and ES-DE install without issue, though DraStic has to be launched directly, and the patched version that fixes the 40Hz screen bug isn’t automatically picked up by frontends.</p>

<p>The emulator lineup is hard to fault. <a href="https://drastic-ds.com/">DraStic</a> for DS, <a href="https://azahar-emu.org/">Azahar</a> for 3DS, and the .emu suite covering handheld and 16-bit systems (<a href="https://www.explusalpha.com/contents/gba-emu">GBA</a>, <a href="https://www.explusalpha.com/contents/gbc-emu">GBC</a>, <a href="https://www.explusalpha.com/contents/nes-emu">NES</a>, <a href="https://www.explusalpha.com/contents/snes9x-ex-plus">SNES</a>, <a href="https://www.explusalpha.com/contents/md-emu">Mega Drive</a>, <a href="https://www.explusalpha.com/contents/neo-emu">Neo Geo</a>, <a href="https://www.explusalpha.com/contents/ngp-emu">Neo Geo Pocket</a>, <a href="https://www.explusalpha.com/contents/pce-emu">PC Engine</a>, <a href="https://www.explusalpha.com/contents/msx-emu">MSX</a>). There’s <a href="https://www.duckstation.org/">DuckStation</a> for PS1, <a href="https://www.ppsspp.org/">PPSSPP</a> for PSP, <a href="https://github.com/flyinghead/flycast">Flycast</a> and <a href="https://redream.io/">Redream</a> for Dreamcast (both included), <a href="https://yabasanshiro.com/">Yaba Sanshiro 2 Pro</a> for Saturn, <a href="https://github.com/fzurita/mupen64plus-ae">Mupen64Plus FZ Pro</a> for N64, <a href="https://www.retroarch.com/">RetroArch</a> as a catch-all, and <a href="https://easyrpg.org/">EasyRPG Player</a> for RPG Maker games. <a href="https://github.com/ClassicOldSong/Apollo">Apollo</a> and <a href="https://moonlight-stream.org/">Limelight</a> handle game streaming. There’s also <a href="https://apkpure.com/">APKPure</a> as an alternative app store (Google Play Services removed it on my unit before I got a chance to use it), a couple of Chinese apps that don’t serve much purpose outside China, and the Play Store, which I haven’t signed into.</p>

<p>GammaOS is the one most people seem to land on for day-to-day use.</p>

<h2 id="gammaos-next">GammaOS Next</h2>

<figure class="image  center">

<picture>

<source srcset="/images/2026-06/rg-ds/gammaos.png.webp" type="image/webp" />

<img src="/images/2026-06/rg-ds/gammaos.png" loading="lazy" alt="" />
</picture>






</figure>

<p>Most long-term owners end up on something different: <a href="https://github.com/TheGammaSqueeze/GammaOSNext/releases/tag/v1.2.2-ANBERNICRGDS">GammaOS Next v1.2.2</a>, a custom Android 14 build (based on LineageOS 21) that several reviewers frame as what should have shipped from the start. When I reviewed the Mangmi Air X last year, GammaOS was Patreon-only. It went public in April 2026, and the RG DS felt like the right device to finally try it on.</p>

<p>It addresses the stock OS’s main rough edges (sleep/wake reliability, input latency in DraStic, diagonal D-pad misfires, general sluggishness). The touch panels are bumped to 120Hz. DualStack mode makes apps behave as single-screen when needed, plus per-screen volume, system-wide audio EQ, and a global shader pipeline so CRT filters apply across all emulators without per-emulator config.</p>

<p>The bottom screen can’t be fully turned off, only dimmed. The more demanding systems (N64, Dreamcast, PSP) are not GammaOS’s strongest suit, but 3DS compatibility, full Android app support, and DraStic with shader support make up for it. DraStic runs well even in low power mode, which helps battery life considerably. Standby drain is about 3% over eight hours.</p>

<p>The install wipes the device (unlike Linux firmware which boots from microSD), so it’s a one-way trip until reflash. <em>Lite</em> is the better choice; Google Services overhead makes Full sluggish on the RG DS hardware.</p>

<p>Install is via fastboot. Post-install it sets up RetroArch and Daijisho, and comes with DraStic, Flycast, Mupen64Plus FZ Pro, and PPSSPP pre-installed. Launching Mario Kart DS from Daijisho feels near-instant. It feels more like a games console out of the box than Android with emulators dropped on it.</p>

<p>I went with Lite, which skips Google Services entirely and ships with Aurora Store and Firefox instead of the Play Store. Noticeably snappier than stock.</p>

<p>The RG button loses its special functions on GammaOS; it just pulls down the notification drawer.</p>

<p>DraStic comes pre-configured for dual screen. DS games run better than on stock.</p>

<p>GammaOS also ships with demo ROMs across a range of platforms and three Pico-8 games via the fake08 RetroArch core. Enough to get a feel for each system before digging into your own library.</p>

<h2 id="linux-flavours">Linux flavours</h2>

<p>Anbernic released an <a href="https://anbernic.com/pages/ambernic-system-update-links-for-various-models">official Linux firmware</a> (v1.0, May 2026) taking a more console-like approach (games sorted by system, launched through DraStic and RetroArch, with a DS-themed UI). The included 64GB card is this firmware, with a starter ROM library and a handful of ports (Doom with nine WADs, Cave Story, VVVVVV, Sonic Robo Blast 2, C-Dogs) plus 39 Pico-8 carts running through the fake08 RetroArch core. Decent for a first boot.</p>

<figure class="iframe  center">









<div data-src="https://www.youtube.com/embed/eKoDHtU_ELk" class="figure-content placeholder" width="600" height="350">
<picture>
  <source srcset="https://i.ytimg.com/vi_webp/eKoDHtU_ELk/hqdefault.webp" type="image/webp" />
  <img src="https://i.ytimg.com/vi/eKoDHtU_ELk/hqdefault.jpg" loading="lazy" alt="" />
</picture>
</div>



</figure>

<p>The UI feels unpolished. The in-game menu has tiny unreadable text, the three included themes are fairly basic, and game cover scraping is patchy. DS games run fine, but there’s no scaling control (everything stretches to 640x480 with no pixel alignment), which looks rough versus DraStic on Android. Raw emulation performance is worse than Android, and high-res 3D mode struggles. Audio bugs, a “now playing” overlay on the bottom screen that can’t be disabled, occasional shutdown issues. Worth revisiting as it matures. It boots from microSD, so no need to wipe the internal Android install to try it.</p>

<p><a href="https://rocknix.org/">ROCKNIX</a> doesn’t have an official RG DS build yet. There’s an unofficial workaround using the RK3566 nightly with a config edit, and there’s already a <a href="https://github.com/beebono/dii-ess-aye">DSi-style EmulationStation theme</a> for it. One to watch once an official build lands.</p>

<p><a href="https://knulli.org/">Knulli</a> is another community Linux build with the same basic install process as ROCKNIX, currently in alpha and Patreon-only. Not practical for most people yet.</p>

<h2 id="worth-it">Worth it</h2>

<p>At $95 it does one thing better than most, playing DS games in the right form factor. The clamshell, dual screens, and a hinge that folds it pocket-sized all do the same job. Save states, fast forward, and a spare screen to pull up a guide or video while playing are things actual DS hardware can’t offer. The bundled emulator lineup covers a lot of ground, DraStic runs everything I threw at it, and GammaOS addresses the rough edges that hurt early reviews.</p>

<p>The caveats <em>matter</em>. The capacitive stylus lag rules out precision games, and DSiWare is effectively a dead end on this hardware. The RK3568 falls apart past 5th-generation 3D. Battery life is three to four hours in practice, well short of the marketing figure. The analogue sticks feel like an afterthought. The vibration motor is absurdly strong for the job. The sharp corners dig in during longer sessions.</p>

<p>As a DS replacement it’s probably the best DS-feeling option going.</p>

<p>Two counterpoints worth considering. A DS Lite plus <a href="/post/ds-pico-flashcart/">a DS Pico flashcart</a> comes in at around half the price, actual hardware, proper resistive touchscreen. There’s also DS wifi-play, which the RG DS can’t offer at all. Hard to ignore if DS games are the whole point.</p>

<p>Or a <a href="https://3ds.hacks.guide/">3DS modded with custom firmware</a>, which runs DS, DSiWare, and 3DS titles perfectly, and covers a fair few other systems besides.</p>

<p>The RG DS is available directly from <a href="https://anbernic.com/products/rgds">Anbernic</a>.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">Merge Commits Are Just Noise</title>
        <link href="https://blog.omgmog.net/post/merge-commits-are-just-noise/" rel="alternate" type="text/html" title="Merge Commits Are Just Noise" />
        <link href="https://blog.omgmog.net/post/merge-commits-are-just-noise/.txt" rel="alternate" type="text/plain" title="Merge Commits Are Just Noise (Plain Text)" />
        <published>2026-06-01T00:00:00+00:00</published>
        <updated>2026-06-01T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/merge-commits-are-just-noise/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="guide" />
        
        <category term="programming" />
        
        <category term="git" />
        
        <summary type="html"><![CDATA[Run git log --graph on most team projects and you’ll see the problem. Parallel lines everywhere, merge commits that just say “Merge branch ‘feature/x’ into develop”. None of it useful. It’s noise. I’ve been using a rebase-based workflow for years to avoid this. The history stays linear and the diffs stay readable. This post covers both the argument and the specific branch structure I use for team feature work.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/merge-commits-are-just-noise/"><![CDATA[<p>Run <code class="language-plaintext highlighter-rouge">git log --graph</code> on most team projects and you’ll see the problem. Parallel lines everywhere, merge commits that just say “Merge branch ‘feature/x’ into develop”. None of it useful. It’s noise.</p>

<p>I’ve been using a rebase-based workflow for years to avoid this. The history stays linear and the diffs stay readable. This post covers both the argument and the specific branch structure I use for team feature work.</p>

<!-- more -->

<p>I use <a href="https://git-fork.com/">Git Fork</a> as my day-to-day git GUI, so some of this is written from that perspective, but the concepts and CLI equivalents apply whichever git client you use.</p>

<h2 id="why-rebasing-over-merging">Why rebasing over merging</h2>

<p>The standard objection to rebasing is that it rewrites history. What it actually discards is the record of <em>when you synced with main</em>, which is rarely useful information to future readers. The commit messages still say what changed and why, and those stay intact.</p>

<p>You get a linear history. The same project with a rebase workflow reads like a changelog.</p>

<div class="inline-grid two-columns">
<figure class="image ">

<picture>

<source srcset="/images/2026-06/git/merge-heavy.png.webp" type="image/webp" />

<img src="/images/2026-06/git/merge-heavy.png" loading="lazy" alt="" />
</picture>






</figure>

<figure class="image ">

<picture>

<source srcset="/images/2026-06/git/rebase-clean.png.webp" type="image/webp" />

<img src="/images/2026-06/git/rebase-clean.png" loading="lazy" alt="" />
</picture>






</figure>

</div>

<p><a href="https://git-scm.com/docs/git-bisect">git bisect</a> works properly. Binary searching for a bug-introducing commit is much more reliable on a linear history. Merge commits add noise and make the search harder to follow.</p>

<p><code class="language-plaintext highlighter-rouge">git blame</code> is cleaner too. Blame output points to the commit that actually introduced a line.</p>

<p>With rebasing, conflicts come up one commit at a time rather than all at once. That encourages keeping commits small. Smaller commits mean easier code review, and reverts are less painful when you need them. If you find yourself resolving the same conflict repeatedly, <a href="https://git-scm.com/book/en/v2/Git-Tools-Rerere">enabling <code class="language-plaintext highlighter-rouge">git rerere</code></a> can automate that.</p>

<p>There are still cases where merging is the right call. Merging a long-lived release branch back into main is worth recording as a merge commit. The argument for rebasing applies to day-to-day feature work. Long-lived release branches are a different case (and a problem all of their own, but that’s a separate post).</p>

<p>Squash merging is another way to keep main clean, but it collapses every commit on the branch into one and throws the individual messages away. That’s fine if you don’t need the individual commit history, but rebasing keeps those commits intact and you still get the detail.</p>

<h2 id="the-workflow">The workflow</h2>

<p>For parallel feature work I use a three-tier branch structure:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>main
└─ feature/xyz
   ├─ user1/xyz-backend
   └─ user2/xyz-frontend
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">main</code> stays stable. <code class="language-plaintext highlighter-rouge">feature/xyz</code> is the integration branch for the feature, owned by whoever’s leading it. Individual developers branch off that, not off main.</p>

<p>I always branch from the feature branch, not main.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git checkout feature/xyz
git checkout <span class="nt">-b</span> user1/xyz-backend
</code></pre></div></div>

<p>Sub-branch developers rebase on the feature branch regularly. The feature lead keeps the feature branch current with main and announces when they’ve done so, so everyone knows to update.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># feature lead</span>
git fetch origin
git rebase origin/main
git push <span class="nt">--force-with-lease</span> origin feature/xyz

<span class="c"># sub-branch developers, after the above</span>
git fetch origin
git rebase origin/feature/xyz
</code></pre></div></div>

<p>PRs target <code class="language-plaintext highlighter-rouge">feature/xyz</code>, not <code class="language-plaintext highlighter-rouge">main</code>. When the first PR lands, the second developer rebases before their PR goes up for review.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git rebase origin/feature/xyz
git push <span class="nt">--force-with-lease</span> origin user2/xyz-frontend
</code></pre></div></div>

<p>Once the feature is complete, the feature branch gets rebased on main one last time, then merges via PR as a single coherent unit. No <code class="language-plaintext highlighter-rouge">--no-ff</code> needed. The rebased history is already readable without forcing a merge commit to mark the boundary.</p>

<p><a href="https://git-scm.com/docs/git-push#Documentation/git-push.txt---force-with-leaseltrefnamegt">–force-with-lease</a> is safer than <code class="language-plaintext highlighter-rouge">--force</code> because it refuses to push if someone else has pushed to the branch since you last fetched. On a branch you own it’s fine; on anything shared, you announce the rebase first.</p>

<p>Only rebase branches you own. Once someone else has based work on a branch, rebasing it rewrites the history they’re working against (they’ll need to rebase again before they can push). For shared branches like <code class="language-plaintext highlighter-rouge">feature/xyz</code>, the feature lead rebases and tells the team, everyone else updates before pushing.</p>

<h2 id="interactive-rebase">Interactive rebase</h2>

<p>I treat commits as cheap checkpoints while working, not as the finished article. A commit is local until you push, so nobody sees it and it costs nothing. Made some progress? Commit it. Going to make a cup of tea? Commit first. The working copy stays tidy and you can always get back to a known state.</p>

<p>The condition is that you clean up before anyone else sees it. That’s what <code class="language-plaintext highlighter-rouge">git rebase -i</code> is for.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git rebase <span class="nt">-i</span> origin/feature/xyz  <span class="c"># all commits ahead of the upstream branch</span>
git rebase <span class="nt">-i</span> HEAD~3               <span class="c"># last 3 commits</span>
git rebase <span class="nt">-i</span> abc1234              <span class="c"># everything after a specific commit</span>
</code></pre></div></div>

<p>The editor opens with each commit listed as <code class="language-plaintext highlighter-rouge">pick</code>. I annotate them:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pick de96a62 scaffold payments module
fixup b0ba055 implement stripe integration
fixup 8a7a4cf add payment form
reword accd3f7 add webhook handler
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">pick</code> keeps a commit as-is. <code class="language-plaintext highlighter-rouge">fixup</code> folds it into the one before and discards the message, good for WIP commits and drive-by typo fixes. <code class="language-plaintext highlighter-rouge">squash</code> does the same but keeps both messages so they can be combined. <code class="language-plaintext highlighter-rouge">reword</code> keeps the commit but opens the message for editing.</p>

<p>Fork handles this visually, with a draggable list and buttons for each operation rather than a text file. There are keyboard shortcuts too (<code class="language-plaintext highlighter-rouge">p</code>/<code class="language-plaintext highlighter-rouge">f</code>/<code class="language-plaintext highlighter-rouge">s</code>/<code class="language-plaintext highlighter-rouge">r</code> for the operations, <code class="language-plaintext highlighter-rouge">cmd+up</code>/<code class="language-plaintext highlighter-rouge">cmd+down</code> to reorder). One thing to note: Fork lists commits newest-first, the opposite of the CLI editor above.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-06/git/rebase.png.webp" type="image/webp" />

<img src="/images/2026-06/git/rebase.png" loading="lazy" alt="" />
</picture>






</figure>

<p>The result is one or two commits that describe what changed. While working I make commits whenever it’s convenient, then tidy them into something readable before the PR goes up. I aim for each commit to represent one coherent change, so sort it before the PR goes up, not during review.</p>

<h2 id="common-sticking-points">Common sticking points</h2>

<p>A lot of the anxiety around rebasing comes from it feeling irreversible, but it isn’t. As long as you committed something locally, <a href="https://git-scm.com/docs/git-reflog">git reflog</a> gets you back to any previous state.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git reflog                    <span class="c"># see everywhere HEAD has been</span>
git reset <span class="nt">--hard</span> HEAD@<span class="o">{</span>n<span class="o">}</span>     <span class="c"># go back to wherever you need</span>
</code></pre></div></div>

<p>Nothing’s lost until git’s garbage collection runs, which it does every 90 days by default. If you’re nervous about a rebase, create a throwaway branch first:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git branch backup/xyz-before-rebase
</code></pre></div></div>

<p>It costs nothing and takes a couple of seconds, and you can delete it once you’re happy with the rebase. Once people realise commits aren’t fragile, the whole model feels less precarious.</p>

<p><code class="language-plaintext highlighter-rouge">--force-with-lease</code> needs a bit more explaining. Force pushing anything feels wrong until you think of it in terms of branch ownership, and on a branch you own, it’s fine.</p>

<p>One habit that’s hard to shift is <code class="language-plaintext highlighter-rouge">git add .</code>. Selective staging (building commits that represent one coherent idea even when the working copy is messier) is one of git’s more useful features and almost invisible to people who’ve never used it.</p>

<p>The CLI equivalent is <a href="https://git-scm.com/docs/git-add">git add -p</a>, which walks through each changed hunk and asks what to do with it. Fork makes this easier. In the diff view, individual hunks can be staged with a click, and if a hunk is too coarse, individual lines within it can be staged too. In practice this means a messy working copy with three half-finished things in it can still produce three focused commits.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">Finding the Small Web in a Loud Internet</title>
        <link href="https://blog.omgmog.net/post/finding-the-small-web-in-a-loud-internet/" rel="alternate" type="text/html" title="Finding the Small Web in a Loud Internet" />
        <link href="https://blog.omgmog.net/post/finding-the-small-web-in-a-loud-internet/.txt" rel="alternate" type="text/plain" title="Finding the Small Web in a Loud Internet (Plain Text)" />
        <published>2026-04-22T00:00:00+00:00</published>
        <updated>2026-04-22T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/finding-the-small-web-in-a-loud-internet/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="indieweb" />
        
        <category term="life" />
        
        <category term="reddit" />
        
        <summary type="html"><![CDATA[My daily reading is deliberate at this point. I’ve settled on a small set of tools that surface the kind of internet I actually want, rather than whatever’s performing well today.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/finding-the-small-web-in-a-loud-internet/"><![CDATA[<p>My daily reading is deliberate at this point. I’ve settled on a small set of tools that surface the kind of internet I actually want, rather than whatever’s performing well today.</p>

<!-- more -->

<h2 id="hacker-news">Hacker News</h2>

<p>I use <a href="https://hcker.news">hcker.news</a> rather than <a href="https://news.ycombinator.com">the main site</a>, mainly to filter down to “smallweb” content and sort chronologically by submission time rather than by rank. The default HN front page surfaces whatever has caught fire in the last few hours, which can feel samey. Sorting by submission time means I catch things before they’ve either exploded or quietly disappeared.</p>

<p>The content I’m actually after is build logs, DIY projects, and obscure deep dives, the kind of thing where someone has spent months on a strange project and written it all up. The discussion threads are often as good as the links themselves.</p>

<h2 id="reddit">Reddit</h2>

<p>On mobile I use <a href="https://github.com/QuantumBadger/RedReader">RedReader</a>, mostly anonymously. It’s fast, strips away the cruft, and doesn’t nag me to sign in every five minutes.</p>

<p>On desktop I use <a href="https://old.reddit.com">old.reddit.com</a> to read threads and occasionally join in. New Reddit is too noisy and too slow. The communities I keep coming back to are around 3D printing, <a href="https://www.clockworkpi.com/">ClockworkPi</a> devices, and handheld SBC gaming, a pretty consistent theme of small computers and making things.</p>

<h2 id="blogs-via-powrss">Blogs via powRSS</h2>

<p>I don’t use an RSS reader. The idea of an inbox full of unread posts is too much like work. <a href="https://powrss.com">powRSS</a> sidesteps that entirely. It’s a timeline of blog posts skewing heavily towards indie and personal sites, something you dip into rather than clear. The content is much the same as what I find on hcker.news: build logs, deep dives, personal projects. It has three modes: a regular feed of recent posts, a shuffle mode that surfaces posts regardless of age (blogs going back to 1995 are fair game), and a random mode that sends you somewhere completely different on every click. That last one in particular captures something that felt lost when algorithmic feeds took over, that sense of not knowing what you’re about to read.</p>

<h2 id="youtube">YouTube</h2>

<p>Probably more YouTube than I’d like to admit. I’ve made peace with it, but with caveats. Shorts are an abomination and I avoid them entirely.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-04/hate-shorts.png.webp" type="image/webp" />

<img src="/images/2026-04/hate-shorts.png" loading="lazy" alt="The 'I like shorts!' NPC from Pokémon, edited to say 'I hate Shorts! It's mindless and hard to avoid!'" />
</picture>






</figure>

<p>What I actually want from YouTube is the opposite: long-form stuff, hour-plus deep dives, video essays. A lot of it I’ll have running in the background while I’m working, treating it more like a podcast than something I sit down to watch. Most of the channels I follow also have podcast feeds, and I’ll happily take either.</p>

<h2 id="actually-reading-things">Actually reading things</h2>

<p>For heavier news and tech sites loaded with ads, Chrome’s <a href="https://support.google.com/chrome/answer/14218344?hl=en-GB">Reader Mode</a> has become a quiet habit. It strips the page back to just the text, which is a significant improvement on sites that have clearly optimised for everything except reading.</p>

<h2 id="sharing-what-i-find">Sharing what I find</h2>

<p>Anything I find interesting, I tend to share on <a href="https://social.omgmog.net">social.omgmog.net</a> (a <a href="https://withknown.com">Known</a> site I’ve been running since 2022), which is syndicated to <a href="https://indieweb.social/@omgmog">Mastodon</a>. Sometimes it’s just a link, sometimes with a comment. It’s an easy way to keep a record of things that caught my attention, somewhere between a bookmark and a blog post.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">I was interviewed for Over/Under</title>
        <link href="https://blog.omgmog.net/post/over-under-interview/" rel="alternate" type="text/html" title="I was interviewed for Over/Under" />
        <link href="https://blog.omgmog.net/post/over-under-interview/.txt" rel="alternate" type="text/plain" title="I was interviewed for Over/Under (Plain Text)" />
        <published>2026-04-13T00:00:00+00:00</published>
        <updated>2026-04-13T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/over-under-interview/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="interview" />
        
        <category term="indieweb" />
        
        <category term="life" />
        
        <summary type="html"><![CDATA[Hyde from lazybea.rs runs a blog interview series called Over/Under, where bloggers rate a mix of topics as overrated or underrated, then pass a question along to the next person. I’m issue 61.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/over-under-interview/"><![CDATA[<p>Hyde from <a href="https://lazybea.rs/">lazybea.rs</a> runs a blog interview series called Over/Under, where bloggers rate a mix of topics as overrated or underrated, then pass a question along to the next person. <a href="https://lazybea.rs/ovr-061/">I’m issue 61</a>.</p>

<!-- more -->

<p>A few of my takes:</p>

<ul>
  <li><strong>NAS:</strong> underrated. Built mine from spare parts for under £200, runs Jellyfin, file sharing, and SSH. No subscriptions.</li>
  <li><strong>Joy-Cons:</strong> overrated. Had mine since 2018, stick drift happened exactly as expected. Third-party controllers with Hall Effect sticks cost less and feel better.</li>
  <li><strong>E-ink readers:</strong> overrated. Black-and-white reading works well, but colour e-ink promises more than it delivers. I went into this in more detail in <a href="/post/bigme-b6-color-e-ink-tablet/">my BigMe B6 post</a>.</li>
  <li><strong>VR:</strong> underrated. The metaverse stuff is nonsense, but VR for games has been genuinely good since 2015.</li>
  <li><strong>Thai food:</strong> overrated, for me personally. Being type-1 diabetic makes eating out tricky to navigate.</li>
</ul>

<p>The favourite books question caught me off guard a bit. Forced to save two: <em>Children of Time</em> by Adrian Tchaikovsky (which I <a href="/post/children-of-time-book-review/">gushed about in 2017</a>), and a family anthology that’s more sentimental than anything I could properly explain.</p>

<p>I also passed a question to the next blogger:</p>

<blockquote>
  <p>How do you balance work and side projects? I’ve held a <a href="https://blog.omgmog.net/post/archive-archaeology/">blog since 2004</a>, I’ve got drawers full of half-finished projects, two kids and a day job that keeps me busy. Finding time for all of it without burning out or letting something slide is a constant negotiation. I’m curious how other people manage it, or if they’ve just accepted that some projects stay half-finished forever.</p>
</blockquote>

<p>I don’t have the answer myself, which is part of why I asked.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">Pulling Google Takeout straight to a NAS</title>
        <link href="https://blog.omgmog.net/post/downloading-google-takeout-to-a-nas/" rel="alternate" type="text/html" title="Pulling Google Takeout straight to a NAS" />
        <link href="https://blog.omgmog.net/post/downloading-google-takeout-to-a-nas/.txt" rel="alternate" type="text/plain" title="Pulling Google Takeout straight to a NAS (Plain Text)" />
        <published>2026-04-09T00:00:00+00:00</published>
        <updated>2026-04-09T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/downloading-google-takeout-to-a-nas/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="google" />
        
        <category term="linux" />
        
        <category term="server" />
        
        <category term="guide" />
        
        <summary type="html"><![CDATA[I’ve been having a bit of a think about data I’ve left entirely in someone else’s hands. Specifically Google Photos, which has photos going back to 2011 and no local copy anywhere. I’m not going anywhere dramatic with it, but having a backup I actually control felt overdue.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/downloading-google-takeout-to-a-nas/"><![CDATA[<p>I’ve been having a bit of a think about data I’ve left entirely in someone else’s hands. Specifically Google Photos, which has photos going back to 2011 and no local copy <em>anywhere</em>. I’m not going anywhere dramatic with it, but having a backup I actually control felt overdue.</p>

<!-- more -->

<p>Part of the problem is how frictionless it is. I take a photo, it syncs, it’s “backed up”, and I never think about it again. That’s the appeal, but it’s also why I’d gone fifteen years without asking where, exactly, “backed up” actually meant.</p>

<h2 id="getting-the-download-urls">Getting the download URLs</h2>

<p>I requested a full Takeout export and it came back as 66 zip files, about 2GB each, 131GB total. My <a href="/post/building-a-home-nas-from-mostly-spare-parts/">NAS</a> has the space; my laptop does not. I didn’t fancy downloading it locally just to copy it across, so I worked out how to pull it straight to the server.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-04/photos/google-takeout.png.webp" type="image/webp" />

<img src="/images/2026-04/photos/google-takeout.png" loading="lazy" alt="" />
</picture>






</figure>

<p>On the Google Takeout page, there’s a list of all the zip files with a download link for each. There’s no way to grab them all at once, but running this in the browser console pulls all the links and copies them to the clipboard:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">copy</span><span class="p">([...</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="dl">"</span><span class="s2">a.WpHeLc</span><span class="dl">"</span><span class="p">)].</span><span class="nx">map</span><span class="p">(</span><span class="nx">a</span> <span class="o">=&gt;</span> <span class="nx">a</span><span class="p">.</span><span class="nx">href</span><span class="p">).</span><span class="nx">join</span><span class="p">(</span><span class="dl">'</span><span class="se">\n</span><span class="dl">'</span><span class="p">));</span>
</code></pre></div></div>

<p>Google obfuscates its CSS class names, so <code class="language-plaintext highlighter-rouge">WpHeLc</code> will likely change at some point. If it returns nothing, inspect one of the download buttons and find the current class. Paste the result into a <code class="language-plaintext highlighter-rouge">urls.txt</code> file on the server.</p>

<h2 id="why-cookies-dont-transfer">Why cookies don’t transfer</h2>

<p>The download links are authenticated, so the trick is carrying the Google session from the browser into the terminal. The obvious approach was to export browser cookies to a <code class="language-plaintext highlighter-rouge">cookies.txt</code> file (via an extension like “Get cookies.txt LOCALLY”) and pass them to <code class="language-plaintext highlighter-rouge">curl</code> or <code class="language-plaintext highlighter-rouge">aria2c</code>. <em>This doesn’t work.</em></p>

<p>The Takeout download URLs go through a redirect chain across multiple Google domains before landing on <code class="language-plaintext highlighter-rouge">takeout-download.usercontent.google.com</code>. The cookies that matter (<code class="language-plaintext highlighter-rouge">__Secure-1PSID</code>, <code class="language-plaintext highlighter-rouge">__Secure-3PSID</code>, <code class="language-plaintext highlighter-rouge">SIDCC</code>, and a few others) don’t survive that redirect in most download tools, and some cookie export extensions miss them entirely. Every download attempt ends up at the Google login page and saves an HTML file instead of a zip. The solution is to skip the redirect entirely and go straight to the final download URL, using Chrome’s exact headers.</p>

<h2 id="pretending-to-be-a-browser">Pretending to be a browser</h2>

<p>Install the <a href="https://chromewebstore.google.com/detail/CurlWget/dgcfkhmmpcmkikfmonjcalnjcmjcjjdn">CurlWget extension</a> in Chrome. Start downloading one of the files from the Takeout page, then immediately click the CurlWget icon. It generates an equivalent <code class="language-plaintext highlighter-rouge">curl</code> command with all the headers and cookie data Chrome used, pointing at the actual <code class="language-plaintext highlighter-rouge">takeout-download.usercontent.google.com</code> URL, no redirect involved. This works where the cookie export didn’t because Chrome had already followed the redirect, so the cookies are tied to the final domain, not the Takeout one.</p>

<p>From that I had the <code class="language-plaintext highlighter-rouge">Cookie:</code> header string, a job ID shared across all files, a numeric user ID, and the base filename, which was enough to build all the download URLs directly. The files are numbered sequentially, so the whole lot can be constructed without touching the Takeout page again.</p>

<p>The cookies don’t last long, though. Each session got me about 7 files (~14GB) before they expired and downloads started coming back as HTML again. The cookie lives in a separate <code class="language-plaintext highlighter-rouge">cookie.txt</code> file so it can be refreshed without touching the script. Grab a new CurlWget capture, save it to a text file, and pipe it through a helper script that pulls out just the value:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat </span>curlwget.txt | bash update-cookie.sh
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="c"># update-cookie.sh</span>
<span class="c"># Usage: pbpaste | bash update-cookie.sh</span>
<span class="c">#    or: cat curlwget.txt | bash update-cookie.sh</span>
<span class="nb">grep</span> <span class="nt">-oP</span> <span class="s2">"(?&lt;=Cookie: )[^'</span><span class="se">\"</span><span class="s2">]*"</span> | <span class="nb">tr</span> <span class="nt">-d</span> <span class="s1">'\n'</span> <span class="o">&gt;</span> cookie.txt
<span class="nb">echo</span> <span class="s2">"cookie.txt updated."</span>
</code></pre></div></div>

<p>The resulting <code class="language-plaintext highlighter-rouge">cookie.txt</code> looks something like this (heavily abbreviated):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SID=g.redacted; HSID=redacted; SSID=redacted; APISID=redacted; SAPISID=redacted; __Secure-1PSID=g.redacted; __Secure-3PSID=g.redacted; SIDCC=redacted; __Secure-1PSIDTS=sidts-redacted; __Secure-3PSIDTS=sidts-redacted; NID=redacted
</code></pre></div></div>

<h2 id="the-download-script">The download script</h2>

<p>With those pieces, the script builds each URL directly and downloads with <code class="language-plaintext highlighter-rouge">curl</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="nv">DIR</span><span class="o">=</span>/path/to/destination
<span class="nv">TOTAL</span><span class="o">=</span><span class="si">$(</span><span class="nb">wc</span> <span class="nt">-l</span> &lt; <span class="s2">"</span><span class="nv">$DIR</span><span class="s2">/urls.txt"</span><span class="si">)</span>

<span class="c"># From the CurlWget capture</span>
<span class="nv">J</span><span class="o">=</span>your-job-id-here
<span class="nv">USER_ID</span><span class="o">=</span>your-numeric-user-id
<span class="nv">BASE_NAME</span><span class="o">=</span>takeout-YYYYMMDDTHHMMSSZ-N

<span class="nv">COOKIE</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span> <span class="s2">"</span><span class="nv">$DIR</span><span class="s2">/cookie.txt"</span><span class="si">)</span>

<span class="c"># Clean up any small HTML files from previous failed attempts</span>
find <span class="s2">"</span><span class="nv">$DIR</span><span class="s2">"</span> <span class="nt">-name</span> <span class="s2">"takeout-part-*.zip"</span> <span class="nt">-size</span> <span class="nt">-1M</span> <span class="nt">-delete</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true

</span><span class="k">for </span>i <span class="k">in</span> <span class="si">$(</span><span class="nb">seq </span>0 <span class="k">$((</span>TOTAL <span class="o">-</span> <span class="m">1</span><span class="k">))</span><span class="si">)</span><span class="p">;</span> <span class="k">do
    </span><span class="nv">part</span><span class="o">=</span><span class="si">$(</span><span class="nb">printf</span> <span class="s1">'%03d'</span> <span class="k">$((</span>i <span class="o">+</span> <span class="m">1</span><span class="k">))</span><span class="si">)</span>
    <span class="nv">filename</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">BASE_NAME</span><span class="k">}</span><span class="s2">-</span><span class="k">${</span><span class="nv">part</span><span class="k">}</span><span class="s2">.zip"</span>
    <span class="nv">output</span><span class="o">=</span><span class="s2">"</span><span class="nv">$DIR</span><span class="s2">/</span><span class="nv">$filename</span><span class="s2">"</span>
    <span class="nv">url</span><span class="o">=</span><span class="s2">"https://takeout-download.usercontent.google.com/download/</span><span class="k">${</span><span class="nv">filename</span><span class="k">}</span><span class="s2">?j=</span><span class="k">${</span><span class="nv">J</span><span class="k">}</span><span class="s2">&amp;i=</span><span class="k">${</span><span class="nv">i</span><span class="k">}</span><span class="s2">&amp;user=</span><span class="k">${</span><span class="nv">USER_ID</span><span class="k">}</span><span class="s2">&amp;authuser=0"</span>

    <span class="k">if</span> <span class="o">[[</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$output</span><span class="s2">"</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">[[</span> <span class="si">$(</span><span class="nb">stat</span> <span class="nt">-c</span>%s <span class="s2">"</span><span class="nv">$output</span><span class="s2">"</span><span class="si">)</span> <span class="nt">-gt</span> 1048576 <span class="o">]]</span><span class="p">;</span> <span class="k">then
        </span><span class="nb">echo</span> <span class="s2">"[</span><span class="k">$((</span>i+1<span class="k">))</span><span class="s2">/</span><span class="nv">$TOTAL</span><span class="s2">] </span><span class="nv">$filename</span><span class="s2"> already exists, skipping"</span>
        <span class="k">continue
    fi

    </span><span class="nb">echo</span> <span class="s2">"[</span><span class="k">$((</span>i+1<span class="k">))</span><span class="s2">/</span><span class="nv">$TOTAL</span><span class="s2">] </span><span class="nv">$filename</span><span class="s2">"</span>

    curl <span class="se">\</span>
        <span class="nt">--header</span> <span class="s2">"User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"</span> <span class="se">\</span>
        <span class="nt">--header</span> <span class="s2">"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"</span> <span class="se">\</span>
        <span class="nt">--header</span> <span class="s2">"Accept-Language: en-GB,en-US;q=0.9,en;q=0.8"</span> <span class="se">\</span>
        <span class="nt">--header</span> <span class="s2">"Referer: https://takeout.google.com/"</span> <span class="se">\</span>
        <span class="nt">--header</span> <span class="s2">"Cookie: </span><span class="nv">$COOKIE</span><span class="s2">"</span> <span class="se">\</span>
        <span class="nt">--retry</span> 10 <span class="se">\</span>
        <span class="nt">--retry-delay</span> 15 <span class="se">\</span>
        <span class="nt">--retry-all-errors</span> <span class="se">\</span>
        <span class="nt">--continue-at</span> - <span class="se">\</span>
        <span class="nt">--progress-bar</span> <span class="se">\</span>
        <span class="nt">-o</span> <span class="s2">"</span><span class="nv">$output</span><span class="s2">"</span> <span class="se">\</span>
        <span class="s2">"</span><span class="nv">$url</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"done"</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">"FAILED"</span>

<span class="k">done

</span><span class="nb">echo</span> <span class="s2">"All done."</span>
</code></pre></div></div>

<p>Run it inside <code class="language-plaintext highlighter-rouge">tmux</code> or <code class="language-plaintext highlighter-rouge">screen</code> so it survives disconnecting from the server:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tmux new <span class="nt">-s</span> takeout <span class="s1">'bash download.sh'</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">--continue-at -</code> handles resume if a download is interrupted, and the script skips any file over 1MB that already exists, so a mid-way cookie expiry just means refreshing it and running the script again.</p>

<h2 id="whats-inside">What’s inside</h2>

<p>Extracted, it’s a single <code class="language-plaintext highlighter-rouge">Takeout/Google Photos/</code> directory with about 28,500 files in it. Almost exactly half are small JSON files, one per photo or video, carrying the metadata that Google doesn’t embed in the image itself (taken time, location, description, view count). The actual images have <em>none</em> of that baked in.</p>

<p>The folder structure is a mix of year folders (<code class="language-plaintext highlighter-rouge">Photos from 2011</code> through <code class="language-plaintext highlighter-rouge">Photos from 2026</code>), named albums I actually created (Iceland 2016, Menorca 2018, Jenny &amp; Max Wedding, various project dumps), and 214 folders called <code class="language-plaintext highlighter-rouge">Untitled</code>, <code class="language-plaintext highlighter-rouge">Untitled(1)</code>, and so on up to <code class="language-plaintext highlighter-rouge">Untitled(213)</code>. Those turned out to be WhatsApp shares, recognisable from the <code class="language-plaintext highlighter-rouge">IMG-20XXXXXX-WAXXXX.jpg</code> filenames inside.</p>

<p>The JSON files are a bit annoying. The <code class="language-plaintext highlighter-rouge">photoTakenTime</code> field has the actual date the photo was taken, but that’s separate from <code class="language-plaintext highlighter-rouge">creationTime</code>, which is when it was uploaded to Google Photos, and neither of those is written into the image file itself. So if I import everything into something like Immich or Lightroom without processing these first, it all ends up timestamped <em>wrong</em>. Tools like <a href="https://github.com/gilesknap/gphotos-takeout">gphotos-takeout</a> and <a href="https://github.com/mattwilson1024/google-photos-exif">google-photos-exif</a> can merge that data back in, but that’s a job for another day.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">Baking webmentions into the build</title>
        <link href="https://blog.omgmog.net/post/baking-webmentions-into-the-build/" rel="alternate" type="text/html" title="Baking webmentions into the build" />
        <link href="https://blog.omgmog.net/post/baking-webmentions-into-the-build/.txt" rel="alternate" type="text/plain" title="Baking webmentions into the build (Plain Text)" />
        <published>2026-03-26T00:00:00+00:00</published>
        <updated>2026-03-26T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/baking-webmentions-into-the-build/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="indieweb" />
        
        <category term="web-development" />
        
        <category term="jekyll" />
        
        <category term="programming" />
        
        <summary type="html"><![CDATA[Back in 2022 I added webmention support to this blog. The implementation worked but it was entirely client-side, so the page would load, JS would fire, fetch from webmention.io, and render the results into the DOM. With JS disabled or on a slow connection, the discussion section was just empty. It worked well enough that I didn’t touch it for three years, but the rate-limit situation had been silently catching visitors out and eventually I started pulling at the thread.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/baking-webmentions-into-the-build/"><![CDATA[<p>Back in 2022 I <a href="/post/adding-support-for-webmentions/">added webmention support to this blog</a>. The implementation worked but it was entirely client-side, so the page would load, JS would fire, fetch from webmention.io, and render the results into the DOM. With JS disabled or on a slow connection, the discussion section was just empty.</p>

<p>It worked well enough that I didn’t touch it for three years, but the rate-limit situation had been silently catching visitors out and eventually I started pulling at the thread.</p>

<!-- more -->

<p>The original setup fetched webmentions from webmention.io on every page load, caching the results in localStorage for 30 minutes. GitHub issue comments (my comment system) were fetched the same way. The result was a discussion section that would flicker in after a moment, or not appear at all if the API was rate-limited or slow.</p>

<p>The GitHub API rate limit is 60 requests per hour per IP for unauthenticated requests. That’s per visitor, so anyone browsing through a few posts in quick succession would hit it pretty fast and suddenly find comments not loading. There was also no record of what mentions existed at build time. Anyone without JavaScript saw <em>nothing</em>.</p>

<h2 id="fetching-at-build-time">Fetching at build time</h2>

<p>I run a small self-hosted service on my VPS called Morris that mirrors webmentions from webmention.io, indexing them by target URL and storing each one as a JSON file so I’ve got a local copy I control rather than depending on the API directly. (<a href="https://webmention.io">webmention.io</a> receives and stores mentions sent to your site; <a href="https://brid.gy">brid.gy</a> bridges social platforms like Mastodon and Twitter so their replies show up as webmentions too. Both are free and genuinely brilliant bits of IndieWeb infrastructure.)</p>

<p>Two scripts handle the fetching, a <a href="https://github.com/omgmog/omgmog.github.com/blob/main/scripts/fetch_webmentions.rb">webmention one</a> and a <a href="https://github.com/omgmog/omgmog.github.com/blob/main/scripts/fetch_github_comments.rb">GitHub comments one</a>, each writing results to <code class="language-plaintext highlighter-rouge">_data/</code>. A <a href="https://github.com/omgmog/omgmog.github.com/blob/main/.github/workflows/interactions.yml">GitHub Actions workflow</a> runs both at 4am daily and commits whatever’s changed.</p>

<p>Fetching GitHub comments via Actions rather than client-side also means the requests go out authenticated with <code class="language-plaintext highlighter-rouge">GITHUB_TOKEN</code>, which has a <a href="https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api">rate limit</a> of 1,000 requests per hour per repository. That’s more than enough for a daily cron job, and since the results are committed to the repo, visitors never touch the API at all unless new comments have arrived since the last build.</p>

<p>With the data files in the repo, Jekyll renders the mentions at build time using standard Liquid templates. The discussion section is in the HTML before anything loads. The JS still runs to pick up anything that’s arrived since the last build, tracks which IDs were already rendered to avoid duplicates, and re-sorts the feed chronologically. The page passes the pre-rendered IDs to the JS as two Sets:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">preRenderedWmIds</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Set</span><span class="p">([{{</span> <span class="nx">output</span> <span class="nx">at</span> <span class="nx">build</span> <span class="p">}}]);</span>
<span class="kd">const</span> <span class="nx">preRenderedCommentIds</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Set</span><span class="p">([{{</span> <span class="nx">output</span> <span class="nx">at</span> <span class="nx">build</span> <span class="p">}}]);</span>
</code></pre></div></div>

<h2 id="one-stream-instead-of-three">One stream instead of three</h2>

<p>Previously the discussion section had separate blocks for comments, replies, and mentions. I merged them into a single feed sorted by date, mixing GitHub comments and webmentions together. Archived comments (old ones I’d manually added to post front matter) slot in at the top since they’re always the oldest.</p>

<p>Items are colour-coded by source, GitHub comments getting one accent and webmentions another, with further tints for Reddit, Mastodon, and Twitter. My own entries flip to a reversed bubble, like a sent message.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-03/webmentions/stream.png.webp" type="image/webp" />

<img src="/images/2026-03/webmentions/stream.png" loading="lazy" alt="" />
</picture>






</figure>

<p>Likes, reposts, and bookmarks are gone from the stream entirely. They used to show as a grid of avatars, which was mostly noise, and a row of ten identical silhouettes doesn’t tell you much. The counts still show up in the interactions badge at the top of the post.</p>

<p>Interaction counts also appear on the post listing pages now. Since the data is baked into the build, the counts render statically alongside each post title with no JS involved.</p>

<div id="post-adding-support-for-webmentions" class="post-list-item h-entry massive" data-post-url="https://blog.omgmog.net/post/adding-support-for-webmentions/">
    <div class="post-meta">
    <time class="dt-published" datetime="2022-04-08T00:00:00+00:00">08 Apr 2022</time>
    
















<a class="interaction-stats" href="/post/adding-support-for-webmentions/#interactions" aria-label="Interactions for Adding support for Webmentions">
    <span class="comment" data-archived-count="0" data-wm-conv-count="21">
        <svg viewBox="0 0 24 24"><use href="#icon-message-circle" /></svg>
        <span class="value">22</span>
    </span>
    <span class="bookmark" style="display: none">
        <svg viewBox="0 0 24 24"><use href="#icon-bookmark" /></svg>
        <span class="value">0</span>
    </span>
    <span class="like">
        <svg viewBox="0 0 24 24"><use href="#icon-heart" /></svg>
        <span class="value">14</span>
    </span>
    <span class="repost">
        <svg viewBox="0 0 24 24"><use href="#icon-repeat-2" /></svg>
        <span class="value">5</span>
    </span>
</a>

    </div>
    <h3 class="post-title"><a href="/post/adding-support-for-webmentions/" class="u-url p-name">Adding support for Webmentions</a></h3>
    <p class="post-excerpt">I’ve added Webmention support to the posts on this blog. Webmentions are a method for websites to know that they’ve been linked to (or mentioned) from elsewhere on the web....</p>
    
</div>

<h2 id="tidying-up">Tidying up</h2>

<p>Webmention.io matches mentions against exact URLs, and I was losing some because people link with or without a trailing slash even though the canonical URL always has one. Webmention.io also has a <code class="language-plaintext highlighter-rouge">.txt</code> version of each post it can check against (I <a href="/post/moving-to-github-actions-and-adding-txt-posts/">added <code class="language-plaintext highlighter-rouge">.txt</code> support</a> back in January). I added both variants to the set of URLs the JS uses when looking for matches, which recovered a few mentions that had been silently missing.</p>

<p>I swapped the various hand-rolled inline SVGs scattered through the interactions markup for a <a href="https://lucide.dev">Lucide</a> icon sprite, one <code class="language-plaintext highlighter-rouge">&lt;svg&gt;</code> block in the page head with <code class="language-plaintext highlighter-rouge">&lt;use href="#icon-name"&gt;</code> anywhere an icon is needed. A bit less markup noise and much easier to maintain.</p>

<p>Webmention.io’s own blocklist doesn’t catch everything. I found a cluster of scraper sites that had all syndicated the same “budget smart home” article and sent webmentions to an old post of mine. They were easy to spot since they all had the same URL path across different junk domains. I added a <code class="language-plaintext highlighter-rouge">BLOCKED_DOMAINS</code> list to the fetch script and purged them from the existing data.</p>

<p>The discussion section now loads with the page. No flicker, no comments quietly missing because someone linked without a trailing slash.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">I turned my DevTerm into a Game Boy Camera and Printer</title>
        <link href="https://blog.omgmog.net/post/devterm-gameboy-printer/" rel="alternate" type="text/html" title="I turned my DevTerm into a Game Boy Camera and Printer" />
        <link href="https://blog.omgmog.net/post/devterm-gameboy-printer/.txt" rel="alternate" type="text/plain" title="I turned my DevTerm into a Game Boy Camera and Printer (Plain Text)" />
        <published>2026-03-20T00:00:00+00:00</published>
        <updated>2026-03-20T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/devterm-gameboy-printer/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="clockwork-pi" />
        
        <category term="hardware" />
        
        <category term="retro-gaming" />
        
        <category term="diy" />
        
        <summary type="html"><![CDATA[The Game Boy Camera was brilliant and stupid in equal measure, a 128x112 pixel sensor bolted on top of a cartridge, printing to thermal paper on a Game Boy Printer the size of a brick. I got both for Christmas when I was 12 and somehow spent hours with them. The minigames are absurd, the image quality is aggressively awful, and the whole thing looks like a prop from a cheap sci-fi film. I loved it.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/devterm-gameboy-printer/"><![CDATA[<p>The Game Boy Camera was brilliant and stupid in equal measure, a 128x112 pixel sensor bolted on top of a cartridge, printing to thermal paper on a Game Boy Printer the size of a brick. I got both for Christmas when I was 12 and somehow spent hours with them. The minigames are absurd, the image quality is <em>aggressively</em> awful, and the whole thing looks like a prop from a cheap sci-fi film. I loved it.</p>

<!-- more -->

<div class="inline-grid two-columns">
<figure class="image ">

<picture>

<source srcset="/images/2026-03/devterm-gb-printer/gameboy-camera-hardware.png.webp" type="image/webp" />

<img src="/images/2026-03/devterm-gb-printer/gameboy-camera-hardware.png" loading="lazy" alt="" />
</picture>






</figure>

<figure class="image ">

<picture>

<source srcset="/images/2026-03/devterm-gb-printer/devterm-hardware.png.webp" type="image/webp" />

<img src="/images/2026-03/devterm-gb-printer/devterm-hardware.png" loading="lazy" alt="" />
</picture>






</figure>

</div>

<p>I’ve had a <a href="https://www.clockworkpi.com/devterm">DevTerm A04</a> since launch, <a href="/post/devterm-stuff/">modded it a bit</a>, and mostly use it as a curiosity. It’s a small ARM-powered Linux machine in a retro terminal form factor with a built-in 58mm thermal printer. At some point it occurred to me that I could use it to emulate the whole Game Boy Camera setup, mGBA running the GB Camera ROM, a USB webcam feeding in as the camera sensor, and the DevTerm’s printer standing in for the Game Boy Printer. Tiny thermal photos. Same aesthetic, one device.</p>

<p>The idea on paper was simple enough. When a print is triggered in-game, mGBA saves the output as a PNG. A Python script watches for new files and converts them to 1-bit monochrome to send to the printer. Four moving parts. Should be fine.</p>

<figure class="image  center">

<picture>

<img src="/images/2026-03/devterm-gb-printer/this-is-fine.gif" loading="lazy" alt="" />
</picture>






</figure>

<p>It was not fine. Not immediately, anyway.</p>

<h2 id="the-os-was-showing-its-age">The OS was showing its age</h2>

<figure class="image  center massive">

<picture>

<source srcset="/images/2026-03/devterm-gb-printer/hirsute-scrot.png.webp" type="image/webp" />

<img src="/images/2026-03/devterm-gb-printer/hirsute-scrot.png" loading="lazy" alt="" />
</picture>






</figure>

<p>My DevTerm was still running the original OS image it shipped with, Ubuntu 21.04 Hirsute. That version hit end-of-life in January 2022, so the package repos were long dead. Anything involving <code class="language-plaintext highlighter-rouge">apt install</code> was either broken or unreachable. The printer service also wasn’t working, which I couldn’t easily diagnose without being able to install anything.</p>

<p>ClockworkPi provide an updated image, <a href="http://dl.clockworkpi.com/DevTerm_A04_v0.2h.img.bz2">DevTerm_A04_v0.2h.img.bz2</a>, based on Ubuntu 22.04 LTS (Jammy) with Linux 5.10.75. Flashed via macOS:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bunzip2 <span class="nt">-c</span> DevTerm_A04_v0.2h.img.bz2 | <span class="nb">sudo dd </span><span class="nv">of</span><span class="o">=</span>/dev/rdiskX <span class="nv">bs</span><span class="o">=</span>4m
</code></pre></div></div>

<p>It booted fine and the package repos worked. The printer still didn’t work, but at least now I could start diagnosing it.</p>

<h2 id="how-the-printer-works">How the printer works</h2>

<p>The DevTerm’s printer doesn’t expose itself as a simple device node. Instead there’s a pair of services: <code class="language-plaintext highlighter-rouge">devterm-socat.service</code> creates a named pipe at <code class="language-plaintext highlighter-rouge">/tmp/DEVTERM_PRINTER_IN</code>, and <code class="language-plaintext highlighter-rouge">devterm-printer.service</code> reads from it and drives the hardware over SPI. To print something, you write ESC/POS commands to the pipe.</p>

<p>On the fresh image both services came up without issue, which was more than could be said for the old Hirsute install.</p>

<h2 id="it-was-the-ribbon-cable">It was the ribbon cable</h2>

<p>I tried sending a test command (<code class="language-plaintext highlighter-rouge">\x12\x54</code> is <code class="language-plaintext highlighter-rouge">DC2 T</code>, the ESC/POS self-test sequence):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">echo</span> <span class="nt">-ne</span> <span class="s2">"</span><span class="se">\x</span><span class="s2">12</span><span class="se">\x</span><span class="s2">54"</span> <span class="o">&gt;</span> /tmp/DEVTERM_PRINTER_IN
</code></pre></div></div>

<p>The printer display showed “Error: NO PAPER”. The paper was loaded. I pulled the holder out and reinserted it. Still “NO PAPER”.</p>

<p>I ran <code class="language-plaintext highlighter-rouge">strace</code> on the printer process to check whether anything was actually happening:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>strace <span class="nt">-p</span> <span class="si">$(</span>pgrep devterm_thermal<span class="si">)</span> 2&gt;&amp;1 <span class="o">&gt;</span> /tmp/strace.log &amp;
<span class="nb">echo</span> <span class="nt">-ne</span> <span class="s2">"</span><span class="se">\x</span><span class="s2">12</span><span class="se">\x</span><span class="s2">54"</span> <span class="o">&gt;</span> /tmp/DEVTERM_PRINTER_IN
</code></pre></div></div>

<p>The log showed <code class="language-plaintext highlighter-rouge">ioctl</code> calls against file descriptor 4. Checking <code class="language-plaintext highlighter-rouge">/proc/$(pgrep devterm_thermal)/fd</code> confirmed FD 4 is <code class="language-plaintext highlighter-rouge">/dev/spidev0.0</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0 -&gt; /dev/null
1 -&gt; /tmp/devterm_printer.log
2 -&gt; /tmp/devterm_printer.err.log
3 -&gt; /dev/mem
4 -&gt; /dev/spidev0.0
5 -&gt; /dev/pts/0
</code></pre></div></div>

<p>SPI commands were going out and the daemon was doing its job. The printer hardware just wasn’t responding.</p>

<p>The ribbon cable (FPC) connecting the printer board to the mainboard was loose, not visibly out but not seated properly at both ends. The spec sheet notes the FPC connector should only be plugged and unplugged fewer than ten times in its entire life, which is a slightly alarming thing to read while reseating a connector for the second time. Reseated it anyway, sent the self-test command again:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">echo</span> <span class="nt">-ne</span> <span class="s2">"</span><span class="se">\x</span><span class="s2">12</span><span class="se">\x</span><span class="s2">54"</span> <span class="o">&gt;</span> /tmp/DEVTERM_PRINTER_IN
</code></pre></div></div>

<p>It printed. Logo, fonts, QR code, system info, the lot.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-03/devterm-gb-printer/printer-test.png.webp" type="image/webp" />

<img src="/images/2026-03/devterm-gb-printer/printer-test.png" loading="lazy" alt="" />
</picture>






</figure>

<p>Hardware sorted. Time to actually do the thing.</p>

<h2 id="the-camera">The camera</h2>

<p>The camera I’m using is an <a href="https://www.amazon.co.uk/dp/B0CTXMJCCT">HBV-1313</a>, a £7.82 USB module that’s 60mm long and 8mm wide. I didn’t want to be lugging a full webcam around or cable-tying something to the shell, and this sits neatly above the screen held on with double-sided tape, which is exactly the kind of mounting solution the GB Camera aesthetic deserves.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-03/devterm-gb-printer/hbv-1313-camera-module.png.webp" type="image/webp" />

<img src="/images/2026-03/devterm-gb-printer/hbv-1313-camera-module.png" loading="lazy" alt="" />
</picture>






</figure>

<p>The module shows up as <code class="language-plaintext highlighter-rouge">/dev/video1</code>. I grabbed a shot with <code class="language-plaintext highlighter-rouge">fswebcam</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fswebcam <span class="nt">-d</span> /dev/video1 test.jpg
</code></pre></div></div>

<p>Converting and sending it to the printer took a small Python snippet:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python3 <span class="nt">-c</span> <span class="s2">"
from PIL import Image, ImageOps
import struct

img = Image.open('test.jpg').convert('L')
w, h = img.size
new_h = int(h * (384 / w))
img = img.resize((384, new_h), Image.NEAREST)
img = ImageOps.invert(img).convert('1')
data = img.tobytes()
header = b'</span><span class="se">\x</span><span class="s2">1d</span><span class="se">\x</span><span class="s2">76</span><span class="se">\x</span><span class="s2">30</span><span class="se">\x</span><span class="s2">00' + struct.pack('&lt;HH', 48, new_h)
open('/tmp/DEVTERM_PRINTER_IN', 'wb').write(header + data)
"</span>
</code></pre></div></div>

<p>The shot was taken blind over SSH, which explains the composition. The print has a gap in the middle where I forgot <code class="language-plaintext highlighter-rouge">-e</code> on the echo command and printed a literal <code class="language-plaintext highlighter-rouge">\n\n\n\n\n</code>. Pipeline confirmed working.</p>

<div class="inline-grid two-columns">
<figure class="image ">

<picture>

<source srcset="/images/2026-03/devterm-gb-printer/camera-test.jpg.webp" type="image/webp" />

<img src="/images/2026-03/devterm-gb-printer/camera-test.jpg" loading="lazy" alt="" />
</picture>






</figure>

<figure class="image ">

<picture>

<source srcset="/images/2026-03/devterm-gb-printer/camera-test-print.png.webp" type="image/webp" />

<img src="/images/2026-03/devterm-gb-printer/camera-test-print.png" loading="lazy" alt="" />
</picture>






</figure>

</div>

<h2 id="mgba-and-the-file-watcher">mGBA and the file watcher</h2>

<p>With the camera already working, I launched <code class="language-plaintext highlighter-rouge">mgba-qt</code> with the GB Camera ROM. mGBA has a camera sensor backend that maps a V4L2 device to the cartridge’s sensor interface, so it picked up <code class="language-plaintext highlighter-rouge">/dev/video0</code> without any extra configuration.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-03/devterm-gb-printer/cam.png.webp" type="image/webp" />

<img src="/images/2026-03/devterm-gb-printer/cam.png" loading="lazy" alt="" />
</picture>






</figure>

<p>mGBA does have GB Printer emulation built in. You open it via Emulation, Game Boy Printer, and get a dialog with a live print buffer, a “hurry up” button to skip the timing delay, “tear off” to flush the buffer (useful for batching multiple prints into one image), zoom, copy, and save. It’s a perfectly good tool, but saving still means clicking through a dialog each time. My first attempt at automating around this was a file watcher: save the PNG wherever you like, and the script picks it up and sends it to the printer.</p>

<p>The script watches a directory for new PNG files and sends each one to the printer. The conversion is the awkward bit: GB Camera output is 160px wide, 4-shade greyscale; the DevTerm printer wants 384 dots per row, 1-bit monochrome. <code class="language-plaintext highlighter-rouge">Image.NEAREST</code> for the resize matters here. Bilinear would blur the pixels into mush; nearest-neighbour keeps the chunky look intact.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">sys</span>
<span class="kn">import</span> <span class="nn">time</span>
<span class="kn">import</span> <span class="nn">struct</span>
<span class="kn">from</span> <span class="nn">pathlib</span> <span class="kn">import</span> <span class="n">Path</span>
<span class="kn">from</span> <span class="nn">watchdog.observers</span> <span class="kn">import</span> <span class="n">Observer</span>
<span class="kn">from</span> <span class="nn">watchdog.events</span> <span class="kn">import</span> <span class="n">FileSystemEventHandler</span>
<span class="kn">from</span> <span class="nn">PIL</span> <span class="kn">import</span> <span class="n">Image</span><span class="p">,</span> <span class="n">ImageOps</span>

<span class="n">PRINTER_IN</span> <span class="o">=</span> <span class="s">'/tmp/DEVTERM_PRINTER_IN'</span>
<span class="n">PRINTER_WIDTH</span> <span class="o">=</span> <span class="mi">384</span>

<span class="k">def</span> <span class="nf">send_image</span><span class="p">(</span><span class="n">path</span><span class="p">):</span>
    <span class="n">img</span> <span class="o">=</span> <span class="n">Image</span><span class="p">.</span><span class="nb">open</span><span class="p">(</span><span class="n">path</span><span class="p">).</span><span class="n">convert</span><span class="p">(</span><span class="s">'L'</span><span class="p">)</span>
    <span class="n">w</span><span class="p">,</span> <span class="n">h</span> <span class="o">=</span> <span class="n">img</span><span class="p">.</span><span class="n">size</span>
    <span class="n">new_h</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">h</span> <span class="o">*</span> <span class="p">(</span><span class="n">PRINTER_WIDTH</span> <span class="o">/</span> <span class="n">w</span><span class="p">))</span>
    <span class="n">img</span> <span class="o">=</span> <span class="n">img</span><span class="p">.</span><span class="n">resize</span><span class="p">((</span><span class="n">PRINTER_WIDTH</span><span class="p">,</span> <span class="n">new_h</span><span class="p">),</span> <span class="n">Image</span><span class="p">.</span><span class="n">NEAREST</span><span class="p">)</span>
    <span class="n">img</span> <span class="o">=</span> <span class="n">ImageOps</span><span class="p">.</span><span class="n">invert</span><span class="p">(</span><span class="n">img</span><span class="p">).</span><span class="n">convert</span><span class="p">(</span><span class="s">'1'</span><span class="p">)</span>
    <span class="n">data</span> <span class="o">=</span> <span class="n">img</span><span class="p">.</span><span class="n">tobytes</span><span class="p">()</span>

    <span class="n">width_bytes</span> <span class="o">=</span> <span class="n">PRINTER_WIDTH</span> <span class="o">//</span> <span class="mi">8</span>
    <span class="n">header</span> <span class="o">=</span> <span class="sa">b</span><span class="s">'</span><span class="se">\x1d\x76\x30\x00</span><span class="s">'</span> <span class="o">+</span> <span class="n">struct</span><span class="p">.</span><span class="n">pack</span><span class="p">(</span><span class="s">'&lt;HH'</span><span class="p">,</span> <span class="n">width_bytes</span><span class="p">,</span> <span class="n">new_h</span><span class="p">)</span>

    <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">PRINTER_IN</span><span class="p">,</span> <span class="s">'wb'</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
        <span class="n">f</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="n">header</span> <span class="o">+</span> <span class="n">data</span><span class="p">)</span>

<span class="k">class</span> <span class="nc">PrintHandler</span><span class="p">(</span><span class="n">FileSystemEventHandler</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">on_created</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">event</span><span class="p">):</span>
        <span class="k">if</span> <span class="n">event</span><span class="p">.</span><span class="n">src_path</span><span class="p">.</span><span class="n">endswith</span><span class="p">(</span><span class="s">'.png'</span><span class="p">):</span>
            <span class="n">time</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="mf">0.1</span><span class="p">)</span>  <span class="c1"># let mGBA finish writing
</span>            <span class="n">send_image</span><span class="p">(</span><span class="n">event</span><span class="p">.</span><span class="n">src_path</span><span class="p">)</span>

<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="s">'__main__'</span><span class="p">:</span>
    <span class="n">watch_dir</span> <span class="o">=</span> <span class="n">sys</span><span class="p">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">sys</span><span class="p">.</span><span class="n">argv</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">1</span> <span class="k">else</span> <span class="s">'.'</span>
    <span class="n">observer</span> <span class="o">=</span> <span class="n">Observer</span><span class="p">()</span>
    <span class="n">observer</span><span class="p">.</span><span class="n">schedule</span><span class="p">(</span><span class="n">PrintHandler</span><span class="p">(),</span> <span class="n">watch_dir</span><span class="p">,</span> <span class="n">recursive</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">observer</span><span class="p">.</span><span class="n">start</span><span class="p">()</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">'Watching </span><span class="si">{</span><span class="n">watch_dir</span><span class="si">}</span><span class="s">'</span><span class="p">)</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="k">while</span> <span class="bp">True</span><span class="p">:</span>
            <span class="n">time</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
    <span class="k">except</span> <span class="nb">KeyboardInterrupt</span><span class="p">:</span>
        <span class="n">observer</span><span class="p">.</span><span class="n">stop</span><span class="p">()</span>
    <span class="n">observer</span><span class="p">.</span><span class="n">join</span><span class="p">()</span>
</code></pre></div></div>

<h2 id="skipping-the-middleman">Skipping the middleman</h2>

<p>The script worked, but the workflow was still a mess. To actually print something you’d take a photo in-game, trigger a print, open the Game Boy Printer dialog, wait for the buffer to fill, click save, choose a filename, save it to wherever the watcher was looking, and <em>then</em> it would send it to the printer. The whole point of the GB Printer was that print was one button. This was six.</p>

<p>So I forked mGBA and wired its printer emulation directly to the thermal pipe. mGBA was already capturing the printer buffer and emitting a signal when a print job came in, so the fork just catches that signal and sends the image to the pipe instead of opening a save dialog. Nothing changes in the emulator’s GB Printer handling. The image gets scaled, contrast-adjusted, dithered to 1-bit monochrome, and written as an ESC/POS bitmap command, all in one step. The printer auto-attaches when any GB game loads, so the game always sees it as connected. That includes <a href="https://github.com/untoxa/gb-photo">Photo!</a>, a homebrew alternative to the GB Camera ROM with considerably more control over the imaging side, if the original isn’t enough.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-03/devterm-gb-printer/mgba-camera-print.png.webp" type="image/webp" />

<img src="/images/2026-03/devterm-gb-printer/mgba-camera-print.png" loading="lazy" alt="" />
</picture>






</figure>

<p>There’s a settings panel under Tools, Thermal Printer Settings:</p>

<ul>
  <li><strong>Auto-print</strong>: whether to actually send anything to the printer (the printer is always attached regardless)</li>
  <li><strong>Pipe path</strong>: defaults to <code class="language-plaintext highlighter-rouge">/tmp/DEVTERM_PRINTER_IN</code></li>
  <li><strong>Density</strong>: 0–15, how hard the print head fires</li>
  <li><strong>Contrast</strong>: 1.0–4.0, applied before dithering</li>
  <li><strong>Dithering</strong>: threshold, ordered/Bayer, Floyd-Steinberg, or Stucki</li>
  <li><strong>Scale</strong>: integer 2x centred, or stretch to full width</li>
  <li><strong>Feed lines</strong>: paper advance after printing</li>
  <li><strong>Instant print return</strong>: skip the print timing delay</li>
</ul>

<p>The defaults are all DevTerm-specific, but it should work with any ESC/POS thermal printer with the pipe path changed.</p>

<p>The fork is at <a href="https://github.com/omgmog/mgba/tree/devterm-thermal-printer">omgmog/mgba</a> on the <code class="language-plaintext highlighter-rouge">devterm-thermal-printer</code> branch, with a <a href="https://github.com/omgmog/mgba/releases/tag/devterm-thermal-printer-v1">pre-built binary for the DevTerm A04</a> (aarch64, Ubuntu 22.04 Jammy) for anyone who doesn’t want to build it.</p>

<h2 id="dithering">Dithering</h2>

<p>Converting from the GB Camera’s 4-shade greyscale to 1-bit monochrome is where most of the image quality lives or dies. A thermal printer can either fire a dot or not (there’s no such thing as grey), so dithering is the only way to fake tonal range. The patch offers four options, and they produce noticeably different results.</p>

<div class="inline-grid two-columns img-crop">
<figure class="image ">

<picture>

<source srcset="/images/2026-03/devterm-gb-printer/face-source.png.webp" type="image/webp" />

<img src="/images/2026-03/devterm-gb-printer/face-source.png" loading="lazy" alt="" />
</picture>





<figcaption><p>Original</p>
</figcaption>
</figure>

<figure class="image ">

<picture>

<source srcset="/images/2026-03/devterm-gb-printer/face-ref.png.webp" type="image/webp" />

<img src="/images/2026-03/devterm-gb-printer/face-ref.png" loading="lazy" alt="" />
</picture>





<figcaption><p>Original 2x</p>
</figcaption>
</figure>

<figure class="image ">

<picture>

<source srcset="/images/2026-03/devterm-gb-printer/face-dither-threshold.png.webp" type="image/webp" />

<img src="/images/2026-03/devterm-gb-printer/face-dither-threshold.png" loading="lazy" alt="" />
</picture>





<figcaption><p>Threshold</p>
</figcaption>
</figure>

<figure class="image ">

<picture>

<source srcset="/images/2026-03/devterm-gb-printer/face-dither-ordered.png.webp" type="image/webp" />

<img src="/images/2026-03/devterm-gb-printer/face-dither-ordered.png" loading="lazy" alt="" />
</picture>





<figcaption><p>Ordered (Bayer)</p>
</figcaption>
</figure>

<figure class="image ">

<picture>

<source srcset="/images/2026-03/devterm-gb-printer/face-dither-floyd-steinberg.png.webp" type="image/webp" />

<img src="/images/2026-03/devterm-gb-printer/face-dither-floyd-steinberg.png" loading="lazy" alt="" />
</picture>





<figcaption><p>Floyd-Steinberg</p>
</figcaption>
</figure>

<figure class="image ">

<picture>

<source srcset="/images/2026-03/devterm-gb-printer/face-dither-stucki.png.webp" type="image/webp" />

<img src="/images/2026-03/devterm-gb-printer/face-dither-stucki.png" loading="lazy" alt="" />
</picture>





<figcaption><p>Stucki</p>
</figcaption>
</figure>

</div>

<p>Threshold is harsh. Everything becomes either white or black, the midtones disappear, and the result looks like a photocopy left in the sun. Ordered/Bayer gives a classic halftone look, regular dot patterns standing in for greys, which is fine but reads as “printed” in a fairly obvious way. Floyd-Steinberg is where it starts to feel more photographic, the grain distributed unevenly in a way that looks almost natural. Stucki does the same thing with a bit more finesse, the dots finer, the transitions smoother. On thermal paper, where there’s no such thing as grey anyway, Stucki consistently gets the most out of the image.</p>

<h2 id="so-anyway-thats-a-print">So anyway, that’s a print</h2>

<figure class="iframe portrait">









<div data-src="https://www.youtube.com/embed/_-2-Nq5mP48" class="figure-content placeholder" width="600" height="350">
<picture>
  <source srcset="https://i.ytimg.com/vi_webp/_-2-Nq5mP48/hqdefault.webp" type="image/webp" />
  <img src="https://i.ytimg.com/vi/_-2-Nq5mP48/hqdefault.jpg" loading="lazy" alt="" />
</picture>
</div>



</figure>

<p>The thermal prints will fade in a few years and they’re too small to frame. Nobody asked for this. But it’s the same stupid aesthetic from Christmas 1998, running on hardware I had to reflash and debug and physically reseat a ribbon cable on, printing photos through an emulator I had to fork. There’s something satisfying about that chain of effort for something this pointless. If you had a GB Camera as a kid, you already understand. If you didn’t, I’m not sure I can explain it.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">Your forecast is accurate and still wrong</title>
        <link href="https://blog.omgmog.net/post/your-forecast-is-accurate-and-still-wrong/" rel="alternate" type="text/html" title="Your forecast is accurate and still wrong" />
        <link href="https://blog.omgmog.net/post/your-forecast-is-accurate-and-still-wrong/.txt" rel="alternate" type="text/plain" title="Your forecast is accurate and still wrong (Plain Text)" />
        <published>2026-03-12T00:00:00+00:00</published>
        <updated>2026-03-12T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/your-forecast-is-accurate-and-still-wrong/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="ux" />
        
        <category term="design" />
        
        <summary type="html"><![CDATA[Icons work because they’re fast. One glance, immediate meaning, no reading required. The tradeoff is that they compress complex information into a single symbol, and compression always drops something.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/your-forecast-is-accurate-and-still-wrong/"><![CDATA[<p>Icons work because they’re fast. One glance, immediate meaning, no reading required. The tradeoff is that they compress complex information into a single symbol, and compression always drops something.</p>

<!-- more -->

<p>Most of the time that’s fine. But sometimes what gets dropped is the thing that actually matters, and the icon quietly lies to you. <a href="https://www.bbc.co.uk/news/articles/czj18j09wvro">Chester Zoo and 80-odd other UK tourist attractions are currently making exactly this argument</a>:</p>

<blockquote>
  <p>“When families see a raincloud icon, many simply stay home. The reality might be a brief shower at 6am — but the symbol suggests a washout.”
— Dom Strange, Chester Zoo</p>
</blockquote>

<p>They’re not disputing the forecast accuracy. A shower did happen. The complaint is about how that accurate data gets turned into an icon, and what that icon communicates.</p>

<h2 id="icons-are-lossy">Icons are lossy</h2>

<p>Take the Google Weather app on Android. It updates roughly every hour and shows current conditions: what’s actually happening outside right now. That part works well. But the daily forecast view, the strip of icons showing tomorrow and the rest of the week, is a completely different thing. Each of those icons has to stand in for an entire day, and that’s where things go wrong.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-03/weather-google-composite.png.webp" type="image/webp" />

<img src="/images/2026-03/weather-google-composite.png" loading="lazy" alt="" />
</picture>






</figure>

<p>Google Weather this morning in Oxfordshire. Current conditions: cloudy, 10°. The hourly strip shows 10-10-10-20-25% chance of rain, pretty unremarkable. Further down, the 10-day shows today’s card with a rain icon at 35%. Same data, same app, different view. The daily summary decided rain was the story.</p>

<p>The compression happens twice. Google Weather’s API defines 40-odd distinct weather conditions, things like light showers, scattered showers, heavy rain, rain periodically heavy, and so on. Those first collapse down to a handful of recognisable icons. Sunny. Cloudy. Rainy. Stormy.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-03/google-weather-rain-types.png.webp" type="image/webp" />

<img src="/images/2026-03/google-weather-rain-types.png" loading="lazy" alt="" />
</picture>





<figcaption><p>Just the rain conditions from Google’s Weather API — twelve variations that mostly end up looking the same at forecast-card size.</p>
</figcaption>
</figure>

<p>Then those already-simplified icons get applied to an entire 24-hour period, picking one moment or one threshold to stand in for everything else. Which hour represents the day? Does overnight count the same as afternoon?</p>

<p>Those decisions are invisible to the person glancing at their phone over breakfast.</p>

<p>A rain cloud feels definitive in a way that “40% chance of showers” doesn’t. Numbers invite interpretation. An icon just <em>is</em>. The icon closes the conversation.</p>

<p>That’s the specific danger. Speed comes from reduction, and reduction drops nuance. By the time a day’s forecast reaches your lock screen, it’s been squeezed so hard that a 6am drizzle and a proper all-day downpour look identical.</p>

<p>Worth noting: Google just redesigned their weather icons this month (higher contrast, animated, more accessible). They look better. But there’s still one per day.</p>

<h2 id="scroll-down-two-inches">Scroll down two inches</h2>

<p>The irony is that every weather app already has this information. Scroll down past the summary and there’s an hourly breakdown, a precipitation chart, sometimes a rain radar. The data exists, it just gets stripped out before it reaches the one place most people actually look.</p>

<p>BBC Weather is a partial exception. It pairs each daily icon with a text label (“Strong winds and rain”, “Light cloud and a gentle breeze”) which already communicates more than an icon alone. Its hourly view goes further, showing a separate icon per hour alongside an actual precipitation percentage.</p>

<div class="inline-grid two-columns">
<figure class="image ">

<picture>

<source srcset="/images/2026-03/weather-bbc-summary.png.webp" type="image/webp" />

<img src="/images/2026-03/weather-bbc-summary.png" loading="lazy" alt="" />
</picture>






</figure>

<figure class="image ">

<picture>

<source srcset="/images/2026-03/weather-bbc-hourly.png.webp" type="image/webp" />

<img src="/images/2026-03/weather-bbc-hourly.png" loading="lazy" alt="" />
</picture>






</figure>

</div>

<p>The inconsistency between apps doesn’t help either. BBC Weather and the Met Office draw from the same underlying data but can show different icons for the same day, because each has its own rules for what tips a forecast from “cloudy” to “rainy”. Check three apps and you might get three different answers.</p>

<p>Which circles back to Chester Zoo’s actual problem. The forecast isn’t wrong (there probably will be some rain). The icon has just been read as a binary signal (go or don’t go). It’s telling you to take an umbrella. Families are reading it as a cancellation notice.</p>

<p>None of this is radical, and the data to go further is already there. The hourly breakdown already exists in every app, it just never makes it up to the forecast card. A few ways it could, without reinventing anything.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-03/weather-mockup-split-icons.png.webp" type="image/webp" />

<img src="/images/2026-03/weather-mockup-split-icons.png" loading="lazy" alt="" />
</picture>





<figcaption><p>Split the icon into morning and afternoon — Tuesday shows rain early, sun later.</p>
</figcaption>
</figure>

<figure class="image  center">

<picture>

<source srcset="/images/2026-03/weather-mockup-badge.png.webp" type="image/webp" />

<img src="/images/2026-03/weather-mockup-badge.png" loading="lazy" alt="" />
</picture>





<figcaption><p>A badge showing until when rain is expected — same icon, much less alarming.</p>
</figcaption>
</figure>

<figure class="image  center">

<picture>

<source srcset="/images/2026-03/weather-mockup-timeline.png.webp" type="image/webp" />

<img src="/images/2026-03/weather-mockup-timeline.png" loading="lazy" alt="" />
</picture>





<figcaption><p>A timeline strip showing wet and dry hours — glanceable, but tells you the shape of the day rather than just whether rain appears somewhere in it.</p>
</figcaption>
</figure>

<p>The problem isn’t that nobody knows how to do this. It’s that somewhere in the design of these apps, someone decided one icon per day was enough, and that decision got copied across every weather app on every home screen until it stopped looking like a choice at all. It takes a consortium of zoos and theme parks losing millions before anyone notices that the thing everyone assumed was fine might not be.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">It&apos;s 2026, why are vendor prefixes still in your CSS?</title>
        <link href="https://blog.omgmog.net/post/why-vendor-prefixes-are-still-in-your-css/" rel="alternate" type="text/html" title="It&apos;s 2026, why are vendor prefixes still in your CSS?" />
        <link href="https://blog.omgmog.net/post/why-vendor-prefixes-are-still-in-your-css/.txt" rel="alternate" type="text/plain" title="It&apos;s 2026, why are vendor prefixes still in your CSS? (Plain Text)" />
        <published>2026-03-11T00:00:00+00:00</published>
        <updated>2026-03-11T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/why-vendor-prefixes-are-still-in-your-css/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="web-development" />
        
        <category term="css" />
        
        <summary type="html"><![CDATA[I’ve spent more time over the last 20 years working on front-end asset pipelines than I’d care to admit. One thing that kept coming up when inheriting older codebases was CSS full of vendor prefixes that hadn’t been necessary for years - -webkit-border-radius sitting above border-radius, -moz-box-shadow doubling up on box-shadow.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/why-vendor-prefixes-are-still-in-your-css/"><![CDATA[<p>I’ve spent more time over the last 20 years working on front-end asset pipelines than I’d care to admit. One thing that kept coming up when inheriting older codebases was CSS full of vendor prefixes that hadn’t been necessary for years - <code class="language-plaintext highlighter-rouge">-webkit-border-radius</code> sitting above <code class="language-plaintext highlighter-rouge">border-radius</code>, <code class="language-plaintext highlighter-rouge">-moz-box-shadow</code> doubling up on <code class="language-plaintext highlighter-rouge">box-shadow</code>.</p>

<!-- more -->

<p>Some of it made sense historically. One project had to support an embedded Internet Explorer 7 browser, and when your floor is IE7 you add all the prefixes, just in case. The tool to handle this automatically was there - <a href="https://github.com/postcss/autoprefixer">Autoprefixer</a> - just not trusted or configured properly. So developers worked around it manually, inconsistently, and the CSS showed it.</p>

<h2 id="what-vendor-prefixes-were-for">What vendor prefixes were for</h2>

<p>Through the early 2010s, the alternative to <code class="language-plaintext highlighter-rouge">-webkit-border-radius</code> was slicing corner images in Photoshop and wiring them up with a pile of nested divs. Browsers were shipping experimental CSS features under vendor prefixes - <code class="language-plaintext highlighter-rouge">-webkit-</code> for Safari and Chrome, <code class="language-plaintext highlighter-rouge">-moz-</code> for Firefox, <code class="language-plaintext highlighter-rouge">-ms-</code> for IE, <code class="language-plaintext highlighter-rouge">-o-</code> for Opera - so you could use them before they were standardised. A prefixed property was still far better than corner images.</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.box</span> <span class="p">{</span>
  <span class="nl">-webkit-border-radius</span><span class="p">:</span> <span class="m">10px</span><span class="p">;</span>
  <span class="nl">-moz-border-radius</span><span class="p">:</span> <span class="m">10px</span><span class="p">;</span>
  <span class="nl">border-radius</span><span class="p">:</span> <span class="m">10px</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The idea was that once the spec settled, the unprefixed version became the standard and the prefixed ones could be dropped. That worked in principle. In practice, nobody dropped them. The Sass approach was to <a href="/post/getting-started-with-using-sass-in-your-existing-website/">bundle the prefixed variants into a mixin</a> so at least you only wrote them once - but the mixin still needed maintaining as browser support changed.</p>

<p>The CSSWG keeps a <a href="https://wiki.csswg.org/ideas/mistakes">list of CSS design mistakes</a> - decisions that made sense at the time but created problems that outlasted their usefulness. Vendor prefixes would fit right in.</p>

<h2 id="the-dead-ones">The dead ones</h2>

<p>Most of what I find in old CSS files doesn’t need prefixes at all. <code class="language-plaintext highlighter-rouge">border-radius</code> and <code class="language-plaintext highlighter-rouge">box-shadow</code> have been supported without prefixes since 2011. <code class="language-plaintext highlighter-rouge">transition</code> since 2013. <code class="language-plaintext highlighter-rouge">transform</code> since 2014. <code class="language-plaintext highlighter-rouge">flexbox</code> since 2015. Flexbox is a good example of how bad it got:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">display</span><span class="o">:</span> <span class="nt">-webkit-box</span><span class="o">;</span>
<span class="nt">display</span><span class="o">:</span> <span class="nt">-webkit-flex</span><span class="o">;</span>
<span class="nt">display</span><span class="o">:</span> <span class="nt">-moz-box</span><span class="o">;</span>
<span class="nt">display</span><span class="o">:</span> <span class="nt">-ms-flexbox</span><span class="o">;</span>
<span class="nt">display</span><span class="o">:</span> <span class="nt">flex</span><span class="o">;</span>
</code></pre></div></div>

<p>Five lines. One of them is correct. The other four are extra bytes that have done nothing for a decade. The <a href="https://www.projectwallace.com/the-css-selection/2026">CSS Selection 2026</a> report found the median website still has 140 vendor-prefixed properties. <code class="language-plaintext highlighter-rouge">-ms-input-placeholder</code> - an IE-only prefix, for a browser that’s been dead since 2022 - still shows up in <em>44% of sites</em>.</p>

<h2 id="the-browser-landscape-in-2026">The browser landscape in 2026</h2>

<p>Part of why those prefixes are dead is just the browser market. <a href="https://gs.statcounter.com/browser-market-share/#monthly-202501-202601-bar">As of early 2026</a>, Chrome has around 69% of global traffic. Add Edge, Samsung Internet, and Opera - all Chromium-based - and it’s closer to 78%. Safari sits at 16%, Firefox at 2.3%.</p>

<p>The diversity of rendering engines that made vendor prefixes necessary in the first place has largely gone. <code class="language-plaintext highlighter-rouge">-o-</code> died when Opera switched to Blink in 2013. <code class="language-plaintext highlighter-rouge">-ms-</code> died with IE. <code class="language-plaintext highlighter-rouge">-moz-</code> is barely relevant at 2.3% market share, and Firefox has dropped prefix requirements for almost everything anyway.</p>

<p>Which leaves <code class="language-plaintext highlighter-rouge">-webkit-</code>, and really that means Safari.</p>

<h2 id="the-ones-still-needed">The ones still needed</h2>

<p>Almost all modern prefix requirements are <code class="language-plaintext highlighter-rouge">-webkit-</code> only - <code class="language-plaintext highlighter-rouge">tab-size</code> is the <a href="https://weser.io/blog/vendor-prefixes-in-2024">one exception</a>, still needing <code class="language-plaintext highlighter-rouge">-moz-</code>. Everything else is Safari.</p>

<p>Some properties need <code class="language-plaintext highlighter-rouge">-webkit-</code> because they’re transitional - the standard exists but Safari hasn’t dropped the prefix yet. <code class="language-plaintext highlighter-rouge">appearance</code>, <code class="language-plaintext highlighter-rouge">backdrop-filter</code>, <code class="language-plaintext highlighter-rouge">user-select</code> fall into this category.</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">-webkit-appearance</span><span class="o">:</span> <span class="nt">none</span><span class="o">;</span>
<span class="nt">-moz-appearance</span><span class="o">:</span> <span class="nt">none</span><span class="o">;</span>
<span class="nt">appearance</span><span class="o">:</span> <span class="nt">none</span><span class="o">;</span>

<span class="nt">-webkit-backdrop-filter</span><span class="o">:</span> <span class="nt">blur</span><span class="o">(</span><span class="err">10</span><span class="nt">px</span><span class="o">);</span>
<span class="nt">backdrop-filter</span><span class="o">:</span> <span class="nt">blur</span><span class="o">(</span><span class="err">10</span><span class="nt">px</span><span class="o">);</span>
</code></pre></div></div>

<p>Then there’s a smaller set that are <code class="language-plaintext highlighter-rouge">-webkit-</code> <em>forever</em> - no standard equivalent exists. <code class="language-plaintext highlighter-rouge">-webkit-text-stroke</code> and <code class="language-plaintext highlighter-rouge">-webkit-text-fill-color</code> for text outlines. <code class="language-plaintext highlighter-rouge">-webkit-tap-highlight-color</code> for controlling the tap flash on iOS. <code class="language-plaintext highlighter-rouge">-webkit-box-reflect</code> for reflection effects. These aren’t going anywhere.</p>

<p>The problem is keeping track of which category a property falls into, and in which browsers. That changes over time. I’m not going to remember that.</p>

<h2 id="copilot-is-making-it-worse">Copilot is making it worse</h2>

<p>Old codebases carrying this stuff around makes sense. What I didn’t expect was vendor prefixes turning up in <em>new</em> code, written in 2025 and 2026.</p>

<p>A lot of it traces back to autocomplete. Copilot and tools like it are trained on the whole history of the web - including years of CSS written before Autoprefixer existed, when manually adding <code class="language-plaintext highlighter-rouge">-webkit-</code> and <code class="language-plaintext highlighter-rouge">-moz-</code> was the only way to get cross-browser support. The model has <em>no</em> sense of what year it is. It sees a <code class="language-plaintext highlighter-rouge">border-radius</code> and it’s seen thousands of examples where the prefixed versions came first, so it suggests them.</p>

<p>The completion looks plausible. It’s not wrong in some obvious way. A developer who doesn’t already know the history accepts it, commits it, and the dead code is back - not from 2012 habits, but from tooling that confidently reproduces them.</p>

<h2 id="autoprefixer-handles-this">Autoprefixer handles this</h2>

<p>Autoprefixer with <a href="https://postcss.org/">PostCSS</a> is the right answer. Write unprefixed CSS, the build step adds whatever prefixes are actually needed for the target browsers.</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* write this */</span>
<span class="nc">.box</span> <span class="p">{</span>
  <span class="nl">appearance</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
  <span class="py">backdrop-filter</span><span class="p">:</span> <span class="n">blur</span><span class="p">(</span><span class="m">10px</span><span class="p">);</span>
  <span class="py">user-select</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* build outputs this */</span>
<span class="nc">.box</span> <span class="p">{</span>
  <span class="nl">-webkit-appearance</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
  <span class="nl">-moz-appearance</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
  <span class="nl">appearance</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
  <span class="nl">-webkit-backdrop-filter</span><span class="p">:</span> <span class="n">blur</span><span class="p">(</span><span class="m">10px</span><span class="p">);</span>
  <span class="py">backdrop-filter</span><span class="p">:</span> <span class="n">blur</span><span class="p">(</span><span class="m">10px</span><span class="p">);</span>
  <span class="nl">-webkit-user-select</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
  <span class="py">user-select</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>It’s driven by a <a href="https://github.com/browserslist/browserslist">browserslist</a> config - either in <code class="language-plaintext highlighter-rouge">package.json</code> or a <code class="language-plaintext highlighter-rouge">.browserslistrc</code> file:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>last 2 versions
&gt; 1%
not dead
</code></pre></div></div>

<p>Autoprefixer checks that and adds only the prefixes those browsers need. As browsers update and drop old requirements, a target list update is all it takes - obsolete prefixes come out automatically.</p>

<p>PostCSS config is minimal:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// postcss.config.js</span>
<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">plugins</span><span class="p">:</span> <span class="p">[</span>
    <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">autoprefixer</span><span class="dl">'</span><span class="p">)</span>
  <span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>For the codebase I mentioned earlier, the migration was two things: configuring Autoprefixer properly, and going through the existing CSS to strip out all the manually-written prefixed properties. Once both were done, the CSS went from full of manual prefixes to zero.</p>

<p>Autoprefixer handles everything, and it knows what it’s doing in a way that a developer copy-pasting from Stack Overflow doesn’t. The <a href="https://www.projectwallace.com/the-css-selection/2026">CSS Selection 2026</a> report noted that vendor prefixes in modern CSS “are primarily managed by CSS toolchains rather than hand-authored” - which tracks. When we finally dropped IE support, we updated the browserslist config and the prefixes came out automatically. No manual cleanup pass needed.</p>

<p>The broader point is that what you write in your source files doesn’t have to be what gets served. The CSS you author is just input - the build step produces whatever the browser actually needs. Hand-writing vendor prefixes into source CSS is solving a problem at the wrong layer.</p>

<h2 id="stylelint-catches-what-slips-through">Stylelint catches what slips through</h2>

<p>Autoprefixer sorts the build output, but it doesn’t stop prefixes getting committed in the first place. That’s where <a href="https://stylelint.io/">Stylelint</a> comes in. It has rules specifically for vendor prefixes - add them to <code class="language-plaintext highlighter-rouge">.stylelintrc</code> and any manually written prefix is a lint error.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"rules"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"property-no-vendor-prefix"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"value-no-vendor-prefix"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"selector-no-vendor-prefix"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"media-feature-name-no-vendor-prefix"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The webkit-forever properties from earlier - <code class="language-plaintext highlighter-rouge">-webkit-tap-highlight-color</code> and the rest - need an exemption, since they’re legitimately needed and Stylelint would otherwise flag them. An inline disable comment is the cleanest way to handle it, since it stays with the code that needs it:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* stylelint-disable-next-line property-no-vendor-prefix */</span>
<span class="nt">-webkit-tap-highlight-color</span><span class="o">:</span> <span class="nt">transparent</span><span class="o">;</span>
</code></pre></div></div>

<p>It also runs as a PostCSS plugin, so if PostCSS is already in the pipeline for Autoprefixer, Stylelint can sit alongside it:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// postcss.config.js</span>
<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">plugins</span><span class="p">:</span> <span class="p">[</span>
    <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">stylelint</span><span class="dl">'</span><span class="p">),</span>
    <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">autoprefixer</span><span class="dl">'</span><span class="p">)</span>
  <span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Or wire it up with <a href="https://typicode.github.io/husky/">husky</a> and <a href="https://github.com/lint-staged/lint-staged">lint-staged</a> and it runs on commit against staged files only:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"lint-staged"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"*.css"</span><span class="p">:</span><span class="w"> </span><span class="s2">"stylelint --fix"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">--fix</code> flag strips the prefixes automatically rather than just erroring. So a Copilot suggestion that sneaks in a <code class="language-plaintext highlighter-rouge">-webkit-border-radius</code> gets cleaned up before it ever hits the repo. Autoprefixer adds what’s needed, Stylelint removes what isn’t.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">The DS Pico might be the last DS flashcart you&apos;ll ever need</title>
        <link href="https://blog.omgmog.net/post/ds-pico-flashcart/" rel="alternate" type="text/html" title="The DS Pico might be the last DS flashcart you&apos;ll ever need" />
        <link href="https://blog.omgmog.net/post/ds-pico-flashcart/.txt" rel="alternate" type="text/plain" title="The DS Pico might be the last DS flashcart you&apos;ll ever need (Plain Text)" />
        <published>2026-03-03T00:00:00+00:00</published>
        <updated>2026-03-03T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/ds-pico-flashcart/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="nintendo" />
        
        <category term="retro-gaming" />
        
        <category term="review" />
        
        <summary type="html"><![CDATA[In 2007 I was deep in the DS flashcart scene, cycling through a string of carts. GBATemp threads, firmware drama, custom skins, compatibility lists. By 2009 I’d moved on. Nearly twenty years later, the DS Pico has pulled me right back in. Open-source, built around an RP2040, and around £10 from Chiscart on AliExpress. I’ve bought three.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/ds-pico-flashcart/"><![CDATA[<p>In 2007 I was deep in the DS flashcart scene, cycling through a string of carts. <a href="https://gbatemp.net">GBATemp</a> threads, firmware drama, custom skins, compatibility lists. By 2009 I’d moved on.</p>

<figure class="image  center">

<picture>

<img src="/images/2026-02/dspico/backin.gif" loading="lazy" alt="" />
</picture>






</figure>

<p>Nearly twenty years later, the DS Pico has pulled me right back in. Open-source, built around an RP2040, and around £10 from <a href="https://www.aliexpress.com/item/1005011543735291.html">Chiscart on AliExpress</a>. I’ve bought three.</p>

<!-- more -->

<p>DS emulation is pretty much solved at this point. It runs fine on a phone without much effort. The case for real hardware isn’t compatibility. It’s that picking up a DS means playing a game.</p>

<p>No lock screen, no notifications, no browser tab in the background. The clamshell clicks shut when you’re done and slips into a pocket. When you open it again there’s no boot screen, no updates, no fuss.</p>

<h2 id="a-pi-in-a-ds-slot">A Pi in a DS slot</h2>

<figure class="image  center massive">

<picture>

<source srcset="/images/2026-02/dspico/slot1.jpg.webp" type="image/webp" />

<img src="/images/2026-02/dspico/slot1.jpg" loading="lazy" alt="" />
</picture>






</figure>

<p>The DS Pico is an open-source flashcart for the Nintendo DS family. It’s the result of about four years of work by the <a href="https://www.lnh-team.org/">LNH Team</a>, a group focused on preserving classic consoles and creating new hardware to keep them alive.</p>

<table class="massive">
  <thead>
    <tr>
      <th>Feature</th>
      <th>Details</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Chip</strong></td>
      <td>RP2040 microcontroller (same as Raspberry Pi Pico)</td>
    </tr>
    <tr>
      <td><strong>Storage</strong></td>
      <td>MicroSD card slot</td>
    </tr>
    <tr>
      <td><strong>Read speed</strong></td>
      <td>Up to 6MB/s sequential</td>
    </tr>
    <tr>
      <td><strong>Power</strong></td>
      <td>Dual power support (~57mW average)</td>
    </tr>
    <tr>
      <td><strong>Port</strong></td>
      <td>Micro USB for firmware updates</td>
    </tr>
  </tbody>
</table>

<p>The schematics, firmware, software, and shell designs are all on <a href="https://www.lnh-team.org/dspico_hw.html">their hardware page</a>. Anyone can make them, and bundle them up with extras, which is why the price varies so much.</p>

<p>The LNH Team also built <a href="https://www.lnh-team.org/dspico_sw.html">their own software</a> to go with it. <a href="https://github.com/LNH-team/pico-launcher">Pico Launcher</a> is the front-end, <a href="https://github.com/LNH-team/pico-loader">Pico Loader</a> runs the games, and load times are nearly instant compared to alternatives like <a href="https://github.com/DS-Homebrew/nds-bootstrap">nds-bootstrap</a>. It supports custom themes and box art display (<a href="https://github.com/Scaletta/PicoCover">PicoCover</a> is the easiest way to fetch and convert NDS and GBA cover art from <a href="https://www.gametdb.com/">GameTDB</a> into the right format), though themes are fairly basic.</p>

<p>I’ve since switched to <a href="https://github.com/DS-Homebrew/TWiLightMenu">TWiLight Menu++</a> as my launcher of choice. There’s a DS Pico-specific build, it looks like the DSi menu out of the box, and it integrates <a href="https://github.com/Gericom/GBARunner2">GBARunner2</a> so GBA ROMs launch right alongside everything else (particularly nice on the DS Lite). It also supports Pico Loader as its game loading backend (the <code class="language-plaintext highlighter-rouge">.bin</code> files go in a <code class="language-plaintext highlighter-rouge">_pico</code> folder on the card, with Game Loader set to “pico” in Settings, Games and Apps), which is a lot faster than the default nds-bootstrap (though a small number of titles have known compatibility issues). Other launchers are available too.</p>

<h2 id="in-the-box">In the box</h2>

<figure class="image  center massive">

<picture>

<source srcset="/images/2026-02/dspico/unboxing.jpg.webp" type="image/webp" />

<img src="/images/2026-02/dspico/unboxing.jpg" loading="lazy" alt="" />
</picture>






</figure>

<p>Each came in a small Chiscart-branded box, with the cart in an injection-moulded case (the purple PCB visible through it), a tiny white micro USB cable, and two stickers (one DS Pico logo, one Chiscart). I’ve got three (clear, clear charcoal, and clear red).</p>

<p>No SD card included, which was fine. I’ve got a stack of reputable branded cards already, and a library of game backups to go with them.</p>

<p>Some sellers are starting to offer a USB-C version for a bit more, worth it if you’d rather not dig out a micro-USB cable (though the larger port apparently means a smaller label on the cart).</p>

<p>Build quality is good. The injection-moulded shell feels premium, clicks in properly and doesn’t rattle around or feel too tight. Some sellers use 3D-printed shells, and there are reports of quality issues with those. I’d check the listing before buying. Only complaint with mine is that the microSD protrudes about 0.5mm from the shell.</p>

<h2 id="getting-started">Getting started</h2>

<p>Most units come pre-flashed with <a href="https://github.com/LNH-team/dspico-wrfuxxed">WRFUxxed</a>, which works across the whole DS family including unmodded DSi and 3DS. There’s also an ntrboot variant for softmodding those consoles, but the DS Lite needs the standard build. Mine came with it already on there, though not every seller ships it enabled. I reflashed to the latest build before testing anyway. The <a href="https://sanrax.github.io/flashcart-guides/cart-guides/dspico/">flashcart-guides setup guide</a> has more detail if needed.</p>

<p>To flash, eject the SD card and plug in the USB cable. It goes straight into BOOTSEL, showing up as <code class="language-plaintext highlighter-rouge">RPI-RP2</code>. Drop the <code class="language-plaintext highlighter-rouge">.uf2</code> on and it reboots. If the card’s already in, hold the button while plugging in.</p>

<p>Getting the launcher and games on to the SD card:</p>

<ol>
  <li>Format a microSD card as FAT32 with 32KB clusters (it won’t detect other formats; 32KB is the standard recommendation for flashcarts)</li>
  <li>Download a pre-built SD card setup, such as <a href="https://archive.org/details/dspico-bootloader-fw-sd-files.-7z">this one on Archive.org</a>, and extract it to the card</li>
  <li>Add your ROMs anywhere on the card (I use a <code class="language-plaintext highlighter-rouge">roms</code> folder)</li>
  <li>Pop the card in and you’re done</li>
</ol>

<p>The cart <strong>needs</strong> a FAT32 SD card to be detected. By design, no card means no boot, which keeps BOOTSEL mode always accessible. The <a href="https://ds-homebrew.com/discord">DSi hacking Discord</a> has a dedicated DS Pico channel if you have any problems.</p>

<h2 id="console-support">Console support</h2>

<p>The DS Pico works across the whole DS family, though what you get varies by console.</p>

<p><strong>On the DSi and 3DS</strong>, this is where it earns its keep. Full DSi mode means DSi-enhanced games use the camera and modern WiFi, and everything that used to need a proper DSi flashcart just works. On the 3DS it shows up as “Nintendo DS Demonstration” in the home menu (I haven’t been able to test this personally, having sold mine a couple of years back, but it’s well-documented to work).</p>

<p>WRFUxxed autoboots on the DSi by default. The exploit kicks in on power-on, there’s a brief red WRFU Tester screen, and it’s straight into Pico Launcher without ever touching the DSi menu. The <a href="https://archive.org/details/dspico-bootloader-fw-sd-files.-7z">archive.org package</a> includes a <code class="language-plaintext highlighter-rouge">DSPico - NoAutoDSI.uf2</code> as of v1.1.4 if that’s not wanted, or it can be disabled by building from source with <code class="language-plaintext highlighter-rouge">ENABLE_PREVENT_DSI_AUTOBOOT</code>.</p>

<p>Everything I’ve thrown at it has worked. NDS games load without issue, and DSi-enhanced games that use the camera get proper camera access. DSiWare packaged as <code class="language-plaintext highlighter-rouge">.nds</code> files works fine too. The firmware also supports <a href="https://wiki.ds-homebrew.com/ds-index/ntrboot">ntrboot</a> as an optional feature you enable when building from source, useful if there’s an unmodified DSi or 3DS that needs softmodding and a DS Pico already kicking about (the LNH Team recommend powering the board via micro-USB rather than the console for DSi ntrboot).</p>

<figure class="image  center massive">

<picture>

<source srcset="/images/2026-02/dspico/dsi.jpg.webp" type="image/webp" />

<img src="/images/2026-02/dspico/dsi.jpg" loading="lazy" alt="" />
</picture>






</figure>

<p><strong>On the DS Lite</strong>, it’s DS mode only. No DSiWare, no DSi-enhanced features. Shows up as “DSpico LNH Team” in the menu and acts as a standard NDS flashcart. The DS Lite needs a non-ntrboot firmware variant. DS games run perfectly, and DSi-enhanced titles fall back to their DS functionality as expected. DSiWare fails to launch, and leaves you with a white screen.</p>

<h2 id="micro-usb-tricks">Micro USB tricks</h2>

<p>The micro USB port isn’t just for firmware updates. The LNH Team put out <a href="https://github.com/LNH-team/dspico-usb-examples/releases">four experimental homebrew apps</a> that use it:</p>

<ul>
  <li><a href="https://github.com/LNH-team/dspico-usb-examples/releases"><code class="language-plaintext highlighter-rouge">mass-storage.nds</code></a>, which mounts the microSD card over USB so you can drag files onto it without removing the card</li>
  <li><a href="https://github.com/LNH-team/dspico-usb-examples/releases"><code class="language-plaintext highlighter-rouge">usb-microphone.nds</code></a>, which uses the DS/DSi/3DS as a PC microphone</li>
  <li><a href="https://github.com/LNH-team/dspico-usb-examples/releases"><code class="language-plaintext highlighter-rouge">usb-speaker.nds</code></a>, which pipes PC audio out through the console’s speaker</li>
  <li><a href="https://github.com/LNH-team/dspico-usb-examples/releases"><code class="language-plaintext highlighter-rouge">usb-video.nds</code></a>, which turns a DSi or 3DS into a webcam</li>
</ul>

<p>They’re very much demos rather than finished tools, and they need a bit of faffing to get running (launch the app, then plug in the cable). But <code class="language-plaintext highlighter-rouge">mass-storage.nds</code> is actually useful. If the card reader dies, it’s an <em>easy</em> way to copy games straight to the cart over USB.</p>

<h2 id="open-source-actually">Open source, actually</h2>

<p>Most flashcarts treat the source as a trade secret. The team behind the DS Pico publish everything online, from PCB designs through to 3D-printable shell files and box art. Anyone with the skills and equipment can build one from scratch.</p>

<p>This matters because the DS flashcart scene has been a mess for years. The original R4 team disbanded ages ago, and the market filled up with clones that sometimes included timebombs (code that bricks the cart after a certain date to force you to buy a new one). With an open-source design, that’s not a concern.</p>

<p>The LNH Team won’t sell boards directly, leaving it to third-party sellers at anywhere from £10 to £25. Some bundle a pre-loaded SD card (<em>games included</em>, if you know what I mean), but it’s all the same open-source hardware.</p>

<h2 id="further-watching">Further watching</h2>

<figure class="iframe ">









<div data-src="https://www.youtube.com/embed/29A2TXtmSLI" class="figure-content placeholder" width="600" height="350">
<picture>
  <source srcset="https://i.ytimg.com/vi_webp/29A2TXtmSLI/hqdefault.webp" type="image/webp" />
  <img src="https://i.ytimg.com/vi/29A2TXtmSLI/hqdefault.jpg" loading="lazy" alt="" />
</picture>
</div>



</figure>

<figure class="iframe ">









<div data-src="https://www.youtube.com/embed/aL5P8sHz_Uk" class="figure-content placeholder" width="600" height="350">
<picture>
  <source srcset="https://i.ytimg.com/vi_webp/aL5P8sHz_Uk/hqdefault.webp" type="image/webp" />
  <img src="https://i.ytimg.com/vi/aL5P8sHz_Uk/hqdefault.jpg" loading="lazy" alt="" />
</picture>
</div>



</figure>]]></content>
    </entry>
    
    <entry>
        <title type="html">Reducing commit log noise caused by a GitHub Action</title>
        <link href="https://blog.omgmog.net/post/reducing-github-action-commit-noise/" rel="alternate" type="text/html" title="Reducing commit log noise caused by a GitHub Action" />
        <link href="https://blog.omgmog.net/post/reducing-github-action-commit-noise/.txt" rel="alternate" type="text/plain" title="Reducing commit log noise caused by a GitHub Action (Plain Text)" />
        <published>2026-02-24T00:00:00+00:00</published>
        <updated>2026-02-24T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/reducing-github-action-commit-noise/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="github-pages" />
        
        <category term="programming" />
        
        <category term="guide" />
        
        <category term="git" />
        
        <summary type="html"><![CDATA[I have a “now playing” widget on my /now page that shows the last track scrobbled to Last.fm. It’s powered by a GitHub Actions workflow that polls the Last.fm API every hour and commits the result to the repo. It worked fine, but quietly polluted my commit history.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/reducing-github-action-commit-noise/"><![CDATA[<p>I have a “now playing” widget on my <a href="/now">/now page</a> that shows the last track scrobbled to Last.fm. It’s powered by a <a href="https://github.com/omgmog/omgmog.github.com/blob/main/.github/workflows/lastfm.yml">GitHub Actions workflow</a> that polls the Last.fm API every hour and commits the result to the repo. It worked fine, but quietly polluted my commit history.</p>

<!-- more -->

<h2 id="the-setup">The setup</h2>

<p>A scheduled action runs every hour, fetches my most recent track via the Last.fm API, and writes it to <code class="language-plaintext highlighter-rouge">_data/now/lastfm.json</code>. If the track changes, it commits the file and pushes, triggering a Jekyll build and redeploy.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Commit changes</span>
  <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">git config user.name "github-actions"</span>
    <span class="s">git config user.email "actions@github.com"</span>
    <span class="s">git add _data/now/lastfm.json</span>
    <span class="s">if git diff --staged --quiet; then</span>
      <span class="s">echo "No change"</span>
    <span class="s">else</span>
      <span class="s">git commit -m "Update data"</span>
      <span class="s">git push</span>
    <span class="s">fi</span>
</code></pre></div></div>

<p>The script only writes to disk when something actually changes (track name, artist, or now-playing status), so most hourly runs are no-ops. But when I’m listening to music, each track change produced a new commit. After a few months the log looked like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>d3e220d post about my home nas
a051ecf Update data
c2d9699 Update data
5eb6c17 Update data
9644ed6 Update data
e4dca4d Update data
b4c5fba limit the feed to 25 posts
</code></pre></div></div>

<p>142 Last.fm commits across 1091 total. Roughly one in eight commits was noise.</p>

<h2 id="fixing-it-going-forward">Fixing it going forward</h2>

<p>The solution was to make the workflow amend the previous update commit, instead of creating a new commit each time. That leaves at most one Last.fm commit between real ones:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout repo</span>
  <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
  <span class="na">with</span><span class="pi">:</span>
    <span class="na">fetch-depth</span><span class="pi">:</span> <span class="m">0</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Commit changes</span>
  <span class="na">id</span><span class="pi">:</span> <span class="s">commit</span>
  <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">git config user.name "github-actions"</span>
    <span class="s">git config user.email "actions@github.com"</span>
    <span class="s">git add _data/now/lastfm.json</span>
    <span class="s">if git diff --staged --quiet; then</span>
      <span class="s">echo "changed=false" &gt;&gt; $GITHUB_OUTPUT</span>
    <span class="s">else</span>
      <span class="s">if [ "$(git log -1 --pretty=%s)" = "Update data" ]; then</span>
        <span class="s">git commit --amend --no-edit</span>
        <span class="s">git push --force-with-lease</span>
      <span class="s">else</span>
        <span class="s">git commit -m "Update data"</span>
        <span class="s">git push</span>
      <span class="s">fi</span>
      <span class="s">echo "changed=true" &gt;&gt; $GITHUB_OUTPUT</span>
    <span class="s">fi</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">fetch-depth: 0</code> is important here. <a href="https://github.com/actions/checkout"><code class="language-plaintext highlighter-rouge">actions/checkout@v4</code></a> does a shallow clone by default (depth 1), and force-pushing a shallow clone replaces the entire remote history with a single commit. I found this out the hard way.</p>

<p><code class="language-plaintext highlighter-rouge">--force-with-lease</code> makes the push fail if something else landed on <code class="language-plaintext highlighter-rouge">main</code> in the meantime, rather than silently overwriting it.</p>

<h2 id="cleaning-up-the-existing-history">Cleaning up the existing history</h2>

<p>The workflow change stopped the bleeding, but there were still 142 commits in the log. I wanted to collapse each run of consecutive Last.fm commits into one.</p>

<p><code class="language-plaintext highlighter-rouge">git rebase -i</code> is the usual tool for this, but editing 190 lines of a rebase todo by hand wasn’t appealing. I set <code class="language-plaintext highlighter-rouge">GIT_SEQUENCE_EDITOR</code> to a small Python script that rewrites the todo:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#!/usr/bin/env python3
</span><span class="kn">import</span> <span class="nn">sys</span>

<span class="n">todo_file</span> <span class="o">=</span> <span class="n">sys</span><span class="p">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>
<span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">todo_file</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
    <span class="n">lines</span> <span class="o">=</span> <span class="n">f</span><span class="p">.</span><span class="n">readlines</span><span class="p">()</span>

<span class="n">out</span> <span class="o">=</span> <span class="p">[]</span>
<span class="n">prev_was_lastfm</span> <span class="o">=</span> <span class="bp">False</span>
<span class="k">for</span> <span class="n">line</span> <span class="ow">in</span> <span class="n">lines</span><span class="p">:</span>
    <span class="n">stripped</span> <span class="o">=</span> <span class="n">line</span><span class="p">.</span><span class="n">strip</span><span class="p">()</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">stripped</span> <span class="ow">or</span> <span class="n">stripped</span><span class="p">.</span><span class="n">startswith</span><span class="p">(</span><span class="s">'#'</span><span class="p">):</span>
        <span class="n">out</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">line</span><span class="p">)</span>
        <span class="k">continue</span>
    <span class="n">parts</span> <span class="o">=</span> <span class="n">stripped</span><span class="p">.</span><span class="n">split</span><span class="p">(</span><span class="s">' '</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>
    <span class="n">action</span><span class="p">,</span> <span class="n">sha</span> <span class="o">=</span> <span class="n">parts</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">parts</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>
    <span class="n">msg</span> <span class="o">=</span> <span class="n">parts</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span> <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">parts</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">2</span> <span class="k">else</span> <span class="s">''</span>
    <span class="n">is_update</span> <span class="o">=</span> <span class="n">msg</span> <span class="o">==</span> <span class="s">'Update data'</span>
    <span class="k">if</span> <span class="n">is_update</span> <span class="ow">and</span> <span class="n">prev_was_update</span><span class="p">:</span>
        <span class="n">out</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">line</span><span class="p">.</span><span class="n">replace</span><span class="p">(</span><span class="n">action</span><span class="p">,</span> <span class="s">'fixup'</span><span class="p">,</span> <span class="mi">1</span><span class="p">))</span>
    <span class="k">else</span><span class="p">:</span>
        <span class="n">out</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">line</span><span class="p">)</span>
    <span class="n">prev_was_update</span> <span class="o">=</span> <span class="n">is_update</span>

<span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">todo_file</span><span class="p">,</span> <span class="s">'w'</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
    <span class="n">f</span><span class="p">.</span><span class="n">writelines</span><span class="p">(</span><span class="n">out</span><span class="p">)</span>
</code></pre></div></div>

<p>It reads through the rebase todo in order. The first <code class="language-plaintext highlighter-rouge">Update data</code> commit in a consecutive run stays as <code class="language-plaintext highlighter-rouge">pick</code>. Every subsequent one becomes <code class="language-plaintext highlighter-rouge">fixup</code>, folding it into the previous commit and discarding its message. Any other commit breaks the run.</p>

<p>I ran it from just before the <a href="https://github.com/omgmog/omgmog.github.com/commit/a1aaf39e">oldest Last.fm commit</a>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">GIT_SEQUENCE_EDITOR</span><span class="o">=</span><span class="s2">"python3 /tmp/squash_lastfm.py"</span> git rebase <span class="nt">-i</span> a1aaf39e
</code></pre></div></div>

<p>1091 commits became 969. 142 Last.fm commits became 20, one per cluster. Then a force push:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git push <span class="nt">--force-with-lease</span>
</code></pre></div></div>

<p>The log now looks like it should:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>e35e061 Update data
b4c5fba limit the feed to 25 posts
fc050f2 also highlight the nav when viewing archive page
...
4fdfec4 Update data
adc03e8 Improve feed.xml
</code></pre></div></div>]]></content>
    </entry>
    
    <entry>
        <title type="html">Building a Home NAS from Mostly Spare Parts</title>
        <link href="https://blog.omgmog.net/post/building-a-home-nas-from-mostly-spare-parts/" rel="alternate" type="text/html" title="Building a Home NAS from Mostly Spare Parts" />
        <link href="https://blog.omgmog.net/post/building-a-home-nas-from-mostly-spare-parts/.txt" rel="alternate" type="text/plain" title="Building a Home NAS from Mostly Spare Parts (Plain Text)" />
        <published>2026-02-23T00:00:00+00:00</published>
        <updated>2026-02-23T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/building-a-home-nas-from-mostly-spare-parts/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="linux" />
        
        <category term="server" />
        
        <category term="guide" />
        
        <category term="review" />
        
        <summary type="html"><![CDATA[I’ve had a SNUNMU GK3 mini-PC kicking about since early-2023, barely used. An impulse buy that ended up on a shelf, alongside a drawer full of old drives from various machines and NAS attempts that never quite came together. Picking up a 5-bay USB enclosure finally gave me a reason to use all of it. It’s not polished and it’s not redundant, but it’s simple, flexible, and entirely under my control.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/building-a-home-nas-from-mostly-spare-parts/"><![CDATA[<p>I’ve had a SNUNMU GK3 mini-PC kicking about since early-2023, barely used. An impulse buy that ended up on a shelf, alongside a drawer full of old drives from various machines and NAS attempts that never quite came together. Picking up a 5-bay USB enclosure finally gave me a reason to use all of it. It’s not polished and it’s not redundant, but it’s simple, flexible, and entirely under my control.</p>

<!-- more -->

<h2 id="the-hardware">The hardware</h2>

<figure class="image  center">

<picture>

<source srcset="/images/2026-02/snunmu-gk3.png.webp" type="image/webp" />

<img src="/images/2026-02/snunmu-gk3.png" loading="lazy" alt="" />
</picture>






</figure>

<p>The SNUNMU GK3 is one of those cheap Chinese mini-PCs from Amazon.</p>

<table class="massive">
  <thead>
    <tr>
      <th>Feature</th>
      <th>Details</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>CPU</strong></td>
      <td>Intel Celeron J4125 (quad-core, 2.0GHz base, 2.7GHz burst)</td>
    </tr>
    <tr>
      <td><strong>RAM</strong></td>
      <td>12GB DDR4</td>
    </tr>
    <tr>
      <td><strong>Storage</strong></td>
      <td>128GB M.2 SSD (built-in)</td>
    </tr>
    <tr>
      <td><strong>Network</strong></td>
      <td>Gigabit Ethernet + Wi-Fi</td>
    </tr>
    <tr>
      <td><strong>USB</strong></td>
      <td>USB 3.0 ports</td>
    </tr>
    <tr>
      <td><strong>Cost</strong></td>
      <td>About £150 back in 2023</td>
    </tr>
  </tbody>
</table>

<p>There’s no official website or support page for the SNUNMU GK3. The Amazon listing is about all you’ll find. That’s pretty typical for these no-name mini-PCs, but it does mean you’re on your own if anything goes wrong.</p>

<p>The <a href="https://www.amazon.co.uk/dp/B085DS8XDS">Yottamaster FS5RU3</a> (£179) is a 5-bay USB 3.0 enclosure with solid aluminium construction, hot-swappable bays, and a fan that’s barely noticeable. I filled all five bays with old hard drives, 4TB x2, 2TB, 1TB, and 750GB.</p>

<p>It supports hardware RAID (0, 1, 3, 5, 10), but mismatched drive sizes make that awkward, and I don’t need the extra complexity. I’m running it in JBOD mode instead, roughly 11.75TB raw, each drive mounted individually over USB 3.0.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-02/nas-setup.png.webp" type="image/webp" />

<img src="/images/2026-02/nas-setup.png" loading="lazy" alt="" />
</picture>






</figure>

<h2 id="the-software">The software</h2>

<p>I tried OMV initially and it felt limiting. The web interface is nice for basic storage management, but once I wanted Docker containers, or anything beyond simple file shares, I was fighting it to do things I could do in a terminal in seconds.</p>

<p>So I went with plain <a href="https://www.debian.org/releases/trixie/">Debian</a>. On top of that I’m running:</p>

<ul>
  <li><a href="https://casaos.io/"><strong>CasaOS</strong></a> - a lightweight browser dashboard. It’s in maintenance mode now (the developers have moved to <a href="https://www.zimaspace.com/">ZimaOS</a>), but works fine on top of Debian. I’ve <a href="https://github.com/IceWhaleTech/CasaOS-UI/compare/main...omgmog:CasaOS-UI:main">tweaked it a bit</a> (app grouping, a sorted app store, some clutter removed).</li>
  <li><a href="https://jellyfin.org/"><strong>Jellyfin</strong></a> - media server for films, TV, music. Open source, no subscription nonsense.</li>
  <li><a href="https://www.portainer.io/"><strong>Portainer</strong></a> - web UI for managing Docker containers without dropping into the CLI every time.</li>
</ul>

<figure class="image  massive">

<picture>

<source srcset="/images/2026-02/casaos-dashboard.png.webp" type="image/webp" />

<img src="/images/2026-02/casaos-dashboard.png" loading="lazy" alt="" />
</picture>






</figure>

<h2 id="setting-up-the-drives">Setting up the drives</h2>

<p>CasaOS has its own auto-mounting, but it kept overriding my mount points and dumping drives into <code class="language-plaintext highlighter-rouge">/DATA/USB_Storage1</code>. I set up fstab entries by hand instead. All the drives are ext4. USB drives can end up as a different <code class="language-plaintext highlighter-rouge">/dev/sdX</code> after a reboot, so I used UUIDs.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo mkdir</span> <span class="nt">-p</span> /mnt/media/<span class="o">{</span>4tb-a,4tb-b,1tb,750g,2tb<span class="o">}</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># /etc/fstab - Media disks (ext4)
UUID=3040769c-... /mnt/media/4tb-a  ext4 defaults,noatime 0  2
UUID=e909fd59-... /mnt/media/4tb-b  ext4 defaults,noatime 0  2
UUID=ac8bdcdc-... /mnt/media/2tb    ext4 defaults,noatime 0  2
UUID=76c68f31-... /mnt/media/1tb    ext4 defaults,noatime 0  2
UUID=815f72aa-... /mnt/media/750g   ext4 defaults,noatime 0  2
</code></pre></div></div>

<p>The fiddly bit was permissions. Jellyfin runs as its own user, so it can’t access files owned by my <code class="language-plaintext highlighter-rouge">max</code> user by default. I added it to my user group and set permissions:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo chown</span> <span class="nt">-R</span> max:max /mnt/media
<span class="nb">sudo chmod</span> <span class="nt">-R</span> 775 /mnt/media
<span class="nb">sudo </span>usermod <span class="nt">-aG</span> max jellyfin
<span class="nb">sudo </span>systemctl restart jellyfin
</code></pre></div></div>

<p>For Samba, one share over <code class="language-plaintext highlighter-rouge">/mnt/media</code> covers everything. The physical layout doesn’t matter. I can browse <code class="language-plaintext highlighter-rouge">smb://mothra/Media</code> (<code class="language-plaintext highlighter-rouge">mothra</code> being the hostname, Kaiju-themed) from any machine on the network and see all the drives as subdirectories. Samba has its own password database, so I needed to add my user there too (<code class="language-plaintext highlighter-rouge">sudo smbpasswd -a max</code>).</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[Media]</span>
   <span class="py">path</span> <span class="p">=</span> <span class="s">/mnt/media</span>
   <span class="py">browseable</span> <span class="p">=</span> <span class="s">yes</span>
   <span class="err">read</span> <span class="py">only</span> <span class="p">=</span> <span class="s">no</span>
   <span class="err">guest</span> <span class="py">ok</span> <span class="p">=</span> <span class="s">no</span>
   <span class="err">valid</span> <span class="py">users</span> <span class="p">=</span> <span class="s">max</span>
   <span class="err">create</span> <span class="py">mask</span> <span class="p">=</span> <span class="s">0664</span>
   <span class="err">directory</span> <span class="py">mask</span> <span class="p">=</span> <span class="s">0775</span>
   <span class="err">force</span> <span class="py">group</span> <span class="p">=</span> <span class="s">max</span>
</code></pre></div></div>

<p>One issue that crept up after a few days was the drives disconnecting overnight due to USB power management putting the enclosure to sleep. I’d have to run <code class="language-plaintext highlighter-rouge">mount -a</code> each morning to get them back.</p>

<p>The fix is a udev rule targeting the Yottamaster’s USB controller by vendor and product ID (<code class="language-plaintext highlighter-rouge">152d:0567</code>), telling the kernel to leave power management alone:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo tee</span> /etc/udev/rules.d/99-yottamaster-usb-power.rules <span class="o">&gt;</span>/dev/null <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">EOF</span><span class="sh">'
ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="152d", ATTR{idProduct}=="0567", TEST=="power/control", ATTR{power/control}="on"
</span><span class="no">EOF

</span><span class="nb">sudo </span>udevadm control <span class="nt">--reload</span>
<span class="nb">sudo </span>udevadm trigger
</code></pre></div></div>

<p>To verify it took, check the power control state for each USB device:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for </span>dev <span class="k">in</span> /sys/bus/usb/devices/<span class="k">*</span>/<span class="p">;</span> <span class="k">do </span><span class="nv">vendor</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span> <span class="s2">"</span><span class="nv">$dev</span><span class="s2">/idVendor"</span> 2&gt;/dev/null<span class="si">)</span><span class="p">;</span> <span class="nv">product</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span> <span class="s2">"</span><span class="nv">$dev</span><span class="s2">/idProduct"</span> 2&gt;/dev/null<span class="si">)</span><span class="p">;</span> <span class="nv">power</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span> <span class="s2">"</span><span class="nv">$dev</span><span class="s2">/power/control"</span> 2&gt;/dev/null<span class="si">)</span><span class="p">;</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$vendor</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"</span><span class="nv">$vendor</span><span class="s2">:</span><span class="nv">$product</span><span class="s2"> </span><span class="nv">$power</span><span class="s2">"</span><span class="p">;</span> <span class="k">done</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">152d:0567</code> entry should show <code class="language-plaintext highlighter-rouge">on</code>. If it still shows <code class="language-plaintext highlighter-rouge">auto</code>, replug the enclosure to force the rule to fire.</p>

<p>I’m organising by media type (films, series, music) rather than worrying about which physical drive things land on. I’ve got about 20 years of media to consolidate from various machines and old drives, so it’s a dump-and-sort job.</p>

<h2 id="working-from-anywhere">Working from anywhere</h2>

<p>Back in 2022 I <a href="/post/raspberry-pi-local-dev-vscode-remote/">set up a Raspberry Pi as a local dev server</a> to keep project files in one place. It worked, but the Pi was slow, storage-limited, and only ever doing one job. The NAS replaces it, same idea but with proper storage and room for Docker containers too.</p>

<p>The NAS is the machine; everything else is just a keyboard and screen. This post was written that way. I started drafting it at my desk, moved to the sofa, carried on from my laptop. Same files, same project, no copying or syncing. I’ve got <a href="https://github.com/jesseduffield/lazygit">lazygit</a> on the server for staging and committing. VS Code’s Remote SSH forwards ports too, so Jekyll running in Docker appears as <code class="language-plaintext highlighter-rouge">localhost:4000</code> on whatever machine I’m on.</p>

<h2 id="the-first-dead-drive">The first dead drive</h2>

<p>A couple of days after getting everything set up, I heard a loud persistent beeping from the room where the NAS lives. The Yottamaster enclosure has an alarm for drive failures, and it was <em>not</em> subtle. The 750GB had gone bad.</p>

<p>I shut down the machine, powered off the enclosure, ejected the dead drive, and booted back up. Debian dropped into recovery mode because fstab still had mount points for a drive that no longer existed. Logged in as root, commented out the 750GB entry in fstab, rebooted, and everything came back up fine.</p>

<p>The 750GB was a random pull from my drawer of old drives. Turns out I bought it in 2008, so no great loss. I’d already moved everything off it onto the newer 4TB drives and formatted it fresh, so nothing was lost.</p>

<p>I’m keeping this simple on purpose. No drive pooling, no RAID, no fancy backup setup. It’s a home NAS for media and project files, not a production server. If a drive dies, I lose whatever was on it, and for 20-year-old rips of films I already own, that’s a risk I can live with.</p>

<h2 id="what-it-actually-cost">What it actually cost</h2>

<p>The Yottamaster enclosure was the only thing I bought: £179. The mini-PC was already here doing nothing, the drives pulled from old machines and previous NAS attempts. Scraped together rather than purpose-built.</p>

<p>A Synology would have meant starting fresh, £400+ for a 4-bay, then drives on top, all tied to their OS and ecosystem. Instead, I got five bays, whatever OS I want, and full control for £179.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">My phone&apos;s AI is screening your AI</title>
        <link href="https://blog.omgmog.net/post/ai-screening-ai/" rel="alternate" type="text/html" title="My phone&apos;s AI is screening your AI" />
        <link href="https://blog.omgmog.net/post/ai-screening-ai/.txt" rel="alternate" type="text/plain" title="My phone&apos;s AI is screening your AI (Plain Text)" />
        <published>2026-02-20T00:00:00+00:00</published>
        <updated>2026-02-20T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/ai-screening-ai/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="ai" />
        
        <category term="android" />
        
        <category term="google" />
        
        <category term="hardware" />
        
        <summary type="html"><![CDATA[I’ve been using Google’s Call Screening feature for a while now. I turned it on mostly to filter out calls from numbers I don’t recognise (I’ve signed up with TPS and reported numbers to the ICO, and neither has made much difference), but in practice it’s doing something I didn’t quite expect: it’s almost exclusively screening other AIs.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/ai-screening-ai/"><![CDATA[<p>I’ve been using Google’s Call Screening feature for a while now. I turned it on mostly to filter out calls from numbers I don’t recognise (I’ve signed up with <a href="https://www.tpsonline.org.uk/">TPS</a> and reported numbers to the <a href="https://ico.org.uk/for-the-public/nuisance-calls/">ICO</a>, and neither has made much difference), but in practice it’s doing something I didn’t quite expect: it’s almost exclusively screening other AIs.</p>

<!-- more -->

<p>The vast majority of calls that trigger it aren’t real people. They’re robocalls and AI diallers, automated systems ringing numbers en masse hoping someone picks up. The calls aren’t really for me. They’re scanning for any human who’ll answer, and what they find instead is Google’s screener. It intercepts them, announces itself, asks who’s calling and why. What follows is two AI systems running through a scripted exchange while I sit there watching the transcript scroll past on my screen.</p>

<p>It’s oddly compelling. The caller’s AI will introduce itself (sometimes with a name, sometimes just a company) and try to pitch. Google’s screener follows its script, <em>politely</em> firm. The caller can’t adapt. Neither can the screener, really. They talk past each other for a few exchanges until one gives up or the call gets marked as spam.</p>

<figure class="audio  center">



<audio class="figure-content" controls="" preload="auto">
<source src="/images/2026-02/robocalls/dobby_audio_1766494241893.mp3" type="audio/mpeg" />
</audio>

<div class="transcript">






<div class="transcript-turn speaker-google">
<span class="transcript-text">Hi, I'm a Google Virtual Calling Assistant recording this call for the person you're trying to reach.</span>
<span class="speaker-label">Google</span>
</div>





<div class="transcript-turn speaker-caller">
<span class="transcript-text">Hello, how are you?</span>
<span class="speaker-label">Caller</span>
</div>





<div class="transcript-turn speaker-google">
<span class="transcript-text">Can you say what you're calling about?</span>
<span class="speaker-label">Google</span>
</div>

</div>




</figure>

<figure class="audio  center">



<audio class="figure-content" controls="" preload="auto">
<source src="/images/2026-02/robocalls/dobby_audio_1770802961028.mp3" type="audio/mpeg" />
</audio>

<div class="transcript">






<div class="transcript-turn speaker-google">
<span class="transcript-text">Hi, I'm a Google Virtual Calling Assistant recording this call for the person you're trying to reach.</span>
<span class="speaker-label">Google</span>
</div>





<div class="transcript-turn speaker-caller">
<span class="transcript-text">Thanks for calling.</span>
<span class="speaker-label">Caller</span>
</div>





<div class="transcript-turn speaker-google">
<span class="transcript-text">Can you say what you're calling about?</span>
<span class="speaker-label">Google</span>
</div>





<div class="transcript-turn speaker-caller">
<span class="transcript-text">Please hold while we connect you.</span>
<span class="speaker-label">Caller</span>
</div>





<div class="transcript-turn speaker-me">
<span class="transcript-text">[block spam call]</span>
<span class="speaker-label">Me</span>
</div>





<div class="transcript-turn speaker-google">
<span class="transcript-text">Please remove this number from your mailing and contact list. Thanks, and goodbye.</span>
<span class="speaker-label">Google</span>
</div>

</div>




</figure>

<figure class="audio  center">



<audio class="figure-content" controls="" preload="auto">
<source src="/images/2026-02/robocalls/dobby_audio_1771238793459.mp3" type="audio/mpeg" />
</audio>

<div class="transcript">






<div class="transcript-turn speaker-google">
<span class="transcript-text">The person you have called is not available. Please leave a message after the tone.</span>
<span class="speaker-label">Google</span>
</div>





<div class="transcript-turn speaker-caller">
<span class="transcript-text">For promotional purposes.</span>
<span class="speaker-label">Caller</span>
</div>





<div class="transcript-turn speaker-caller">
<span class="transcript-text">If you want to opt out, press 9. Thank you.</span>
<span class="speaker-label">Caller</span>
</div>

</div>




</figure>

<figure class="audio  center">



<audio class="figure-content" controls="" preload="auto">
<source src="/images/2026-02/robocalls/dobby_audio_1771584080415.mp3" type="audio/mpeg" />
</audio>

<div class="transcript">






<div class="transcript-turn speaker-google">
<span class="transcript-text">Hi, I'm a Google Virtual Calling Assistant recording this call for the person you're trying to reach.</span>
<span class="speaker-label">Google</span>
</div>





<div class="transcript-turn speaker-caller">
<span class="transcript-text">Hi, this call is for promotional purposes.</span>
<span class="speaker-label">Caller</span>
</div>





<div class="transcript-turn speaker-google">
<span class="transcript-text">Can you say what you're calling about?</span>
<span class="speaker-label">Google</span>
</div>





<div class="transcript-turn speaker-caller">
<span class="transcript-text">If you want to opt out, press 9.</span>
<span class="speaker-label">Caller</span>
</div>

</div>




</figure>

<p>The “press 9 to opt out” in the last two is good. The opt-out assumes a human who can comply. That branch <em>never</em> executes. The robocall was written for a world where a human picks up. No human did. The “Hello, how are you?” in the first clip is just the robocall trying to sound like a real person. Google’s screener doesn’t take the bait.</p>

<p>There’s something absurd about the whole thing. The call was never meant for a human to answer. My phone was never going to hand it off to me. The entire exchange plays out in a layer I never touch, two scripts running against each other, and I’m just an observer watching the transcript. Occasionally I’ll tap “block spam call”, which triggers Dobby to add a polite “please remove this number from your mailing and contact list” before hanging up. That’s the full extent of my involvement: a single button press.</p>

<h2 id="how-i-transcribed-these">How I transcribed these</h2>

<p>The recordings are stereo: Google’s screener on the right channel, the caller on the left. The files are named <code class="language-plaintext highlighter-rouge">dobby_audio_*</code>, which is apparently Google’s internal codename for the Call Screen feature. <a href="https://github.com/openai/whisper">Whisper</a> handles the transcription. So: an AI transcribing two AIs talking to each other. It’s AIs all the way down. Every system in that chain is optimising an objective. None of them experience the exchange as anything. I’m the only one present for the joke.</p>

<p>A short Python script splits the channels, runs each through the model, then merges the segments back in timestamp order:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">wave</span><span class="p">,</span> <span class="n">struct</span><span class="p">,</span> <span class="n">whisper</span><span class="p">,</span> <span class="n">tempfile</span><span class="p">,</span> <span class="n">os</span>

<span class="n">model</span> <span class="o">=</span> <span class="n">whisper</span><span class="p">.</span><span class="n">load_model</span><span class="p">(</span><span class="s">"base"</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">extract_channel</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">channel</span><span class="p">):</span>
    <span class="k">with</span> <span class="n">wave</span><span class="p">.</span><span class="nb">open</span><span class="p">(</span><span class="n">path</span><span class="p">)</span> <span class="k">as</span> <span class="n">w</span><span class="p">:</span>
        <span class="n">params</span> <span class="o">=</span> <span class="n">w</span><span class="p">.</span><span class="n">getparams</span><span class="p">()</span>
        <span class="n">frames</span> <span class="o">=</span> <span class="n">w</span><span class="p">.</span><span class="n">readframes</span><span class="p">(</span><span class="n">w</span><span class="p">.</span><span class="n">getnframes</span><span class="p">())</span>
    <span class="n">samples</span> <span class="o">=</span> <span class="n">struct</span><span class="p">.</span><span class="n">unpack</span><span class="p">(</span><span class="sa">f</span><span class="s">"&lt;</span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">frames</span><span class="p">)</span><span class="o">//</span><span class="mi">2</span><span class="si">}</span><span class="s">h"</span><span class="p">,</span> <span class="n">frames</span><span class="p">)</span>
    <span class="n">mono</span> <span class="o">=</span> <span class="n">samples</span><span class="p">[</span><span class="n">channel</span><span class="p">::</span><span class="mi">2</span><span class="p">]</span>
    <span class="n">tmp</span> <span class="o">=</span> <span class="n">tempfile</span><span class="p">.</span><span class="n">NamedTemporaryFile</span><span class="p">(</span><span class="n">suffix</span><span class="o">=</span><span class="s">".mp3"</span><span class="p">,</span> <span class="n">delete</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="k">with</span> <span class="n">wave</span><span class="p">.</span><span class="nb">open</span><span class="p">(</span><span class="n">tmp</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="s">"w"</span><span class="p">)</span> <span class="k">as</span> <span class="n">out</span><span class="p">:</span>
        <span class="n">out</span><span class="p">.</span><span class="n">setnchannels</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
        <span class="n">out</span><span class="p">.</span><span class="n">setsampwidth</span><span class="p">(</span><span class="n">params</span><span class="p">.</span><span class="n">sampwidth</span><span class="p">)</span>
        <span class="n">out</span><span class="p">.</span><span class="n">setframerate</span><span class="p">(</span><span class="n">params</span><span class="p">.</span><span class="n">framerate</span><span class="p">)</span>
        <span class="n">out</span><span class="p">.</span><span class="n">writeframes</span><span class="p">(</span><span class="n">struct</span><span class="p">.</span><span class="n">pack</span><span class="p">(</span><span class="sa">f</span><span class="s">"&lt;</span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">mono</span><span class="p">)</span><span class="si">}</span><span class="s">h"</span><span class="p">,</span> <span class="o">*</span><span class="n">mono</span><span class="p">))</span>
    <span class="k">return</span> <span class="n">tmp</span><span class="p">.</span><span class="n">name</span>

<span class="k">for</span> <span class="n">path</span> <span class="ow">in</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">os</span><span class="p">.</span><span class="n">listdir</span><span class="p">(</span><span class="s">"recordings/"</span><span class="p">)):</span>
    <span class="n">left</span> <span class="o">=</span> <span class="n">extract_channel</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>   <span class="c1"># caller
</span>    <span class="n">right</span> <span class="o">=</span> <span class="n">extract_channel</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>  <span class="c1"># Google
</span>
    <span class="n">segs</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">seg</span> <span class="ow">in</span> <span class="n">whisper</span><span class="p">.</span><span class="n">transcribe</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">left</span><span class="p">)[</span><span class="s">"segments"</span><span class="p">]:</span>
        <span class="n">segs</span><span class="p">.</span><span class="n">append</span><span class="p">((</span><span class="s">"Caller"</span><span class="p">,</span> <span class="n">seg</span><span class="p">[</span><span class="s">"start"</span><span class="p">],</span> <span class="n">seg</span><span class="p">[</span><span class="s">"text"</span><span class="p">].</span><span class="n">strip</span><span class="p">()))</span>
    <span class="k">for</span> <span class="n">seg</span> <span class="ow">in</span> <span class="n">whisper</span><span class="p">.</span><span class="n">transcribe</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">right</span><span class="p">)[</span><span class="s">"segments"</span><span class="p">]:</span>
        <span class="n">segs</span><span class="p">.</span><span class="n">append</span><span class="p">((</span><span class="s">"Google"</span><span class="p">,</span> <span class="n">seg</span><span class="p">[</span><span class="s">"start"</span><span class="p">],</span> <span class="n">seg</span><span class="p">[</span><span class="s">"text"</span><span class="p">].</span><span class="n">strip</span><span class="p">()))</span>

    <span class="k">for</span> <span class="n">speaker</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">text</span> <span class="ow">in</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">segs</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">x</span><span class="p">:</span> <span class="n">x</span><span class="p">[</span><span class="mi">1</span><span class="p">]):</span>
        <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"[</span><span class="si">{</span><span class="n">speaker</span><span class="si">}</span><span class="s">] </span><span class="si">{</span><span class="n">text</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<p>Call Screen <em>works</em>, but it’s quite locked down. The messaging the screener uses is fixed. There’s no way to adjust what it asks or how it phrases things. The voice is whatever Google decided on. It’s acting on my behalf, answering in my place, but the representation is <em>entirely</em> theirs to design. It’s a centralised proxy, built for a generalised user, standing in for me. I’d like to tweak it a bit (maybe make it sound less corporate, or change what it actually says to callers). It feels like it should be configurable, but it isn’t.</p>

<p>It’s a small gripe, and it still catches the AIs. I’m just entertained watching them at it. Dobby, quietly handling the world’s nonsense so I don’t have to.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">Archive Archaeology</title>
        <link href="https://blog.omgmog.net/post/archive-archaeology/" rel="alternate" type="text/html" title="Archive Archaeology" />
        <link href="https://blog.omgmog.net/post/archive-archaeology/.txt" rel="alternate" type="text/plain" title="Archive Archaeology (Plain Text)" />
        <published>2026-02-13T00:00:00+00:00</published>
        <updated>2026-02-13T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/archive-archaeology/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="life" />
        
        <category term="site-related" />
        
        <summary type="html"><![CDATA[I was talking to someone about foobar2000 and how prolific I’d been in the customisation scene back in the day. I knew I’d written up a detailed config somewhere, with screenshots and everything, but the domain was long gone and I hadn’t thought about it in years. On a whim I punched moglenstar.net into the Wayback Machine and there it was, cached HTML from a site I’d let lapse almost two decades ago. That led to more searching.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/archive-archaeology/"><![CDATA[<p>I was talking to someone about <a href="https://www.foobar2000.org/">foobar2000</a> and how prolific I’d been in the customisation scene back in the day. I knew I’d written up a detailed config somewhere, with screenshots and everything, but the domain was long gone and I hadn’t thought about it in years. On a whim I punched moglenstar.net into the <a href="https://web.archive.org/">Wayback Machine</a> and there it was, cached HTML from a site I’d let lapse almost two decades ago. That led to more searching.</p>

<!-- more -->

<p>The earliest post on this blog used to be from October 2010. I knew there was older stuff out there, on domains I’d moved away from, but I’d assumed it was just <em>gone</em>. It wasn’t. I ended up recovering 117 posts stretching back to January 2004, along with 348 archived comments, 81 images, and enough CSS nostalgia to make me wince.</p>

<h2 id="the-timeline">The timeline</h2>

<p>I’m approaching 40 this year and I’ve had a blog for over half my life. In that time, the platforms and domains changed constantly. Prior to 2004 I’d used countless free hosting platforms (Geocities, cjb.net, ISP-provided web space), built with FrontPage and Notepad. I’m glad I can’t find any of those.</p>

<ul>
  <li><strong>moglenstar.net</strong> (2004-2007) - my first proper domain. A custom PHP blog I’d built called CMOGS, then <a href="https://textpattern.com/">Textpattern</a>, then <a href="https://www.movabletype.org/">Movable Type</a>, then <a href="https://wordpress.org/">WordPress</a>, then <a href="https://www.blogger.com/">Blogger</a>, then WordPress again. Five platform changes in three years.</li>
  <li><strong>omgmog.net</strong> (2007-2009) - WordPress 2.2, mostly R4DS flash cartridge themes and a CSS tutorial</li>
  <li><strong>blog.omgmog.net</strong> (2010-2011) - <a href="https://www.tumblr.com/">Tumblr</a>, then <a href="http://octopress.org/">Octopress</a>. Userscripts, book reviews, Minecraft. This eventually became the current <a href="https://jekyllrb.com/">Jekyll</a> blog.</li>
  <li><strong>3dp.omgmog.net / status.omgmog.net</strong> (2017-2018) - small Jekyll-powered blogs on subdomains. 3D printing, VR, and other hobby stuff. status.omgmog.net was eventually superseded by <a href="https://social.omgmog.net">social.omgmog.net</a>.</li>
</ul>

<p>The moglenstar.net domain lapsed around 2007 and ended up with a domain broker. The omgmog.net blog content got lost somewhere between Tumblr and Octopress migrations. Most of the early blog.omgmog.net posts didn’t survive the move to Jekyll either. The subdomain blogs went offline when I stopped maintaining them.</p>

<h2 id="the-rabbit-hole">The rabbit hole</h2>

<p>What started as a whim became a two-week obsession. I’d find a post, then notice a sidebar link to another post I hadn’t seen, then search for that one, then find a reference to an image that might still exist somewhere. Each recovery led to three more searches.</p>

<figure class="image  center">

<picture>

<img src="/images/2026-02/archive/inception-deeper-ezgif.com-optimize.gif" loading="lazy" alt="" />
</picture>






</figure>

<p>I have a box of old hard drives. I went through them all, one by one with a USB enclosure, scouring folders and moving anything useful to my NAS. Found a 2011 Octopress backup I didn’t remember making. Searched my Dropbox archive for screenshots I’d uploaded to cl.ly a decade ago. The compulsion wasn’t just to recover the content, it was to get it <em>right</em>: the correct dates, the original formatting, the images that belonged with each post.</p>

<h2 id="moglenstarnet-60-posts">moglenstar.net (60 posts)</h2>

<p>The Wayback Machine had about 5,000 captures of moglenstar.net between 2004 and 2010. Most of the later ones are the domain broker’s parking page, but filtering those out I found 60 blog posts across five different eras.</p>

<p><strong>Early 2004</strong> was a custom PHP blog on the homepage, then a Textpattern install at <code class="language-plaintext highlighter-rouge">/site/</code>, then briefly Movable Type at <code class="language-plaintext highlighter-rouge">/max/</code>. Diary-style entries about building a PC, being bored at college, and promising to finish the site design. Over the course of a week in January I ordered parts, built the thing, got a BIOS error, diagnosed a RAM compatibility issue, then hit a graphics card incompatibility. Five posts about it, all ending with some variation of “hopefully it’ll work tomorrow.” The Textpattern posts from March and April are mostly procrastination and coursework avoidance. The <code class="language-plaintext highlighter-rouge">/max/</code> posts from May and June are even more sparse, one is literally just <code class="language-plaintext highlighter-rouge">...</code></p>

<p><strong>Late 2004</strong> was WordPress 1.0.1 at <code class="language-plaintext highlighter-rouge">/journal/</code>. Posts about web design, CSS, and being annoyed about college assignments. “yes, i’ve installed wordpress, and i’m using a template.. so shoot me.”</p>

<p><strong>2005</strong> was Blogger-powered. Just two posts, both about redesigning the site again.</p>

<p><strong>Early 2006</strong> started with WordPress on the <a href="https://web.archive.org/web/20060615032031/http://getk2.com/">K2 theme</a>, one of the popular “Web 2.0” WordPress themes with rounded corners and Ajax comments. Site redesigns, <a href="/post/pixel-level-detail/">pixel art</a>, <a href="/post/css-tabs-ala-mog/">CSS tutorials</a>. This is where I started sharing foobar2000 configs on the Neowin and Hydrogenaudio forums and getting actual traffic.</p>

<p><strong>Mid-to-late 2006</strong> was a custom WordPress theme with a green header. The most interesting stuff here: a detailed <a href="/post/my-foobar-setup/">foobar2000 tutorial</a> with 75 comments, four pixel art icon sets, Windows Media Player 11-style playback buttons, and a <a href="/post/new-columns-ui-config/">Columns UI config</a> with colour schemes.</p>

<p>I recovered four icon sets I’d designed (grey media icons, icecons, <a href="/post/mog-blue-icons/">mog blue</a>, <a href="/post/no-hue-icons/">no-hue</a>). They’re tiny, 16x16 or 20x20 pixels, hand-pixelled. Very much of the era.</p>

<figure class="image  center">

<picture>

<source srcset="/images/moglenstar/icons/greyicons/preview.png.webp" type="image/webp" />

<img src="/images/moglenstar/icons/greyicons/preview.png" loading="lazy" alt="" />
</picture>






</figure>

<p>The posts are short. Really short. “<a href="/post/college/">so all of a sudden everything costs money, eh?</a>” is the entire text of one of them. Reading them back is <em>painful</em>. Not because they’re badly written (they are), but because they’re so earnest. Pure LiveJournal energy. I’m writing about my maths exam, my nephew’s birthday, buying jeans, fixing a family friend’s PC. One post is just me <a href="/post/pc-stuff-arrived/">being excited about a heatsink arriving</a>.</p>

<p>I’d completely forgotten about the PC build saga. Five posts in January 2004, each one ending with “hopefully it’ll work tomorrow” as I diagnosed one hardware incompatibility after another. I remembered building a PC that year. I didn’t remember the RAM compatibility issues, the graphics card that wouldn’t work with the motherboard, the week of troubleshooting. Turns out it was the internal floppy drive shorting to ground the whole time. Reading it back felt like finding someone else’s diary.</p>

<figure class="image  center">

<picture>

<source srcset="/images/moglenstar/mooglebar11promo.png.webp" type="image/webp" />

<img src="/images/moglenstar/mooglebar11promo.png" loading="lazy" alt="" />
</picture>






</figure>

<p>Where the Wayback Machine had captured comments, I pulled those in too. 99 comments across eight posts, now displayed alongside the archived content. Most are foobar2000 questions (“how do you change the color schemes?”) but some are from people I’d forgotten existed. One comment on a <a href="/post/new-columns-ui-config/">Columns UI config post</a> is from someone called Dawn, apologising for not saying more because she was “still wearing off some Grey Goose and Tequila” after a pool party. I have no memory of who Dawn was.</p>

<h2 id="omgmognet-8-posts">omgmog.net (8 posts)</h2>

<p>I moved to omgmog.net in 2007 and initially set it up WordPress 2.2. The Wayback Machine had less coverage here, but I found eight posts.</p>

<p>Four of them were R4DS flash cartridge themes (<a href="/post/r4ds-tango-theme/">Tango</a>, <a href="/post/r4ds-bone-theme/">Bone</a>, <a href="/post/r4ds-vista-theme/">Vista</a>, <a href="/post/r4ds-panda-theme/">Panda</a>), pixel art skins for the Nintendo DS flash cartridge menu. I was active on the GBATEmp forums at the time, sharing themes and configs. The preview images were hosted on omgmog.net/things/ which is gone now. The theme files themselves are gone. All that survives is the post text and the tag <code class="language-plaintext highlighter-rouge">pixel-art</code>.</p>

<p>The <a href="/post/creating-the-moanlog-search-form-with-css/">moanlog CSS tutorial</a> from 2009 is the most complete post from this era. Full code blocks, HTML, CSS, and some JavaScript to provide input placeholder functionality. The <code class="language-plaintext highlighter-rouge">placeholder</code> attribute handles this now, but this was before HTML5 was widely supported.</p>

<p>A <a href="/post/installing-os-x-snow-leopard-on-the-dell-inspiron-1525/">Snow Leopard hackintosh guide</a> turned up when I realised my <a href="/post/installing-mac-os-x-snow-leopard-on-the-dell-inspiron/">existing 2010 post</a> referenced an earlier version I’d written on omgmog.net. The Wayback Machine had the full page including 11 comments from people trying to follow the instructions. There was also a jQuery tutorial, and a post about <a href="/post/ive-been-busy/">launching Dingoonity</a>.</p>

<h2 id="blogomgmognet-12-posts">blog.omgmog.net (12 posts)</h2>

<p>I had a local backup of the blog from around 2010-2011 sitting on an old external drive. It was an Octopress install with 28 posts. The blog had started on Tumblr in 2010, moved to Octopress later that year, then switched to <a href="https://github.com/plusjade/jekyll-bootstrap">Jekyll Bootstrap</a> in 2012 before settling on plain Jekyll. Cross-referencing against what already existed on the current blog, I found 17 posts that hadn’t been migrated. Five of those were Tumblr reblogs or single-sentence posts, so I skipped them and brought over the remaining 12.</p>

<p>Some of these had images hosted on cl.ly (CloudApp) or Dropbox public folders. cl.ly is gone, but I found a backup of my Dropbox Public folder on the same drive. That saved the images for four posts: a <a href="/post/grooveshark-view-modes-userscript/">Grooveshark userscript</a> with six screenshots, a <a href="/post/review-the-smashing-book-2/">Smashing Book 2 review</a> with four photos, and a Google+ userstyle for Reddit with two screenshots.</p>

<p>The rest of the cl.ly-hosted images are gone. The Kindle 3 review, Minecraft posts, and Pac-Man Moleskine post all have dead image links now.</p>

<p>The recovered posts are a mix of userscripts (Grooveshark view modes, Reddit tools, Notched for Minecraft forums), book reviews, jQuery debugging, and a <a href="/post/trying-coffeescript/">CoffeeScript comparison</a>. Peak 2011 web developer stuff. The last one chronologically is from April 2012, about <a href="/post/moving-away-from-twitter-bootstrap/">ditching Twitter Bootstrap</a> and building a custom theme for what was by then a Jekyll blog.</p>

<p>The blog.omgmog.net era was also when I used <a href="https://disqus.com/">Disqus</a> for comments. I’d forgotten about that until I went looking for an old account and found I could still export everything. The XML dump contained 249 non-spam comments spanning 2010 to 2016. Most of the URLs in the export matched posts that still existed on the current blog, so parsing the XML and injecting comments into the right posts was straightforward. A handful used Tumblr’s numeric post IDs (<code class="language-plaintext highlighter-rouge">/post/1265485126</code>) instead of slugs, which took some manual mapping to resolve.</p>

<p>The Disqus comments are a different flavour from the moglenstar.net ones. Less “how do I change the colour scheme?” and more “great post!” or technical follow-ups. A surprising number are from bots or now-deleted accounts. But there are real conversations too: someone asking about <a href="/post/making-postgres-work-nicely-on-osx-mountain-lion/">Postgres on Mountain Lion</a>, readers sharing their own <a href="/post/gamification-of-life-with-the-fitbit-flex/">Fitbit experiences</a>, a back-and-forth about <a href="/post/why-your-reasons-for-no-longer-using-a-css-pre-processor-are-wrong/">CSS pre-processors</a> that aged poorly. 249 comments across 50 posts, now displayed alongside the content they were responding to.</p>

<h2 id="3dpomgmognet--statusomgmognet-37-posts">3dp.omgmog.net / status.omgmog.net (37 posts)</h2>

<p>3dp.omgmog.net was a Jekyll blog for 3D printing. I bought an Anycubic Kossel delta printer in October 2017 and immediately started documenting everything. Calibration problems, first prints, filament tests, printer mods, things I’d designed and printed. 25 posts total, from <a href="/post/it-begins/">unboxing</a> through to <a href="/post/extra-keys-v2/">custom macro keys</a> for my keyboard.</p>

<p>Then I just… stopped posting about it. I kept printing, kept designing things, but the novelty of documenting every print wore off. Classic new hobby arc. Twelve posts from status.omgmog.net survived: bash aliases, a <a href="/post/children-of-time-book-review/">book review</a>, a <a href="/post/enabling-google-daydream-on-oneplus-3t/">Daydream VR guide</a>, Windows VPN setup, Android emulator notes, <a href="/post/installing-ubuntu-on-macbookpro-8-2/">Ubuntu on a MacBook</a>, an <a href="/post/peanut-arduboy-kit/">Arduboy kit build</a>, and some game development notes.</p>

<h2 id="the-process">The process</h2>

<p>The Wayback Machine’s CDX API (<code class="language-plaintext highlighter-rouge">web.archive.org/cdx/search/cdx</code>) gives back a list of every captured URL for a domain. I fed moglenstar.net and omgmog.net through it, filtered out the duplicates and parking pages, and ended up with a list of unique post URLs to fetch. I’d built a scraping tool for a previous Wayback Machine project, and the fetching approach worked the same way here.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl "web.archive.org/cdx/search/cdx?url=moglenstar.net/*&amp;output=json&amp;fl=timestamp,original"
</code></pre></div></div>

<p>Each era used different markup, so extracting the post content was a lot of BeautifulSoup and regular expressions. Title from one selector, date from another, body from a third. Six different site layouts across the moglenstar.net captures meant six different sets of extraction rules. Nothing clever, just a lot of <code class="language-plaintext highlighter-rouge">soup.find()</code> calls and some sed for cleanup.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># the 2006 wordpress era
</span><span class="n">title</span> <span class="o">=</span> <span class="n">soup</span><span class="p">.</span><span class="n">find</span><span class="p">(</span><span class="s">"h2"</span><span class="p">,</span> <span class="n">class_</span><span class="o">=</span><span class="s">"storytitle"</span><span class="p">).</span><span class="n">text</span>
<span class="n">date</span> <span class="o">=</span> <span class="n">soup</span><span class="p">.</span><span class="n">find</span><span class="p">(</span><span class="s">"h3"</span><span class="p">,</span> <span class="n">class_</span><span class="o">=</span><span class="s">"date"</span><span class="p">).</span><span class="n">text</span>
<span class="n">body</span> <span class="o">=</span> <span class="n">soup</span><span class="p">.</span><span class="n">find</span><span class="p">(</span><span class="s">"div"</span><span class="p">,</span> <span class="n">class_</span><span class="o">=</span><span class="s">"storycontent"</span><span class="p">)</span>
<span class="n">comments</span> <span class="o">=</span> <span class="n">soup</span><span class="p">.</span><span class="n">find_all</span><span class="p">(</span><span class="s">"li"</span><span class="p">,</span> <span class="n">class_</span><span class="o">=</span><span class="s">"comment"</span><span class="p">)</span>
</code></pre></div></div>

<p>The Octopress posts were simpler. I had the files locally, already well-structured HTML. Converting to Jekyll markdown was mostly stripping tags and reformatting code blocks with a shell script.</p>

<h2 id="the-presentation">The presentation</h2>

<p>I didn’t want the old posts to just appear as regular blog entries. They’re from a different time and a different site, and that should be obvious.</p>

<p>Each archived post gets wrapped in an “era fragment” that echoes the look of the original site. The moglenstar.net posts from 2004-early 2006 get a dark theme with yellow accents. The mid-2006 posts get the K2 WordPress theme look, slate blue-grey with orange links. The late-2006 posts get the custom “mogdotnet” theme, charcoal header with green accents. The omgmog.net and blog.omgmog.net posts have their own styles too.</p>

<figure class="image  center massive">

<picture>

<source srcset="/images/2026-02/archive/eras.png.webp" type="image/webp" />

<img src="/images/2026-02/archive/eras.png" loading="lazy" alt="" />
</picture>






</figure>

<p>The styling is approximate (the Wayback Machine didn’t preserve CSS consistently), but it’s enough to trigger the right kind of nostalgia. Seeing a post wrapped in that dark 2004 theme immediately puts me back in the mindset of a teenager customising every pixel of everything. The rounded corners and drop shadows of the K2 era are pure Web 2.0. The lime green of the 2010 Octopress blog feels like yesterday by comparison.</p>

<p>There’s also a notice at the top of each post explaining where it came from and when, so nobody mistakes my 2004 opinions for my current ones.</p>

<p>External links in archived posts get automatically rewritten to point through the Wayback Machine. Most of these sites don’t exist any more, and the ones that do (Neowin, Hydrogenaudio) have changed enough that the archived versions are more contextually relevant anyway.</p>

<h2 id="the-losses">The losses</h2>

<p>Not everything made it. The Wayback Machine captured snapshots, not continuous archives. Some posts exist only as titles in a sidebar, the full content never crawled. The 2005 Blogger-powered era has just two posts, but I remember writing more. The Tumblr period probably had a dozen posts I never migrated and can’t find now.</p>

<p>The R4DS themes are the biggest loss. I designed four complete theme packs with custom icons and layouts. The preview images are gone, the download links are dead, and the themes themselves probably only exist on the SD cards of a few people who downloaded them in 2007. Somewhere out there, a Nintendo DS is booting up with my pixel art on it.</p>

<h2 id="the-remnants">The remnants</h2>

<p>Everything recovered has been added to this blog under its original date. The moglenstar.net posts are tagged with <code class="language-plaintext highlighter-rouge">moglenstar</code>, the omgmog.net posts have <code class="language-plaintext highlighter-rouge">archive: omgmog.net</code> in their front matter. I haven’t cleaned them up or rewritten them. Lowercase “i”, double dots instead of ellipses, missing apostrophes, emoticon text faces, “smile” where a forum smiley used to be. They’re exactly as they were.</p>

<p>The earliest post on this blog is now from January 2004. That’s 22 years of posting, with some gaps.</p>

<p><strong>117</strong> posts recovered. <strong>348</strong> comments preserved. <strong>81</strong> images found. Most of it terrible. All of it mine.</p>

<blockquote>
  <p>None of this would exist without the <a href="https://archive.org/">Internet Archive</a>. They cached thousands of pages from my defunct domains, going back over two decades. A nonprofit storing 99+ petabytes of data, including 625 billion web pages, with no ads or paywalls. If any of this resonated, <a href="https://archive.org/donate/">consider donating</a>.</p>
</blockquote>]]></content>
    </entry>
    
    <entry>
        <title type="html">Using emoji to bypass FRP on a decade-old tablet</title>
        <link href="https://blog.omgmog.net/post/using-emoji-to-bypass-frp-on-a-decade-old-tablet/" rel="alternate" type="text/html" title="Using emoji to bypass FRP on a decade-old tablet" />
        <link href="https://blog.omgmog.net/post/using-emoji-to-bypass-frp-on-a-decade-old-tablet/.txt" rel="alternate" type="text/plain" title="Using emoji to bypass FRP on a decade-old tablet (Plain Text)" />
        <published>2026-02-12T00:00:00+00:00</published>
        <updated>2026-02-12T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/using-emoji-to-bypass-frp-on-a-decade-old-tablet/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="android" />
        
        <category term="hardware" />
        
        <category term="emoji" />
        
        <summary type="html"><![CDATA[I recently picked up two Pixel C tablets from a seller on eBay, along with the magnetic keyboard. One tablet had a cracked screen but worked fine and wasn’t locked. The other was cosmetically perfect but the display was completely black. I figured between the two I could end up with at least one working tablet.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/using-emoji-to-bypass-frp-on-a-decade-old-tablet/"><![CDATA[<p>I recently picked up two Pixel C tablets from a seller on eBay, along with the magnetic keyboard. One tablet had a cracked screen but worked fine and wasn’t locked. The other was cosmetically perfect but the display was completely black. I figured between the two I could end up with at least one working tablet.</p>

<!-- more -->

<figure class="image  center massive">

<picture>

<source srcset="/images/2026-02/pixel-c-angles.png.webp" type="image/webp" />

<img src="/images/2026-02/pixel-c-angles.png" loading="lazy" alt="" />
</picture>






</figure>

<h2 id="the-pixel-c">The Pixel C</h2>

<p>I’ve been through a few Android and ChromeOS tablets over the years, from <a href="/post/finally-a-post-about-the-nexus-7/">the Nexus 7</a> to <a href="/post/using-chromebook-lenovo-ideapad-duet-in-2020/">the Lenovo Duet</a>. The <a href="https://blog.google/products-and-platforms/platforms/android/meet-pixel-c-our-take-on-tablet/">Pixel C</a> was Google’s first tablet designed entirely in-house, released in December 2015. It was <a href="https://9to5google.com/2015/12/10/pixel-c-chrome-os-device/">originally intended to run Chrome OS</a> before being pivoted to Android mid-development.</p>

<table class="massive">
  <thead>
    <tr>
      <th>Feature</th>
      <th>Details</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Display</strong></td>
      <td>10.2-inch IPS LCD, 2560x1800 (308 ppi)</td>
    </tr>
    <tr>
      <td><strong>Processor</strong></td>
      <td>Nvidia Tegra X1, quad-core</td>
    </tr>
    <tr>
      <td><strong>RAM</strong></td>
      <td>3 GB</td>
    </tr>
    <tr>
      <td><strong>Storage</strong></td>
      <td>32 GB or 64 GB</td>
    </tr>
    <tr>
      <td><strong>OS</strong></td>
      <td>Android 6.0.1 at launch, final update Android 8.1</td>
    </tr>
  </tbody>
</table>

<p>The keyboard attaches magnetically and charges inductively from the tablet itself. Quite slick for its time. Google stopped selling the Pixel C in 2017 and ended software support in June 2019, so the two I’m working with are running the final build they’ll <em>ever</em> get.</p>

<h2 id="the-black-screen-problem">The black screen problem</h2>

<p>The cracked screen unit set up without any trouble. The black screen unit was more interesting. It seemed to turn on fine (I could hear notification and lock sounds) but no picture. I hooked it up to my computer and tried poking at it with <code class="language-plaintext highlighter-rouge">adb</code>, <code class="language-plaintext highlighter-rouge">fastboot</code>, and <code class="language-plaintext highlighter-rouge">mtp</code>. I set up a quick loop to monitor what state it was in. It polls <code class="language-plaintext highlighter-rouge">fastboot</code>, <code class="language-plaintext highlighter-rouge">adb</code>, and the USB product ID every second, printing a timestamped line and beeping whenever the state changes:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">serial</span><span class="o">=</span><span class="s2">"5A220..."</span>
<span class="nv">prev</span><span class="o">=</span><span class="s2">""</span>
<span class="k">while </span><span class="nb">true</span><span class="p">;</span> <span class="k">do
  </span><span class="nv">fb</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>fastboot devices 2&gt;/dev/null | <span class="nb">awk</span> <span class="s1">'/fastboot$/ {print $1; exit}'</span><span class="si">)</span><span class="s2">"</span>
  <span class="nv">ad</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>adb devices 2&gt;/dev/null | <span class="nb">awk</span> <span class="s1">'NR&gt;1 &amp;&amp; NF {print $1":"$2; exit}'</span><span class="si">)</span><span class="s2">"</span>
  <span class="nv">pid</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>system_profiler SPUSBDataType | <span class="nb">awk</span> <span class="nt">-v</span> <span class="nv">s</span><span class="o">=</span><span class="s2">"</span><span class="nv">$serial</span><span class="s2">"</span> <span class="s1">'
    /Product ID:/ {pid=$3}
    $0 ~ "Serial Number: " s {print pid; exit}
  '</span><span class="si">)</span><span class="s2">"</span>

  <span class="nv">cur</span><span class="o">=</span><span class="s2">"pid=</span><span class="k">${</span><span class="nv">pid</span><span class="k">:-</span><span class="nv">na</span><span class="k">}</span><span class="s2"> fastboot=</span><span class="k">${</span><span class="nv">fb</span><span class="k">:-</span><span class="nv">none</span><span class="k">}</span><span class="s2"> adb=</span><span class="k">${</span><span class="nv">ad</span><span class="k">:-</span><span class="nv">none</span><span class="k">}</span><span class="s2">"</span>
  <span class="k">if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$cur</span><span class="s2">"</span> <span class="o">!=</span> <span class="s2">"</span><span class="nv">$prev</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">printf</span> <span class="s2">"%s  %s</span><span class="se">\n</span><span class="s2">"</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">date</span> <span class="s1">'+%H:%M:%S'</span><span class="si">)</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$cur</span><span class="s2">"</span>
    osascript <span class="nt">-e</span> <span class="s1">'beep 1'</span>
    <span class="nv">prev</span><span class="o">=</span><span class="s2">"</span><span class="nv">$cur</span><span class="s2">"</span>
  <span class="k">fi
  </span><span class="nb">sleep </span>1
<span class="k">done</span>
</code></pre></div></div>

<p>Output looked something like this as the device cycled through states:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>14:32:01  pid=na fastboot=none adb=none
14:32:15  pid=0x5201 fastboot=5A220... adb=none
14:32:22  pid=0x5202 fastboot=none adb=none
</code></pre></div></div>

<p>It would boot to fastboot no problem, but the bootloader was locked and only read-only commands would work:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fastboot getvar all
(bootloader) product: dragon
(bootloader) secure: yes
(bootloader) unlocked: no
(bootloader) partition-size:chromeos: 0xe8f800000
...
</code></pre></div></div>

<p>That <code class="language-plaintext highlighter-rouge">chromeos</code> partition is a nice reminder of the Pixel C’s origins. Any write attempts just bounced:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fastboot flash recovery recovery.img
Sending 'recovery' (13200 KB)   OKAY [  0.354s]
Writing 'recovery'     FAILED (remote: 'unsupported command')
</code></pre></div></div>

<p>Despite having <code class="language-plaintext highlighter-rouge">system-a</code> and <code class="language-plaintext highlighter-rouge">system-b</code> partitions, slot switching wasn’t an option either:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fastboot --set-active=a
fastboot: error: Device does not support slots
</code></pre></div></div>

<p>Eventually I got the display to show a picture by applying pressure to the rear left half of the case. Turns out the internal display connector on these Pixel C units is a known weak point. The device now had a working screen, so I booted it up hoping for a simple wipe and sign-in.</p>

<p>Enter Factory Reset Protection (FRP). The previous owner hadn’t unlinked their account, and no amount of fastboot flashing or sideloading would get it out of that state. I was effectively locked out of my <em>own</em> hardware. In FRP mode, the setup process is completely locked down: no notifications, no settings, no home button, no app switcher, no browser, no global search. Just a single-track wizard that demands the previous owner’s Google account before it’ll let you do anything else.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-02/frp-message.png.webp" type="image/webp" />

<img src="/images/2026-02/frp-message.png" loading="lazy" alt="" />
</picture>






</figure>

<p>Most of the standard “pivots” to get into the settings menu have been patched out on this build. I tried the usual talkback tricks, but they were all dead ends.</p>

<p>My next instinct was to try a buffer overflow. I remembered <a href="https://nvd.nist.gov/vuln/detail/CVE-2015-3860">CVE-2015-3860</a>, a vulnerability in Android 5.x that crashed the lockscreen by flooding the password field with a massive string. Same principle, different target. I connected to my Wi-Fi, went back to the “Add network” screen, and started flooding the SSID field with text.</p>

<p>I used 💥 emoji copied from the on-screen picker, then connected the physical keyboard and hammered Ctrl+V until the field was overflowing with thousands of characters.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-02/emoji-breakout.jpg.webp" type="image/webp" />

<img src="/images/2026-02/emoji-breakout.jpg" loading="lazy" alt="" />
</picture>






</figure>

<p>It worked. The settings app eventually gave up and crashed. When the “Settings has stopped” popup appeared, I tapped <strong>Send feedback</strong>, which is a classic escape hatch from the sandboxed setup screen.</p>

<p>This opens a feedback form with links to terms, privacy policy, and so on. Tapping one of those links opens a restricted webview, but I didn’t have full access yet.</p>

<h2 id="dead-ends">Dead ends</h2>

<p>I set up a self-hosted page with <code class="language-plaintext highlighter-rouge">intent://</code> URLs as anchor links, hoping I could navigate to one like:</p>

<p><code class="language-plaintext highlighter-rouge">intent://com.android.settings/#Intent;scheme=android-app;end</code></p>

<p>The restricted webviews just ignored them or looped me back to the start of the setup. Pretty frustrating.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-02/intent-error.png.webp" type="image/webp" />

<img src="/images/2026-02/intent-error.png" loading="lazy" alt="" />
</picture>






</figure>

<p>I spent a few hours trying to find my own exploit: setting up a <code class="language-plaintext highlighter-rouge">dnsmasq</code> server to MITM the DNS requests, hoping I could redirect the login handshake to something I controlled. No luck; the certificate pinning on the June patch is quite decent.</p>

<p>I tried some “physical” exploits too. With the webview open, I started ripping the USB keyboard dongle in and out. Each replug spawned the webview again, and eventually the “System UI has stopped” popup appeared. But I didn’t realise what state I’d got it into and rebooted manually. Spent the next hour trying to repeat it with nothing to show but a sore arm.</p>

<p>I found that selecting text would sometimes offer intents. A search box gave me something I could type into and select. I typed an email address and got a Gmail intent. There’s an old trick where you start setting up an Exchange account, and when it asks to install certificates it prompts you to create a PIN that persists across reboots. I managed to set a PIN this way, but it didn’t help bypass the FRP account check. Dead end.</p>

<p>I tried a street address instead. When I selected it, Android offered a <strong>Google Maps</strong> intent. Since Maps on the tablet was so old, it prompted me to <strong>Use Maps on Chrome</strong>.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-02/maps-intent.jpg.webp" type="image/webp" />

<img src="/images/2026-02/maps-intent.jpg" loading="lazy" alt="" />
</picture>






</figure>

<figure class="image  center">

<picture>

<source srcset="/images/2026-02/chrome-launch.jpg.webp" type="image/webp" />

<img src="/images/2026-02/chrome-launch.jpg" loading="lazy" alt="" />
</picture>






</figure>

<p><em>Finally</em>, I was in a real browser.</p>

<p>With full Chrome, the intent URL that had failed in restricted webviews now worked:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>intent://com.android.settings/#Intent;scheme=android-app;end
</code></pre></div></div>

<p>This dropped me straight into Settings. I did a factory reset from there, hoping it would clear the FRP flag since I was inside the OS rather than recovery. It didn’t. After reboot, the same “Verify your account” screen was waiting for me. Back to square one.</p>

<p>I repeated the Maps trick to get back into Chrome. This time I knew I needed a different approach.</p>

<h2 id="getting-in">Getting in</h2>

<p>I headed over to <code class="language-plaintext highlighter-rouge">addrom.com/bypass</code> to grab the tools I needed. From there I downloaded two APKs:</p>

<ol>
  <li><strong>Google Account Manager 6.0.1</strong> - an older version that still has the “Browser Sign-in” option</li>
  <li><strong>FRP Bypass APK</strong> - a small app that triggers the account manager</li>
</ol>

<p>After installing both, I launched the FRP Bypass app, tapped the three dots menu, and selected <strong>Browser Sign-in</strong>. This opened a Google login page where I signed in with my Gmail account. The system accepted it as the new owner account.</p>

<p>Quick reboot, and instead of “Verify your account” I got “Account Added”.</p>

<figure class="image  center">

<picture>

<img src="/images/2026-02/hackerman.gif" loading="lazy" alt="" />
</picture>






</figure>

<p>I wasn’t about to leave it at that. First thing I did was head to <strong>Settings &gt; About Tablet</strong> and mash the build number until I got developer access, then toggle <strong>OEM Unlocking</strong> and <strong>USB Debugging</strong> to ON.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-02/developer-options.png.webp" type="image/webp" />

<img src="/images/2026-02/developer-options.png" loading="lazy" alt="" />
</picture>






</figure>

<p>I’ve also run <code class="language-plaintext highlighter-rouge">fastboot flashing unlock</code> from my Mac to make sure I never have to do the emoji dance again.</p>

<p>The whole process took a couple of hours spread over two or three days. Life gets in the way of these deep dives.</p>

<p>As for the display fix, it’s probably not permanent. According to <a href="https://www.ifixit.com/Teardown/Google+Pixel+C+Teardown/62277">iFixit’s teardown</a>, the screen is glued down, so properly securing the ribbon cable would be quite involved. For now I’ll just be careful with it.</p>

<p>The cracked screen unit is set up and working fine despite the cracks. It might become a dedicated Klipper display for my 3D printer. This one, with its intact screen, could become my go-to 10” tablet for magazines, comics, and books. Or more likely I’ll just mess with it for a bit and then stick it on a shelf.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">Using split view in Chrome</title>
        <link href="https://blog.omgmog.net/post/using-split-view-in-chrome/" rel="alternate" type="text/html" title="Using split view in Chrome" />
        <link href="https://blog.omgmog.net/post/using-split-view-in-chrome/.txt" rel="alternate" type="text/plain" title="Using split view in Chrome (Plain Text)" />
        <published>2026-02-05T00:00:00+00:00</published>
        <updated>2026-02-05T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/using-split-view-in-chrome/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="software" />
        
        <category term="review" />
        
        <summary type="html"><![CDATA[Chrome’s split view tabs is one of those features that silently arrived and slipped into my workflow. I’ve found myself using it more and more over the past couple of weeks since it rolled out at the end of 2025. It’s as intuitive as tab grouping (hold shift when selecting multiple tabs), and genuinely useful. The kind of feature that slots in and makes me wonder how I managed without it.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/using-split-view-in-chrome/"><![CDATA[<p>Chrome’s split view tabs is one of those features that silently arrived and slipped into my workflow. I’ve found myself using it more and more over the past couple of weeks since it rolled out at the end of 2025. It’s as intuitive as tab grouping (hold shift when selecting multiple tabs), and genuinely useful. The kind of feature that slots in and makes me wonder how I managed without it.</p>

<!-- more -->

<h2 id="how-it-works">How it works</h2>

<p>Chrome added a native split screen feature back in November. Right-click any tab and there’s a split view option (the wording changes depending on context - “Add tab to new split view” on the active tab, “New split view with current tab” on inactive tabs). Pick another tab and they sit side by side in the same window.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-02/chrome-split-tabs/split-tab-menu.png.webp" type="image/webp" />

<img src="/images/2026-02/chrome-split-tabs/split-tab-menu.png" loading="lazy" alt="" />
</picture>






</figure>

<figure class="image  massive">

<picture>

<source srcset="/images/2026-02/chrome-split-tabs/split.png.webp" type="image/webp" />

<img src="/images/2026-02/chrome-split-tabs/split.png" loading="lazy" alt="" />
</picture>






</figure>

<p>Each half gets its own status area at the bottom right, the browser toolbar gets a split tab icon to manage things (swap sides, close one view, split it back out), and the address bar content follows whichever side has mouse focus. The divider resizes by dragging, or double-click it to quickly swap sides.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-02/chrome-split-tabs/split-status-menu.png.webp" type="image/webp" />

<img src="/images/2026-02/chrome-split-tabs/split-status-menu.png" loading="lazy" alt="" />
</picture>






</figure>

<figure class="image  center">

<picture>

<source srcset="/images/2026-02/chrome-split-tabs/split-toolbar-menu.png.webp" type="image/webp" />

<img src="/images/2026-02/chrome-split-tabs/split-toolbar-menu.png" loading="lazy" alt="" />
</picture>






</figure>

<h2 id="why-im-using-it">Why I’m using it</h2>

<p>The obvious case: documentation on one side while working on the other. Reading through API docs, following a tutorial, watching a YouTube video - whilst coding or testing in the other half. No alt-tabbing, no hunting for which window’s where.</p>

<p>I use Raycast for window management. I’ve got keyboard shortcuts to snap windows left and right, quarter screens, the lot. For Chrome tabs though, split view is better. With separate Chrome windows I’m managing window positions, worrying about which window’s in focus, dealing with them getting buried under other apps. With split view it’s <em>just</em> one window, one thing to manage. Even if the viewports are a bit smaller than using two windows.</p>

<figure class="image  massive">

<picture>

<source srcset="/images/2026-02/chrome-split-tabs/split-vs-windows.png.webp" type="image/webp" />

<img src="/images/2026-02/chrome-split-tabs/split-vs-windows.png" loading="lazy" alt="" />
</picture>






</figure>

<p>I’m running dual 2560x1440 displays with loads of windows open - editor, Slack, work stuff, personal stuff - and split view still makes a difference. Even with all that screen space and Raycast managing it, keeping related tabs in one window is simpler than juggling separate Chrome windows across displays.</p>

<p>Firefox is getting split view in 148. It’s <a href="https://blog.nightly.mozilla.org/2026/01/26/take-note-split-view-is-ready-for-testing-these-weeks-in-firefox-issue-194/">currently in Nightly for testing</a>. Competition’s good - hopefully it pushes both browsers to refine the feature further.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">Downgrading the OnePlus 7 Pro for LineageOS</title>
        <link href="https://blog.omgmog.net/post/downgrading-the-oneplus-7-pro-for-lineageos/" rel="alternate" type="text/html" title="Downgrading the OnePlus 7 Pro for LineageOS" />
        <link href="https://blog.omgmog.net/post/downgrading-the-oneplus-7-pro-for-lineageos/.txt" rel="alternate" type="text/plain" title="Downgrading the OnePlus 7 Pro for LineageOS (Plain Text)" />
        <published>2026-02-04T00:00:00+00:00</published>
        <updated>2026-02-04T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/downgrading-the-oneplus-7-pro-for-lineageos/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="guide" />
        
        <category term="android" />
        
        <summary type="html"><![CDATA[I’ve had a OnePlus 7 Pro sitting in a drawer for a couple of years. I used it from 2019, found it too big, got a Pixel 5a, and now I’m on the Pixel 7a. The usual thing, fiddling with an old gadget that’s been kicking about doing nothing. It’s a nice phone with nice features. Shame it’s stuck on such an old version of Android. Time to get LineageOS on there.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/downgrading-the-oneplus-7-pro-for-lineageos/"><![CDATA[<p>I’ve had a OnePlus 7 Pro sitting in a drawer for a couple of years. I used it from 2019, found it too big, got a Pixel 5a, and now I’m on the Pixel 7a. The usual thing, fiddling with an old gadget that’s been kicking about doing nothing. It’s a nice phone with nice features. Shame it’s stuck on such an old version of Android. Time to get LineageOS on there.</p>

<!-- more -->

<h2 id="the-bootloader-problem">The bootloader problem</h2>

<p>With the Android 12-based OxygenOS the bootloader can’t be unlocked. To get around this I needed to downgrade to Android 11 first.</p>

<p>The URLs floating around the internet for the official rollback packages are broken, the <code class="language-plaintext highlighter-rouge">oxygenos.oneplus.net</code> domain doesn’t serve them anymore. The files still exist on AWS though. Just swap the domain:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://oxygenos.oneplus.net/OnePlus7ProOxygen_21.E.41_OTA_0410_all_2112101752_downgrade_3821c2d4496c48fc.zip
</code></pre></div></div>

<p>becomes:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://s3.amazonaws.com/oxygenos.oneplus.net/OnePlus7ProOxygen_21.E.41_OTA_0410_all_2112101752_downgrade_3821c2d4496c48fc.zip
</code></pre></div></div>

<p>I found this via <a href="https://community.oneplus.com/thread/1181167844348919809">a thread on the OnePlus community forums</a>.</p>

<h2 id="downgrading-to-android-11">Downgrading to Android 11</h2>

<ol>
  <li>Download the rollback update package using the S3 URL above</li>
  <li>Enable <strong>Developer Options</strong> (tap the build number 7 times in Settings → About Phone)</li>
  <li>Enable <strong>USB Debugging</strong> in Developer Options</li>
  <li>Push the file to the phone:</li>
</ol>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>adb push OnePlus7ProOxygen_21.E.41_OTA_0410_all_2112101752_downgrade_3821c2d4496c48fc.zip /sdcard/
</code></pre></div></div>

<ol>
  <li>On the phone, go to <strong>Settings → System → About Device → System Updates → Local Install</strong></li>
  <li>Pick the zip, extract, and hit <strong>Install Now</strong> (the phone will reboot)</li>
</ol>

<h2 id="unlocking-the-bootloader">Unlocking the bootloader</h2>

<p>After the downgrade completes:</p>

<ol>
  <li>Enable <strong>Developer Options</strong> again</li>
  <li>Enable <strong>USB Debugging</strong> again</li>
  <li>Reboot into fastboot:</li>
</ol>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>adb reboot bootloader
</code></pre></div></div>

<ol>
  <li>Unlock the bootloader:</li>
</ol>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fastboot oem unlock
</code></pre></div></div>

<h2 id="installing-lineageos">Installing LineageOS</h2>

<p>The <a href="https://wiki.lineageos.org/devices/guacamole/variant1/">LineageOS installation guide for the OnePlus 7 Pro (guacamole)</a> covers this in detail. The rough process:</p>

<p>Flash the partition images:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fastboot flash dtbo dtbo.img
fastboot flash vbmeta vbmeta.img
</code></pre></div></div>

<p>Reboot back into fastboot and flash the boot image:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fastboot reboot bootloader
fastboot flash boot boot.img
</code></pre></div></div>

<p>Factory reset from the recovery, then sideload LineageOS and GApps (I wanted the Play Store):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>adb sideload lineage-23.0-20260202-nightly-guacamole-signed.zip
adb sideload MindTheGapps-16.0.0-arm64-20250812_214353.zip
</code></pre></div></div>

<p>Reboot the device and it’s done.</p>

<p>LineageOS 23 runs fine. An evening spent downgrading from Android 12 to 11 so I could upgrade to 16, as one does. Back in the drawer it goes.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">The LinYuvo KS42 Wireless Joycons for the Nintendo Switch</title>
        <link href="https://blog.omgmog.net/post/linyuvo-ks42-wireless-joycons/" rel="alternate" type="text/html" title="The LinYuvo KS42 Wireless Joycons for the Nintendo Switch" />
        <link href="https://blog.omgmog.net/post/linyuvo-ks42-wireless-joycons/.txt" rel="alternate" type="text/plain" title="The LinYuvo KS42 Wireless Joycons for the Nintendo Switch (Plain Text)" />
        <published>2026-02-01T00:00:00+00:00</published>
        <updated>2026-02-01T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/linyuvo-ks42-wireless-joycons/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="nintendo" />
        
        <category term="review" />
        
        <category term="hardware" />
        
        <category term="gaming" />
        
        <summary type="html"><![CDATA[I’ve gone through a few sets of Joy-Cons since buying my Switch back in 2018. Every pair has ended up with stick drift, and at this point my Switch is running Android full time, so spending £60 on first-party replacements felt like a waste. I picked up the LinYuvo KS42 from AliExpress for around £18 instead.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/linyuvo-ks42-wireless-joycons/"><![CDATA[<p>I’ve gone through a few sets of Joy-Cons since buying my Switch back in 2018. Every pair has ended up with stick drift, and at this point my Switch is <a href="/post/installing-android-on-the-nintendo-switch/">running Android</a> full time, so spending £60 on first-party replacements felt like a waste. I picked up the <a href="https://www.linyuvo.com/products/ks42">LinYuvo KS42</a> from <a href="https://www.aliexpress.com/item/1005007359114819.html">AliExpress</a> for around £18 instead.</p>

<!-- more -->

<figure class="image  center">

<picture>

<source srcset="/images/2026-02/ks42-switch.png.webp" type="image/webp" />

<img src="/images/2026-02/ks42-switch.png" loading="lazy" alt="" />
</picture>






</figure>

<h2 id="specs">Specs</h2>

<table class="massive">
  <thead>
    <tr>
      <th>Feature</th>
      <th>Details</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Connectivity</strong></td>
      <td>Bluetooth 2.1</td>
    </tr>
    <tr>
      <td><strong>Joysticks</strong></td>
      <td>Hall Effect</td>
    </tr>
    <tr>
      <td><strong>Gyro</strong></td>
      <td>6-axis</td>
    </tr>
    <tr>
      <td><strong>Battery</strong></td>
      <td>500mAh per side, ~6.5 hours, ~3 hours to charge</td>
    </tr>
    <tr>
      <td><strong>Weight</strong></td>
      <td>350g (combined)</td>
    </tr>
    <tr>
      <td><strong>Extras</strong></td>
      <td>Dual vibration motors, 2 macro back buttons, turbo, RGB LEDs</td>
    </tr>
  </tbody>
</table>

<h2 id="build-and-feel">Build and feel</h2>

<p>These are <em>chunky</em>. They’re obviously inspired by (or a rebadged clone of) the <a href="https://nyxigame.com/pages/wireless-nintendo-switch-controller-nyxi-hyperion-series">Nyxi Hyperion</a>, for about a third of the price, with a similar ergonomic grip that makes the Switch feel more like holding a proper controller. I’ve got big hands and the standard Joy-Cons always felt cramped, especially for longer sessions. The KS42s sorted that out - with them attached, the Switch is about the same size as a Steam Deck.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-02/ks42-x-steamdeck.png.webp" type="image/webp" />

<img src="/images/2026-02/ks42-x-steamdeck.png" loading="lazy" alt="" />
</picture>






</figure>

<p>The left controller has a full-size d-pad rather than the four individual buttons on Nintendo’s Joy-Cons, which I prefer. Buttons feel soft but responsive. The Hall Effect sticks have a smooth range of motion and shouldn’t develop drift - they use magnets instead of physical contacts.</p>

<p>Each half has its own USB-C port for charging (they do not charge via the Switch). In the box there’s a bridging piece that clips the two halves together to form one (slightly wide) controller for wireless play. It also works with the original Joy-Cons and actually feels quite nice with them - a good option for player 2.</p>

<p>The vibration motors feel cheap. Instead of a continuous rumble, they do a noticeable burst of small vibrations. There’s a button on the back of each controller to adjust or disable it. There are two macro buttons on the back, reminiscent of the Steam Deck’s back paddles, but I’ve not found a use for them yet. The RGB LEDs have a few colour modes but I turned them off after about five minutes - they drain the battery and I couldn’t care less about RGB.</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-02/ks42-wireless.png.webp" type="image/webp" />

<img src="/images/2026-02/ks42-wireless.png" loading="lazy" alt="" />
</picture>






</figure>

<p>The controllers pair just like any other joycons. All the buttons map correctly, the gyro works, and holding the home button wakes the Switch from sleep. Slide them onto the rails and they’re recognised immediately (including the satisfying snap animation). The connecting edge is thicker than on first-party Joy-Cons, so there’s an abrupt step where the controller meets the Switch. Looks a bit odd given how thin the console is.</p>

<p>There’s no NFC, so no Amiibo support. They’ve got SL and SR buttons for split play, but holding a single controller sideways isn’t the most comfortable given how chunky they are. Fine for a quick round of something, not great for longer sessions.</p>

<p>Because of the grips, the Switch tilts back noticeably when set down on a surface or a lap. The standard Joy-Cons sit fairly flat, but with the KS42s attached the whole thing leans back at an angle. Not ideal for handheld propped against a cushion or a tray table.</p>

<p>One annoyance: accidentally knocking a controller while the Switch is off will wake it up, and it’ll sit there draining battery. They apparently auto-sleep after 5 minutes, but I’d rather they just stayed off.</p>

<p>For £18 they do the job. They’re comfortable, they work with Android without any fuss, and the build quality is fine for what they cost. I wanted functional joycons for a banned, last-gen Switch that I’m not about to spend serious money on, and that’s exactly what I got.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">Plugins instead of patches</title>
        <link href="https://blog.omgmog.net/post/plugins-instead-of-patches/" rel="alternate" type="text/html" title="Plugins instead of patches" />
        <link href="https://blog.omgmog.net/post/plugins-instead-of-patches/.txt" rel="alternate" type="text/plain" title="Plugins instead of patches (Plain Text)" />
        <published>2026-01-28T00:00:00+00:00</published>
        <updated>2026-01-28T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/plugins-instead-of-patches/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="indieweb" />
        
        <category term="programming" />
        
        <category term="site-related" />
        
        <summary type="html"><![CDATA[I’ve been running Known as my social posting hub for a while now. It’s a solid IndieWeb-friendly CMS that handles status updates, photos, and syndication to various silos. Recently I upgraded my instance for the first time in about four years, which meant dealing with all the little tweaks I’d made to core templates over that time. Previously I’d just edited core files directly, which meant upgrades would wipe my changes. After four years I had no idea what I’d even modified (I’ll go into the archaeology of that in another post). This time I decided to do it properly, and ended up writing four plugins.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/plugins-instead-of-patches/"><![CDATA[<p>I’ve been running <a href="https://withknown.com/">Known</a> as <a href="https://social.omgmog.net">my social posting hub</a> for a while now. It’s a solid IndieWeb-friendly CMS that handles status updates, photos, and syndication to various silos. Recently I upgraded my instance for the first time in about four years, which meant dealing with all the little tweaks I’d made to core templates over that time.</p>

<p>Previously I’d just edited core files directly, which meant upgrades would wipe my changes. After four years I had no idea what I’d even modified (I’ll go into the archaeology of that in another post). This time I decided to do it properly, and ended up writing <a href="https://github.com/omgmog?tab=repositories&amp;q=Known-">four plugins</a>.</p>

<!-- more -->

<h2 id="likelabels">LikeLabels</h2>

<p>Known calls likes “stars”. The button says “star”, the count says “stars”. This is fine, but I wanted mine to say “likes” instead. A small thing, but it <em>bugged</em> me.</p>

<p>The <a href="https://github.com/omgmog/Known-LikeLabels">LikeLabels</a> plugin overrides the template that renders these labels and pulls the text from a config setting instead. There’s an admin page where I can set the singular and plural forms to whatever I want. “Like/likes”, “heart/hearts”, “fave/faves”, whatever.</p>

<h2 id="custommenu">CustomMenu</h2>

<p>Jumping into a template file every time I want to tweak the navigation isn’t ideal, so I made the <a href="https://github.com/omgmog/Known-CustomMenu">CustomMenu</a> plugin to handle it through the admin UI instead.</p>

<p>It adds an admin page with a table of menu items. Each one has a label, URL, and an optional <code class="language-plaintext highlighter-rouge">rel</code> attribute (handy for <code class="language-plaintext highlighter-rouge">rel="me"</code> verification). I can drag them around to reorder, and mark one as “active” to highlight which site I’m currently on. The plugin then injects these into the toolbar template.</p>

<h2 id="richfeed">RichFeed</h2>

<p>The <a href="https://github.com/omgmog/Known-RichFeed">RichFeed</a> plugin adds OpenGraph metadata to the JSON Feed output for any URLs in posts. It also renders Markdown and strips out bare URLs that have been unfurled (so they don’t appear twice).</p>

<p>The plan is to use this data to create richer embeds of my timeline on my blog. This plugin is doing a bit much (unfurling, Markdown, URL stripping), but it works for what I need.</p>

<h2 id="unfurlmanager">UnfurlManager</h2>

<p>Sometimes I don’t <em>want</em> a URL to be unfurled. Maybe the preview card looks rubbish, or maybe I’m linking to something where the URL itself is the point. The <a href="https://github.com/omgmog/Known-UnfurlManager">UnfurlManager</a> plugin lets me hide individual unfurls on a per-post basis.</p>

<p>Each post stores a list of URLs that shouldn’t be unfurled. The RichFeed plugin checks this list and excludes those URLs from the <code class="language-plaintext highlighter-rouge">_unfurls</code> data. The URL still appears as a link in the post, it just doesn’t get a preview card.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">The Bigme B6 Color E-Ink Tablet</title>
        <link href="https://blog.omgmog.net/post/bigme-b6-color-e-ink-tablet/" rel="alternate" type="text/html" title="The Bigme B6 Color E-Ink Tablet" />
        <link href="https://blog.omgmog.net/post/bigme-b6-color-e-ink-tablet/.txt" rel="alternate" type="text/plain" title="The Bigme B6 Color E-Ink Tablet (Plain Text)" />
        <published>2026-01-26T00:00:00+00:00</published>
        <updated>2026-01-26T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/bigme-b6-color-e-ink-tablet/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="android" />
        
        <category term="review" />
        
        <category term="hardware" />
        
        <summary type="html"><![CDATA[I’ve owned plenty of Kindles and Android tablets over the years, but I’ve been curious about colour e-ink for a while - whether it’s actually useful or just a gimmick.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/bigme-b6-color-e-ink-tablet/"><![CDATA[<p>I’ve owned plenty of Kindles and Android tablets over the years, but I’ve been curious about colour e-ink for a while - whether it’s actually useful or just a <em>gimmick</em>.</p>

<!-- more -->

<figure class="image  massive">

<picture>

<source srcset="/images/2026-01/bigme-b6/device.png.webp" type="image/webp" />

<img src="/images/2026-01/bigme-b6/device.png" loading="lazy" alt="" />
</picture>






</figure>

<p>The <a href="https://bigmestore.com/products/bigme-b6-color-ereader-with-android-14os">Bigme B6 Color</a> is a 6-inch Android tablet with <a href="https://www.eink.com/product/detail/EC060KH5">Kaleido 3</a> colour e-ink. I picked one up wanting something easier on the eyes for bedtime reading - not another glowing phone screen.</p>

<h2 id="specs">Specs</h2>

<table class="massive">
  <thead>
    <tr>
      <th>Feature</th>
      <th>Details</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Display</strong></td>
      <td>6-inch Kaleido 3 colour e-ink (300 PPI B&amp;W, 150 PPI colour)</td>
    </tr>
    <tr>
      <td><strong>Resolution</strong></td>
      <td>1448 × 1072 (B&amp;W), 724 × 536 (colour layer)</td>
    </tr>
    <tr>
      <td><strong>Processor</strong></td>
      <td>MediaTek Helio P35, Octa-core 2.3GHz</td>
    </tr>
    <tr>
      <td><strong>RAM</strong></td>
      <td>4GB</td>
    </tr>
    <tr>
      <td><strong>Storage</strong></td>
      <td>64GB (expandable via microSD up to 1TB)</td>
    </tr>
    <tr>
      <td><strong>OS</strong></td>
      <td>Android 14</td>
    </tr>
    <tr>
      <td><strong>Battery</strong></td>
      <td>2100mAh</td>
    </tr>
    <tr>
      <td><strong>Frontlight</strong></td>
      <td>Warm/cool adjustable (36 white and amber LEDs)</td>
    </tr>
    <tr>
      <td><strong>Connectivity</strong></td>
      <td>Dual-band WiFi, Bluetooth, USB-C with OTG</td>
    </tr>
    <tr>
      <td><strong>Audio</strong></td>
      <td>Speaker, microphone</td>
    </tr>
    <tr>
      <td><strong>Size</strong></td>
      <td>108 × 149 × 7mm</td>
    </tr>
    <tr>
      <td><strong>Weight</strong></td>
      <td>176g</td>
    </tr>
  </tbody>
</table>

<h2 id="the-display">The display</h2>

<p>Colour e-ink is not vibrant. The colour layer sits on top of the B&amp;W layer, so colours are muted (think newspaper, <em>not</em> magazine), resolution is halved for colour content, and there’s some ghosting when switching between colour and B&amp;W.</p>

<figure class="image  massive">

<picture>

<source srcset="/images/2026-01/bigme-b6/color-display.png.webp" type="image/webp" />

<img src="/images/2026-01/bigme-b6/color-display.png" loading="lazy" alt="" />
</picture>






</figure>

<p>For pure text reading, the B&amp;W mode is excellent. Text is sharp and the frontlight is even. For illustrated books and comics, the colour adds something - not enough to replace an iPad, but enough to make children’s books and the occasional graphic novel more usable than greyscale.</p>

<p>At 6 inches, it’s pocketable (large pocket) and comfortable for one-handed reading - good for commuting or bedtime. But that size limits what works well. A4 PDFs need constant zooming and panning, detailed comics feel cramped. It’s just right for novels, articles, and manga formatted for smaller screens.</p>

<p>It comes with a magnetic protective cover that snaps on and wakes the device when opened. The back of the device itself is also magnetic, which could be useful for mounting or storage - I haven’t found a use for it yet, but it’s <em>there</em>.</p>

<h2 id="android-on-e-ink">Android on e-ink</h2>

<p>The B6 runs Android 14, which means I can install any reading app I want - Kindle, Kobo, Google Books, Libby, <a href="https://koreader.rocks/">KOReader</a>. That flexibility is the main selling point over a locked-down Kindle or Kobo.</p>

<p>The trade-off is that most Android apps assume fast colour screens. Animations stutter, menus leave traces, and battery drains faster with apps running in the background. Bigme has added some e-ink optimisations (refresh mode options, reduced animations), but it’s still <em>phone-first</em> Android running on e-ink.</p>

<p>The stock OS also comes with a lot of bloat - Bigme’s own app store, translation apps, a video player, various MediaTek utilities. I disabled most of it and replaced the home screen to get something cleaner. I’ve put together a <a href="https://gist.github.com/omgmog/2c0987faac4d7e4bc907af4e6830168c">debloat script</a> that handles the cleanup. After debloating, battery life is good - I charge it maybe once a week with regular use. Better than any LCD tablet, not as good as a dedicated Kindle.</p>

<p>One other quirk: the face buttons. They’re hardware buttons with icons for specific functions, remappable through xSetting. The icons made no sense to me, so I covered them with vinyl stickers labelled B/H/F/T/S and rebound them to back/home/files/task switcher/settings. <em>Much</em> better.</p>

<figure class="image  massive">

<picture>

<source srcset="/images/2026-01/bigme-b6/buttons.jpg.webp" type="image/webp" />

<img src="/images/2026-01/bigme-b6/buttons.jpg" loading="lazy" alt="" />
</picture>






</figure>

<p>There don’t seem to be any custom Android versions available for the B6 unfortunately. There’s been <a href="https://xdaforums.com/t/bigme-hibreak-root-mediatek-6765.4697830/">some work on other Bigme devices</a>, but nobody’s cracked the screen software.</p>

<p>After debloating, I’m using:</p>

<ul>
  <li><a href="https://koreader.rocks/">KOReader</a> - my main reading app</li>
  <li><a href="https://f-droid.org/packages/com.nononsenseapps.feeder/">Feeder</a> - RSS feeds</li>
  <li>Kindle, Google Play Books, Libby - for books I’ve bought elsewhere or library borrowing</li>
  <li>Spotify - audiobooks and podcasts</li>
</ul>

<h2 id="should-you-buy-one">Should you buy one?</h2>

<p>Probably not.</p>

<p>I’m using the B6 for reading books, catching up on RSS feeds and indieweb stuff - basically anything that isn’t doom scrolling. It’s good at that. The e-ink screen means I can read for hours without the eye strain I get from phones and tablets.</p>

<p>But it sits in an awkward spot. It promises a lot (Android flexibility, colour e-ink, compact size) but it doesn’t quite deliver on any of them fully. I’d like something a bit bigger for PDFs, and the screen still feels sluggish for anything interactive. It’s caught between being a Kindle and being a tablet, without being the best at <em>either</em>.</p>

<p>I paid around £125 for the B6. The colour e-ink market has matured since then - there are more options now and prices are dropping. The <a href="https://inkbook.eu/products/e-book-reader-inkbook-solaris-color">InkBook Solaris Color</a> has similar specs for £120-145, and mainstream devices like the <a href="https://uk.kobobooks.com/products/kobo-clara-colour">Kobo Clara Colour</a> now offer Kaleido 3 without needing to tinker with Android. If I were buying today, I’d probably look at one of those or wait for the next generation of colour e-ink panels.</p>

<p>Colour e-ink is nice to have, but not essential. For most reading, regular e-ink is probably fine and cheaper.</p>]]></content>
    </entry>
    
    <entry>
        <title type="html">Why can&apos;t I read this picture book?</title>
        <link href="https://blog.omgmog.net/post/why-cant-i-read-this-picture-book/" rel="alternate" type="text/html" title="Why can&apos;t I read this picture book?" />
        <link href="https://blog.omgmog.net/post/why-cant-i-read-this-picture-book/.txt" rel="alternate" type="text/plain" title="Why can&apos;t I read this picture book? (Plain Text)" />
        <published>2026-01-23T00:00:00+00:00</published>
        <updated>2026-01-23T00:00:00+00:00</updated>
        <id>https://blog.omgmog.net/post/why-cant-i-read-this-picture-book/</id>
        <author>
            <name>Max Glenister</name>
        </author>
        
        <category term="ux" />
        
        <category term="design" />
        
        <summary type="html"><![CDATA[I’ve spent the last five years reading picture books with my kids at bedtime. Hundreds of them - and I’m still going, even as my eldest moves on to chapter books. One thing has consistently driven me mad: I often can’t read the words.]]></summary>
        <content type="html" xml:base="https://blog.omgmog.net/post/why-cant-i-read-this-picture-book/"><![CDATA[<p>I’ve spent the last five years reading picture books with my kids at bedtime. Hundreds of them - and I’m still going, even as my eldest moves on to chapter books. One thing has consistently driven me mad: I often can’t read the words.</p>

<!-- more -->

<p>Books that look fine in the shop become unreadable at night. Black text on dark blues, purples, greens - whatever colour the page happens to be. I spend too many evenings squinting, tilting the book toward the light, trying to decode sentences while my tired eyes give up.</p>

<h2 id="highlighting-the-problem">Highlighting the problem</h2>

<p>The worst offenders follow a predictable pattern - designers pick a colour scheme that matches the illustration, then slap black text on top without considering how anyone will actually read it. Dark blue backgrounds with black text, nearly unreadable in low light. The reverse - dark blue text on black backgrounds - is just as bad. Purple, dark green, any dark colour really.</p>

<p>This isn’t about bad eyesight (though as I approach 40, that’s increasingly relevant). Picture books are read by tired adults, in low light, often at the end of a long day. Designing for perfect vision under studio lighting misses the real use case.</p>

<p>The Web Content Accessibility Guidelines (WCAG) specify a minimum contrast ratio of 4.5:1 for normal text. It’s designed for screens, but screens let you zoom, boost brightness, or switch themes. Print doesn’t. If anything, print has <em>less</em> margin for poor contrast, not more.</p>

<p>Here’s what those contrast ratios look like in practice:</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-01/contrast-ratios-best.png.webp" type="image/webp" />

<img src="/images/2026-01/contrast-ratios-best.png" loading="lazy" alt="" />
</picture>





<figcaption><p>Source: <a href="https://accessiblyapp.com/web-accessibility/colors/">https://accessiblyapp.com/web-accessibility/colors/</a></p>
</figcaption>
</figure>

<p><strong>One in 12 men</strong> and <strong>one in 200 women</strong> have colour vision deficiency. <strong>One in 30 people</strong> in Europe experience some form of sight loss. Poor contrast creates accessibility problems regardless of lighting conditions. The bedtime reading scenario makes it worse.</p>

<p>Certain colours fail more often than others under low light. The problem with blue and purple backgrounds is luminance - these dark colours have similar luminance values to black, which shrinks the contrast between them.</p>

<p>Remember that dress photo from 2015?</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-01/the-dress.jpg.webp" type="image/webp" />

<img src="/images/2026-01/the-dress.jpg" loading="lazy" alt="" />
</picture>






</figure>

<p>The dress went viral because our brains guess the light source. Bedtime reading forces a similar guess, every night, under dim, uneven lamplight. Designers assume bright, even conditions. The colours and contrast that work in one context fail in the other.</p>

<h2 id="this-isnt-difficult-to-fix">This isn’t difficult to fix</h2>

<p>I’ve spent 20 years working in interface design and web UX, so I know basic contrast principles really aren’t complicated. This isn’t about killing creativity. Illustrations can be as bold and colourful as you like. That’s the point of picture books. Text just needs to be boring enough to read.</p>

<p>Testing designs in a dark room by bedside lamp would catch most of these problems. If it’s hard to read in that light, there’s a good chance the contrast’s too low.</p>

<p>Illustrators already know how to handle nighttime scenes - they use careful colour shifts to keep illustrations visible. That same consideration needs to extend to the text.</p>

<p>Contrast checking tools exist - and print designers are already using the same software as web designers, just with CMYK instead of RGB. The tools are right there in the workflow. Here’s how they visualise the problem:</p>

<figure class="image  center">

<picture>

<source srcset="/images/2026-01/contrast-diagram.png.webp" type="image/webp" />

<img src="/images/2026-01/contrast-diagram.png" loading="lazy" alt="" />
</picture>





<figcaption><p>Source: <a href="https://accessiblyapp.com/web-accessibility/color-contrast/">https://accessiblyapp.com/web-accessibility/color-contrast/</a></p>
</figcaption>
</figure>

<p>These tools test combinations against 4.5:1, and greyscale testing shows whether text will work in dim light. Dark text on light backgrounds gives the best results - black on cream or off-white is easiest to read. For coloured backgrounds, luminance difference needs to stay high - a cream or off-white text box with dark text, or halos and outlines around letters when backgrounds vary. <strong>Legibility isn’t optional.</strong></p>

<p>This is fixable. It’s not a hard problem. The only real requirement is to design for where these books are actually read: dim rooms, tired parents, and small humans waiting for the next page.</p>

<hr />

<p><strong>Further reading:</strong></p>
<ul>
  <li><a href="https://www.w3.org/WAI/WCAG22/Techniques/general/G18">Web Content Accessibility Guidelines (WCAG) 2.2</a></li>
  <li><a href="https://webaim.org/resources/contrastchecker/">WebAIM Contrast Checker</a></li>
  <li><a href="https://accessibility.blog.gov.uk/2016/06/17/colour-contrast-why-does-it-matter/">Colour contrast: why does it matter?</a></li>
  <li><a href="https://accessibility.blog.gov.uk/2016/09/02/dos-and-donts-on-designing-for-accessibility/">Dos and don’ts on designing for accessibility</a></li>
  <li><a href="https://www.charitycomms.org.uk/core-principles-for-accessible-design-in-print">Core principles for accessible design in print</a></li>
  <li><a href="https://www.iped-editors.org/wp-content/uploads/2024/06/Bookswithoutbarriers_Screen-2.pdf">Books Without Barriers (PDF)</a></li>
  <li><a href="https://bitcrowd.dev/designing-beautiful-and-accessible-ux-color-contrast/">Designing beautiful and accessible UX: Colour contrast</a></li>
</ul>]]></content>
    </entry>
    
</feed>