Blog

The Right Tool, and the Right Way to Hold It


"Use whatever works" has quietly become the default in tech. There is something to it: sometimes the ugly bash script that runs every night without complaint really is the right answer.

https://y.getyarn.io/2242b7b1-fc2e-406b-88dd-2d045fb4804c_text.gif

But it is a half truth. Tools are rarely chosen on merit alone. Most projects come with politics and an agenda attached, and you are expected to pick a side: systemd, Rust, Nix. Half the discussion around them is about belonging to the right camp, not about whether the tool fits the job in front of you.

And the full sentence was always longer anyway: whatever works, that you understand and can maintain. With AI agents it has never been easier to drop that second half.

The Thirty Second Solution

An agent can hand you a finished solution in seconds: a script, a config, a whole setup, a workflow, a document. It works and the problem looks solved. But if you cannot maintain it, was it worth it? You have a solution, just an unmaintainable one, and the bill arrives the day it breaks and you are looking at something you "made" for the first time.

The How Matters

How you get to a solution decides whether you can live with it afterwards. If all you deliver are fast food solutions, passing a prompt in and the output along, you are not an engineer in that loop. You are a meat proxy, and proxies get optimized away.

This did not start with agents. Using VS Code because it was easy was never the solution it was the first step towards being replaced by Copilot. The editor is fine. Choosing it because it asked nothing of you was the problem.

A Tool You Can Get Behind

The right tool has to fit the job, but it also has to be one you can stand behind: one whose limits you know and that makes you better, not just faster. That gets harder when every week brings a new agent and a thousand YouTube videos racing to explain it. The temptation is to stop evaluating and grab whatever is on top of the pile.

Sometimes the right tool is uncomfortable. It takes time before it pays off but in the long run it is worth it. Choosing the lazy path was allways the problem. Some will say it is hard to argue for learning Neovim or Emacs today. Fair, but then why learn Linux or containers at all, when the solution is presented to you anyway? Why grow when you can take the easy pill and have an answer right away? Why work out? Because the why and the how matter. Use the agent, then understand what it gave you, and grow with your tools instead of being replaced by them.

/en/blog/2026/11/07/learning-curves.webp
Typical learning curves for some common text editors, by Manuel

Retroid Pocket Nova on ROCKNIX


I already own a Steam Deck, so the Retroid Pocket Nova was never meant to replace anything. What I wanted was a small, dedicated retro machine that I could hand to someone else without worrying about desktop clutter, and one that could give older games a proper 4:3 frame instead of the stretched widescreen look I usually end up with on the Deck. The Nova also happens to run on an ARM chip, the Snapdragon 8 Gen 2 (SM8550), and I was curious whether that architecture could handle more than emulation, including Windows games through a translation layer. This post is a rundown of what I did with it: shader settings, getting ROCKNIX running well, PortMaster (which turned into the biggest rabbit hole by far), Pico-8 in 4:3, and a first look at Steam on ARM.

/en/blog/2026/09/16/retroid-pocket-nova.jpeg

Before any of the software side there is a hardware detail worth mentioning: Retroid runs a DIY option on the Nova that lets you order a custom shell and button color instead of taking whatever the standard configurations ship with. Being a sucker for the classic atomic purple look, I went with the transparent purple shell and black buttons, the same combination I already run on most of my other retro devices.

Why ROCKNIX Instead of Stock Android

I am not a fan of fiddling around with Android. Between launchers, scoped storage permissions, and every emulator front end wanting its own idea of where ROMs should live, it stops feeling like a handheld and starts feeling like a phone I have to administer. So the moment I heard ROCKNIX had support for the Nova's chipset, I was happy even though it is clearly still in an early state for this particular device.

The rough edges show up immediately. On first boot the controller identified itself as a PlayStation DualSense Edge, back paddles and all, instead of anything resembling the Nova's actual button layout. That is not really a bug: it is ROCKNIX's InputPlumber service intentionally emulating a DualSense Edge so that paddle-aware games get proper input, but it does mean the very first thing you see is a slightly wrong set of button icons. Once you know that is what is happening it is easy to look past, and for a chip this new to ROCKNIX, the fact that the rest of the system already works this well says a lot about the project.

Getting ROCKNIX Onto the Device

The path that worked for me was to install ROCKNIX onto a microSD card first and boot from that. Booting from SD is the safer way to try a young port like this one, since nothing about the internal Android install is touched and a bad flash just means reformatting a card. Once I confirmed the SD boot was stable, I enabled SSH from the network settings menu and used that shell to move the install over to internal storage, which is both faster and one less card to keep track of. If you are trying this yourself, do the SD boot first, make sure your controller, WiFi, and audio all behave the way you expect, and only then commit to internal storage.

Shader Settings

Shaders are the part I spend an unreasonable amount of time on with any new emulation device, and this section ended up growing well past the single crt-easymode setup I started with, mostly because black frame insertion turned into its own multi day detour.

All of the shaders below come from the libretro/slang-shaders repository, which ships with RetroArch and therefore with ROCKNIX. I am linking the actual files so this is reproducible rather than just a list of settings names.

General systems: newpixie-crt, flattened, plus black frame insertion

I started with a single pass crt-easymode.slang setup and it was perfectly fine, but I kept running across recommendations for newpixie-crt and wanted to actually try it. The stock version chains four passes: phosphor persistence through an accumulate pass, two blur passes for the glow and color bleed, then a main pass that adds animated rolling scanlines, a vignette, and a lens curvature on top. Reading the actual shader math rather than trusting the parameter label, that curvature value also warps the coordinate used to sample the game image itself, so I patched the shader to keep sampling a plain, unwarped coordinate while leaving the original curved one in place for the vignette and frame mask.

The curvature fix itself came out of a discussion on the libretro forums about editing the newpixie CRT shader, which is where I first saw the coordinate swap described.

While I had the file open, I also baked in a subtle rounded corner effect using the same corner rounding math several standalone shaders in the same repository use, folded directly into the existing pass rather than added as a new one. That distinction mattered on this hardware specifically. This chain is already close to some kind of GPU or driver limit on texture bindings: adding the exact same corner effect as its own separate pass produced an outright shader compile failure on screen. A few extra lines of math inside a pass that already exists sidesteps that limit completely. The standalone version of that same corner effect, used briefly earlier with a lighter single pass shader before newpixie replaced it, is here: rounded-corners.slang.

shader3 = "newpixie-crt-flat.slang"
scale_type3 = "viewport"
curvature = 0.5
cornersize = 0.02
cornersmooth = 1000.0

The full file, both the curvature fix and the baked in corner rounding together, is here: newpixie-crt-flat.slang. That is one pass of the full seven pass chain, the accumulate and blur passes above it, then black frame insertion below.

Black frame insertion deserves its own explanation. RetroArch has a native setting for it, and it works, but only at exactly two times the content refresh rate. The Nova's panel runs at 120Hz against 60fps content, so there is no middle ground: it is either off or a full black frame flashed every other frame. That was too obvious for my taste. RetroArch also supports a newer, shader driven approach where the frame gets redrawn multiple times per screen refresh and a shader decides per redraw what to actually show, which opens up softer, motion aware techniques instead of a blanket flash. I settled on a shader called 120hz smart BFI, three passes (stock, calculations, then the actual flicker pass), appended right after the newpixie pass above. It only flickers pixels that are actually moving between frames, so static menus and backgrounds do not strobe at all. Getting there took a few wrong turns, including hitting that same compile limit when the corner effect was tried as its own eighth pass. Testing over SSH was mostly useless too, since the debug screenshot tool does not capture real in game rendering, so playing an actual game on the device was the only reliable check.

The final version of this, newpixie flattened as above, black frame insertion, and the baked in corner rounding, went onto NES, SNES, Genesis and Master System, PS1, Saturn, Dreamcast, and N64.

I still leave this off Game Boy, Game Boy Color, Game Boy Advance, and the DS, and off PSP, GameCube, and PS2, for mostly the same reason as before: those are LCD handhelds or already high resolution systems where a CRT effect does not represent anything that ever actually existed on the real hardware. Game Boy, Game Boy Color, and Game Boy Advance still got their own separate treatment, described below, and PSP got a display scaling fix rather than a shader, described further down as well.

Game Boy Advance, through mGBA

Given a GBA screen is a real LCD panel and never a CRT, this one gets a genuinely different effect rather than a toned down CRT one: vba-color for the same color correction curve the classic VBA emulator used, into lcd1x, which recreates the actual pixel grid structure of an LCD screen instead of faking scanlines.

shaders = "2"
shader0 = "vba-color.slang"
filter_linear0 = "false"
scale_type0 = "source"
scale0 = "1.0"
darken_screen = 0.400000

shader1 = "lcd1x-mix.slang"
filter_linear1 = "false"
scale_type1 = "viewport"

BRIGHTEN_SCANLINES = 32.000000
BRIGHTEN_LCD = 12.000000
GRID_STRENGTH = 0.750000

The lcd1x-mix name is not a stock file: I ended up writing a small modification of the original lcd1x.slang. The stock shader brightens the scanline and grid darkening through two parameters. Reading the actual formula rather than assuming from the label, higher values actually mean a less visible grid, the opposite of what the name suggests at a glance. Even pushed all the way to the top of their range there is still a visible minimum darkening baked into the formula, so once that ceiling stopped being subtle enough I added one more parameter, GRID_STRENGTH, that blends the gridded result back toward the plain image at any strength from zero to one. 0.75 landed well here, once BRIGHTEN_SCANLINES and BRIGHTEN_LCD were already maxed out. The full file is here: lcd1x-mix.slang, and it is what both this section and the Game Boy and Game Boy Color one below actually use, just with different strength values.

Game Boy and Game Boy Color, through Gambatte

The paper-like look actually lives here, not on mGBA. I had this misattributed for a while. Gambatte handles both GB and GBC, and now gets a three-pass chain: sharp-shimmerless for clean pixel-perfect upscaling, into image-adjustment for the warm, slightly desaturated white-paper tone, then the same custom lcd1x-mix from the GBA section above for the LCD grid texture.

shaders = "3"
shader0 = "sharp-shimmerless.slang"
filter_linear0 = "true"
shader1 = "image-adjustment.slang"
filter_linear1 = "true"

ia_luminance = 0.750000
ia_bright_boost = -0.150000
ia_saturation = 0.900000
ia_R = 1.025000
ia_B = 0.950000

shader2 = "lcd1x-mix.slang"
filter_linear2 = "false"
scale_type2 = "viewport"

BRIGHTEN_SCANLINES = 32.000000
BRIGHTEN_LCD = 12.000000
GRID_STRENGTH = 0.500000

The paper tone values are unchanged from before, same values as I once had written down for mGBA, since I originally set both up together and mixed up which core actually kept the config. Two notes if you copy these: 1.05 / 0.90 for ia_R / ia_B is too warm, 1.025 / 0.950 is the sweet spot. And ia_bright_boost being negative is intentional: it pulls the brightness back down slightly after the saturation boost, which is what gives it that paper-white rather than washed-out look. The grid strength ended up lower here than on GBA, 0.5 rather than 0.75. At the exact same brighten values the grid read noticeably harsher against this warmer, darker paper tone than against GBA's brighter base image, and GB and GBC's native resolution is lower than GBA's to begin with, so each grid cell is physically larger on the same screen.

Actual bezels

If you want a real graphical bezel around the image rather than just the plain color-adjustment chains above, the slang-shaders repository also ships full border packs, including a Super Game Boy border set with sample frames pulled straight from cartridge art (there is a Pokemon Red / Blue / Yellow set in there, which felt fitting given how much of my PortMaster time went to Pokemon fan games below), and a Game Boy Player border modeled on the GameCube accessory, which can also be combined with crt-easymode in a single preset. I have not committed to one of these full time. The plain shader chains above are still my daily setup, but they are worth a look if you want the small display to look like it is sitting inside an actual shell.

One ROCKNIX-specific gotcha worth knowing: shader presets need ROCKNIX's own runtime paths, not whatever path a preset shipped with from another platform. ROCKNIX rebuilds a shader tree fresh on every boot from a combination of your own files and the bundled packs, so once you point a preset at the right spot it stays stable across reboots, but a preset copied straight from, say, a Steam Deck EmuDeck install will not resolve correctly without editing the path first.

PortMaster: Where Most of the Time Actually Went

This is the section that grew far beyond what I originally planned, because PortMaster on ROCKNIX turned out to be a genuinely deep rabbit hole. PortMaster is the tool ROCKNIX (and most of the Batocera-family systems) use to install native Linux ports of PC and homebrew games, everything from simple SDL games to full engine reimplementations, packaged as install scripts that live right alongside your ROMs.

The bulk of what I did was getting a stack of Pokemon fan games running well. Most of these ship as Pokemon Essentials projects running on a community-maintained engine called mkxp-z, which reimplements RPG Maker's runtime for Linux. The scripts these games carry were mostly written for Windows and an old version of Ruby a decade ago, so a lot of them break in small specific ways under a modern Ruby interpreter: a stray Windows-only font installer that always reports fonts missing on Linux, byte-handling code that assumes an ancient string-indexing behavior, even a syntax pattern (an unreachable break right after a return) that older Ruby quietly accepted and newer Ruby now rejects outright. None of these are hard bugs once you find them: there are just a lot of them, spread across a dozen different games that all copy-pasted the same community scripts.

Two examples, since these are worth having on hand if you run into the same games. First, the fullscreen fix that most of these ports needed in their mkxp.json, since the default is a small windowed canvas with letterboxing on every side:

{
  "fixedAspectRatio": false,
  "fullscreen": true
}

Second, and this was the best find of the whole process: Pokemon Essentials ships two completely different ways of entering your character's name. One expects a proper on-screen keyboard from the operating system; the other is a self-contained screen that draws its own letter grid and only needs the D-pad and two buttons, exactly like naming a Pokemon on the original Game Boy games. The on-screen-keyboard path is the one that is broken on a Wayland-based handheld like this, and chasing it down through virtual keyboard protocols turned into its own multi-hour side quest. But the classic grid was sitting right there in the same script the whole time, fully working, just switched off by default in the Settings script:

# was: USEKEYBOARDTEXTENTRY = true
USEKEYBOARDTEXTENTRY = false

Flipping that one line fixed name entry across every affected game at once, no keyboard, no touch input, no extra dependencies.

The more exciting part came next: native ARM64 game ports that need no emulation at all. Projects using static recompilation, where an N64 or GameCube game's original machine code is translated once into real native code rather than interpreted at runtime, are becoming a small but active homebrew scene, and several of them now ship prebuilt ARM64 Linux binaries directly. I got a Banjo-Kazooie recompilation running completely natively, no translation layer in sight, using the Vulkan driver already on the device:

/en/blog/2026/09/16/banjo-recompiled.webp

A Twilight Princess recompilation runs the same way, and I also got a Super Smash Bros. Melee PC port working, which does need a light compatibility layer (box64) since that one only ships x86-64 builds. It still renders through the same real Vulkan driver, once a couple of missing system libraries are pointed at a compatibility runtime already present on the device:

export BOX64_LD_LIBRARY_PATH="./box64-libs"
export BOX64_EMULATED_LIBS="libsqlite3.so.0"
box64 ./melee "/path/to/Super Smash Bros. Melee.rvz"
/en/blog/2026/09/16/melee-memory-card.png

Thanks to just how good modern recompilations have gotten, we are living in a bit of a golden age for retro games right now: there is already a huge amount of native Linux gaming support on ARM, and it keeps growing. This is basically the role I wanted the Nova for in the first place: a Steam Deck addition mainly for retro games, emulation, and the occasional fan game. And with the 4:3 aspect ratio, it does that job perfectly.

PSP and Pico-8: Fixing the Aspect Ratio

These two needed the opposite fix from each other. The PSP screen is natively about 16:9, not 4:3, so left alone it renders letterboxed, black bars top and bottom, to preserve that shape. I wanted the opposite: filling the whole 4:3 panel instead, accepting the mild horizontal squish that comes with it. That lives in the [Graphics] section of PPSSPP's own ppsspp.ini. It needs two keys together, since changing SmallDisplayZoomType alone had no visible effect:

SmallDisplayZoomType = 0
DisplayStretch = True

Pico-8 is built around a fixed, square-ish 128 by 128 canvas, so there I wanted the aspect ratio preserved rather than stretched. Once installed through PortMaster, setting the display to 4:3 instead of a full stretch keeps its pixel grid clean and square the way the fantasy console was designed to look, exactly the kind of small win the Nova was supposed to deliver.

Steam and Windows Games on ARM

This is the part I am still poking at rather than something I have fully settled. The idea of running actual Windows games on an ARM chip through Steam, on a handheld this size, is the kind of thing that would have sounded absurd a few years ago, and the fact that it is even worth attempting now says something about how far compatibility layers have come. I am approaching this the same way I approach the 4:3 side of things: picking games that were built for or look right in a 4:3 or boxier aspect ratio rather than assuming every modern widescreen title will behave.

For actually finding games worth trying, the Does it 4:3 curator on Steam has been the most useful starting point. It is exactly what it sounds like, a running list of games that support or look right in a 4:3 aspect ratio, which pairs nicely with a small handheld screen and with the general philosophy behind the rest of this setup.

Closing Thoughts

None of this replaces the Steam Deck, and it was never supposed to. What the Nova gives me is a dedicated, boxy little retro machine I do not mind handing to a kid or leaving on a shelf, running an operating system I actually enjoy configuring instead of fighting. ROCKNIX support for this chipset is young: the wrong controller identity on first boot is proof of that. But the pace of what already works, from properly tuned shaders to genuinely native ARM64 game ports through PortMaster, made the early rough edges easy to forgive. If you already have a Steam Deck and are wondering whether a small ARM handheld is worth adding next to it, the answer for me was yes, mostly because of how much of this stack simply is not stretched-widescreen software at all.


GrapheneOS on a Pixel 10 Pro: Not Quite There Yet


Back in my Obtainium post I said I'd probably wait for GrapheneOS on an OEM device before making the full switch. I got impatient. I picked up a Pixel 10 Pro and flashed GrapheneOS on it. I lived with it as my daily phone for a full month with no dual-booting back to stock and no safety net beyond a second phone sitting in a drawer. Here's where I landed. It's a genuinely great piece of software running on genuinely great hardware. The battery life is bad enough that I'm putting it back down for now.

Part of why I bothered with this experiment now rather than later is that Google is already moving to make installing apps from outside the Play Store harder. They call it sideloading which I think is a fairly loaded term for something that is really just installation. The moment installing the software you choose becomes harder the device is a little less yours. Google frames the new developer verifcation requirements as optional and aimed at malware. I get the argument to a degree. There really are people like older relatives or less technical friends who click through prompts they shouldn't and end up with something malicious on their phone. But those are usually not the people going out of their way to install an app from outside the Play Store in the first place. The people this actually affects are the ones who already know exactly what they are doing. Being able to develop and install apps outside of Google's own store without asking anyone for permission has always been Android's real advantage over iOS. If that advantage quietly erodes in practice even while staying optional on paper it is worth asking whether Android is still the obvious choice and whether iOS starts looking more reasonable once Google starts pulling this kind of stunt. See this piece on what the change could undermine and Google's own support page on the new developer verification requirement for the details.

/en/blog/2026/06/20/graphene.webp

The Install

Flashing GrapheneOS onto the Pixel 10 Pro felt familiar. I've flashed enough custom ROMs over the years that the process barely needed a guide. Enable USB debugging. Unlock the bootloader. Flash the images. Relock the bootloader. GrapheneOS's own web installer walks through exactly that sequence and nothing more.

What I wasn't expecting: a warning screen on every single boot. The phone stops for a few seconds to tell me the OS has been modified from the factory image. Yes. I know. I unlocked the bootloader myself. I flashed it myself. I relocked it myself on this exact device on purpose. Come on Google. I don't need to be humiliated with the same reminder every restart.

Private Space, or a Second Profile

A small subset of apps I still needed were tied to Google in some way. The first question was where to put them. GrapheneOS ships Android's Private Space (a separate lockable app container on top of your normal profile and distinct from GrapheneOS's own user profiles) and that's where I installed the Play Store along with the handful of apps that depend on Google Play Services.

For a while I assumed my battery problems came from exactly that. Google Play Services running a second time inside its own sandbox. Waking up on its own schedule. Duplicating background work my main profile wasn't doing at all. Some people sidestep this entirely by putting Google Play in a fully separate user profile instead of Private Space (a harder boundary since a profile behaves closer to a second phone than a second folder). I tried it for a few days and gave up. Switching profiles on Android means locking one and unlocking the other and waiting for it to resume and losing any sense of a single continuous home screen. Fine for something you open twice a day. Miserable for something you're in and out of constantly. Private Space at least stays one swipe away.

I also seriously considered dropping Google Play entirely and living off Aurora Store and F-Droid-adjacent sources only. That plan mostly worked except for a small stubborn list of apps I couldn't replace.

The Apps I Can Only Get From Play

  • Chess.com: no open-source app matches their specific ecosystem or ratings or puzzles.
  • Symfonium: there might actually be a way out of this one. Substreamer looks like a solid alternative and there's an open issue tracking getting it distributed outside the Play Store (Obtainium included). For now it's still Play Store only so I'm sticking with Symfonium. I care about this one specifically because I run Navidrome as my Spotify alternative and want a client for it that isn't tied to Google Play.
  • My banking apps: the one pleasant surprise. Every bank I use ran fine on GrapheneOS without complaint. No Play Integrity refusal. No "this device is not certified" wall.

That list is short enough that Private Space alone would have been a reasonable compromise. It wasn't what pushed me back to stock.

The Actual Dealbreakers: Camera and Battery

Two things did it. Neither is really GrapheneOS's fault so much as a consequence of what GrapheneOS deliberately doesn't ship. No Google Camera. And (this is the one that surprised me) noticeably worse battery life even after accounting for the Private Space overhead above. I isolated it as best I could and closed Private Space entirely for a full day. The drain was still well past what the same phone did on stock. Whatever Pixel-specific power management Google is doing at the firmware or driver level GrapheneOS isn't getting the full benefit of it yet.

The camera app is the more personal complaint. Pixel phones without GCam simply don't take the photos people expect from a Pixel. That gap is wide enough that it's the first thing anyone notices when I hand them the phone.

For now my plan is just to keep waiting. Waiting for GrapheneOS's Pixel-side battery gap to close and separately for the promised Motorola OEM GrapheneOS device in the hope that a phone actually built around it ships with a preinstalled camera app that isn't an afterthought.

The App Stack

Minimal doesn't mean bare. Here's what's actually on the phone for anyone trying the same experiment. Consider this a snapshot of one month of testing and not a permanent verdict. Some of these could easily be swapped out again by the time you read this (Symfonium included).

  • Obtainium: still the backbone of the whole setup. There's now a much larger set of pre-made app configs than when I first wrote about it so getting started takes a fraction of the effort it used to.
  • AppVerifier: checks an APK's signature against what the developer actually published (mostly for my own peace of mind).
  • HeliBoard: keyboard, no cloud anything.
  • Brave: daily browser.
  • Lawnchair: my launcher, customizable without being bloated. ARK Launcher is a newer, even more minimal option I only came across after settling on Lawnchair. Might have used it instead if I'd found it sooner.
  • NewPipe: YouTube without the YouTube app.
  • Absorb: audiobooks.
  • AntennaPod: podcasts (Pocket Casts is worth a look too if you want something more polished).
  • Lichess: doesn't replace Chess.com but it's what I actually play on.
  • KurobaEx: for browsing 4chan.
  • Signal.
  • Nekogram: open-source Telegram client.
  • Tailscale: for reaching my home network and self-hosted services like Navidrome and Immich from outside.
  • Organic Maps: offline maps and navigation.
  • VLC: video viewer.
  • Streamyfin: open-source Jellyfin client.
  • For email I've been trying Proton Mail with Thunderbird for Android or FairEmail as the fallback if it doesn't stick.
  • Steam. It's genuinely great that Valve's own app is already available through Obtainium, no third party repackaging needed.
  • Cake Wallet: crypto.
  • Immich: photo backup.
  • Aegis: 2FA.
  • The Fossify suite: Calendar, Contacts, Documents for scanning and Messages as a full replacement for Google's stock apps.
  • DAVx5: syncs contacts (and calendars) over CardDAV/CalDAV, no Google account needed.

What I'm Still Missing

A few gaps never got closed during the month.

I never found a good NFC alternative that respects my privacy as much as that's realistically possible for a payment rail (I know, I know). The practical answer stays the same: don't bother, carry a physical card for tap to pay. I'm even considering picking up a magnetic card holder at some point just to make carrying a stack of physical cards again a bit less annoying.

I also never found a proper ebook reader with COPS or Calibre server sync that could replace Moon Reader Pro for that specific workflow. Everything I tried either lacked the sync or lacked the reading experience.

And restating the obvious: chess.com. Lichess is what I play on but it's not a substitute for the actual platform.

Closing Thoughts

None of this is really a knock against GrapheneOS. Between the Obtainium ecosystem and the app list above and the fact that my banking apps didn't even blink going Google-free is far more practical today than it was even a year ago. Privacy (and not handing your daily driver over to Google) matters. Probably more than most of the convenince it costs you. But battery life is not a convenience. It's a requirement. Losing half a day of runtime compared to stock isn't something I'm willing to trade for it right now. It's also the reason I can't hand this phone to family and call it done. A device that dies early isn't more private. It's just off.

For now the plan is the same as it was in January just more specific. Wait for the battery gap to close and keep hoping Motorola's GrapheneOS phone ships with a camera app worth using out of the box.


Adding a Font to CrossPoint Reader


CrossPoint Reader is an open-source e-reader firmware for the Xteink X4 (ESP32-C3). Fonts are baked into the firmware as C headers at compile time, so adding a new one means converting it, generating an ID, and wiring it up in a few places. This is how I added Atkinson Hyperlegible.

Atkinson Hyperlegible is a typeface from the Braille Institute, designed for low-vision readers. It has become my go-to font in KOReader as well, so porting it to CrossPoint felt like the obvious first move when I started customising the firmware.

Prerequisites

You need Python with freetype-py and fonttools, plus Ruby for the ID script:

pip install freetype-py fonttools

1. Get the Font Files

You can use any TTF or OTF files. I used the Fast Atkinson variant already installed on my system at ~/.local/share/fonts/. If you want the standard Atkinson Hyperlegible, download the TTFs from the Braille Institute and put them somewhere convenient.

2. Convert to C Headers

The converter is lib/EpdFont/scripts/fontconvert.py. Reader fonts use --2bit --compress --pnum. Run it once per size per style:

cd lib/EpdFont/scripts

FONT_DIR="$HOME/.local/share/fonts"
for size in 12 14 16 18; do
  for style in Regular Bold Italic BoldItalic; do
    name="atkinson_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
    python fontconvert.py \
      "$name" "$size" \
      "${FONT_DIR}/Fast_Atkinson_${style}.otf" \
      --2bit --compress --pnum \
      > "../builtinFonts/${name}.h"
  done
done

Adjust the font path and filenames to match whichever variant you have.

3. Add Headers to all.h

All built-in font headers are included through lib/EpdFont/builtinFonts/all.h. Add your new headers there:

#include <builtinFonts/atkinson_12_bold.h>
#include <builtinFonts/atkinson_12_bolditalic.h>
#include <builtinFonts/atkinson_12_italic.h>
#include <builtinFonts/atkinson_12_regular.h>
// ... repeat for 14, 16, 18

4. Generate Font IDs

Font IDs are SHA-256 hashes of the generated headers, computed by build-font-ids.sh. Add a block for Atkinson to that script:

echo "#define ATKINSON_12_FONT_ID ($(
ruby -rdigest -e 'puts [
  "./atkinson_12_regular.h",
  "./atkinson_12_bold.h",
  "./atkinson_12_bolditalic.h",
  "./atkinson_12_italic.h",
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
))"
# ... repeat for 14, 16, 18

Then regenerate src/fontIds.h:

bash lib/EpdFont/scripts/build-font-ids.sh > src/fontIds.h

5. Wire Up the Font Objects

In src/main.cpp, inside the #ifndef OMIT_FONTS block, declare the font objects and families:

EpdFont atkinson12RegularFont(&atkinson_12_regular);
EpdFont atkinson12BoldFont(&atkinson_12_bold);
EpdFont atkinson12ItalicFont(&atkinson_12_italic);
EpdFont atkinson12BoldItalicFont(&atkinson_12_bolditalic);
EpdFontFamily atkinson12FontFamily(&atkinson12RegularFont, &atkinson12BoldFont,
                                   &atkinson12ItalicFont, &atkinson12BoldItalicFont);
// ... repeat for 14, 16, 18

Then register them with the renderer in setupDisplayAndFonts(), still inside #ifndef OMIT_FONTS:

renderer.insertFont(ATKINSON_12_FONT_ID, atkinson12FontFamily);
renderer.insertFont(ATKINSON_14_FONT_ID, atkinson14FontFamily);
renderer.insertFont(ATKINSON_16_FONT_ID, atkinson16FontFamily);
renderer.insertFont(ATKINSON_18_FONT_ID, atkinson18FontFamily);

6. Add to Settings

In src/CrossPointSettings.h, extend the enum:

enum FONT_FAMILY { NOTOSERIF = 0, NOTOSANS = 1, OPENDYSLEXIC = 2, ATKINSON = 3, FONT_FAMILY_COUNT };

In src/CrossPointSettings.cpp, add an ATKINSON case to both switch statements, getReaderFontId() and getReaderLineCompression():

// getReaderFontId()
case ATKINSON:
  switch (fontSize) {
    case SMALL:        return ATKINSON_12_FONT_ID;
    case MEDIUM:
    default:           return ATKINSON_14_FONT_ID;
    case LARGE:        return ATKINSON_16_FONT_ID;
    case EXTRA_LARGE:  return ATKINSON_18_FONT_ID;
  }

// getReaderLineCompression()
case ATKINSON:
  switch (lineSpacing) {
    case TIGHT:   return 0.90f;
    case NORMAL:
    default:      return 0.95f;
    case WIDE:    return 1.0f;
  }

In src/SettingsList.h, add STR_ATKINSON to the fontFamily options list:

SettingInfo::Enum(StrId::STR_FONT_FAMILY, &CrossPointSettings::fontFamily,
                  {StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC, StrId::STR_ATKINSON},
                  "fontFamily", StrId::STR_CAT_READER),

7. Add the Translation String

In lib/I18n/translations/english.yaml:

STR_ATKINSON: "Atkinson Hyperlegible"

Then regenerate the I18n headers:

python scripts/gen_i18n.py lib/I18n/translations lib/I18n/

Build and Flash

pio run --target upload

The font should now appear as "Atkinson Hyperlegible" in the reader's font selection menu. If you ever change any of the generated header files, re-run the ID script and rebuild. Otherwise the cached layout files in .crosspoint/ on the SD card will have mismatched IDs and the cache will need to be cleared.

Update: 1.3.0 Makes This Much Easier

With the 1.3.0 release, adding fonts to CrossPoint got a lot more straightforward. The project now ships with more predefined font sources, so you may not need to go hunting for and converting TTFs yourself. Even better, there is now a Font Builder tool on the CrossPoint site that lets you add fonts directly through the browser without touching the command line.


NFS Most Wanted (2005) on my Steam Deck


The Problem

I added Need for Speed: Most Wanted (2005) to Steam as a non-Steam game on my Deck. The repack included a _SteamDeckFix folder and instructions to set WINEDLLOVERRIDES"dinput8,d3dx9_26,d3dx9_43=n,b" %command%= as a launch option. I did all that, but the game refused to start.

The Fix

Protontricks. Install it via Flatpak if you have not already:

flatpak install flathub com.github.Matoking.protontricks

Find your game's App ID and install the d3dx9 verb:

flatpak run com.github.Matoking.protontricks -l | grep -i "most wanted"
flatpak run com.github.Matoking.protontricks 2910532096 d3dx9

This installs all d3dx9 DLLs (versions 24 through 43) as native overrides in the game's Wine prefix. Ignore the wall of warnings, that is normal. After this, the game launched fine with no WINEDLLOVERRIDES needed.

If you are fighting with older DX9 games on the Deck, skip the launch option approach and go straight to Protontricks.