nx-hbmenu
Nothing special. Just a homebrew menu for the Nintendo Switch.
* with a UE5-class depth-of-field and motion-blur 3D deferred rendering pipeline
† and a photo mode out of Ghost of Tsushima
Apps sit left to right by when you last opened them. Open something an hour ago and it is under your thumb; leave something since spring and it is a long way right, small and out of focus.
Fifteen shaders draw that, on a chip from 2015. Not one of them is a compute shader, and §4 is the story of why not.
It comes with 4IFIR and updates from the console, so most people never need this page. On its own it is one file, hbmenu.nro, into the root of the SD card, over whatever hbmenu is there. Any setup, whether or not it has ever heard of the rest.
There is one row and one order, and the order is time. Nothing is layered on top of it: no folders, no tags, no categories, no search box in the strip.
Six places to be: pinned, today, this week, this month, earlier, never. Which of the six a tile lands in is worked out fresh every time the list is read, out of one date. Nothing on your card ever says “this one is a Today app”.
Nine lines decide it.
// source/data/bucket.c:3-11
LatitudeBucket bucket_of(const AppRecord *a, uint64_t now) {
if (a->starred) return LAT_PINNED;
if (a->atime == 0) return LAT_UNTOUCHED;
uint64_t dt = (now > a->atime) ? (now - a->atime) : 0;
if (dt < SECS_PER_DAY) return LAT_TODAY;
if (dt < SECS_PER_DAY * 7) return LAT_THIS_WEEK;
if (dt < SECS_PER_DAY * 30) return LAT_THIS_MONTH;
return LAT_EARLIER;
}
Set the console's clock back a day and, without one careful line in there, every app would leap to the far right at once, as though nothing had been opened in a few hundred billion years. That line lands them in Today instead. Still wrong, and wrong in a way you can read.
| Bucket | Predicate | Seconds | Note |
|---|---|---|---|
| Pinned | starred | — | off the axis, always leftmost |
| Today | dt < 1 day | 86 400 | |
| This week | dt < 7 days | 604 800 | |
| This month | dt < 30 days | 2 592 000 | |
| Earlier | otherwise | — | the axis has no right edge |
| Untouched | atime == 0 | — | never launched under this menu |
A launches, straight into the handoff. X pins. Y deletes, and for four seconds X takes it back. − filters, + opens the menu, B is HOME immediately — there is no exit animation, deliberately.
The filter brings its own keyboard. The system one crashes the console when a homebrew menu dismisses it, and it took instrumented logging to prove that. What replaced it is a twenty-eight-cell alphabetical ribbon, which asks the console for nothing and works however the menu was started.
The date all of this turns on is the menu's own, written the moment you launch. A memory card keeps its own record of when a file was last touched, and it cannot be trusted. An axis built on that would reshuffle itself for reasons you could never see.
It lives in one small file on the card, and it is written on three occasions: when you launch something, when you pin something, and on the way out. Never when you move the cursor. Cards wear out.
When it does write, the new file goes down beside the old one and is only swapped in once it is whole. Pull the power out halfway through and the library is still there. The swap is the part that saves you, not the flush.
Each app keeps its own history. Fourteen days of it, one bit a day, four bytes for the whole thing. Launch something and today's bit goes on. A day rolls over, the word shifts along, and whatever falls past the fourteenth day is gone.
// source/common.h:87-96
static inline void pulse_age(uint32_t *bits, uint32_t today, uint32_t last_known_day) {
if (last_known_day == 0) return;
if (today <= last_known_day) return;
uint32_t shift = today - last_known_day;
*bits = (shift >= 14) ? 0u : ((*bits) << shift) & 0x3FFFu;
}
static inline void pulse_mark_today(uint32_t *bits) {
*bits |= 0x1u;
}
Those four bytes are the dots under the tile and the sparkline in the panel above it. The mask is easy to get wrong, which is why it is checked on a desk before it is ever checked on a console.
It runs the same order, the same six places and the same buttons, at the same size. It is the interaction and not the renderer: flat web, no depth, no lens. Where it and the console disagree about how something looks, the console is right.
Two of the buttons under it open states the shipping build cannot reach. The catalog screen has no way in on the device, and the install queue is a client for a piece that is not in this repository. §7 is the list.
One bundle, fonts inlined, nothing fetched while it runs; it opens from file:// out of a plain checkout. Rebuild it with node tools/build_mock.mjs.
Nothing about a menu needs a lens. This one has one anyway.
The row of tiles is not a picture. It is a room with tiles standing in it, and what you see is a camera pointed at that room. The tile under the cursor stands on the focal plane. The others step back as they get older, and the lens softens each one by how far back it stands. What is far away looks far away.
The ladder is flat near the cursor and steep past it. The nearest tiles stay sharp enough to read, and everything from five out sits at one far depth, softened by the same amount, so the end of the library reads as texture rather than as a list. The straight ladder it replaced had spent nearly three pixels of softness on the very first neighbour.
The lens is never told what that curve was. It asks one thing of each pixel: how far is this from the place that is in focus. That distance is the whole instruction.
The sharp place does not travel with the selection. The cursor is fixed on the screen, and the library walks through a blur field that stands still.
To soften one pixel the shader looks at thirty-six places around it. They sit in three rings, turned against one another by the golden angle — the angle that keeps sunflower seeds from lining up. What is behind is gathered apart from what is in front, so a sharp tile never bleeds into the soft one behind it.
Where the blur is small, or the frame is moving fast, twenty places are enough. Most of any frame is barely out of focus, and on a disc that narrow thirty-six is more looking than the answer needs. The parts already smeared past recognition are given the same twenty.
The loop that reads those places has to be written so the compiler can count it in advance. Written the other way, the chip moved the table of places out of its fast registers and into memory, and the menu ran at one frame a second. Nothing errored and nothing warned. There is a comment above that loop now, addressed to whoever touches it next:
// source/shaders/lens_gather_fsh.glsl:148-150
// BOTH loops keep COMPILE-TIME trip counts: a runtime bound stops the
// unroll and nv50 demotes TAPS[] to per-thread local memory — the
// gather collapses to seconds-per-frame (b99 fps=1 incident).
Every fragment writes its own depth: surfaces, glyph edges, the antialiasing on a rounded corner. An earlier build let the small things inherit the depth of the card they sat on, and a letter hanging over the edge of a soft tile stayed razor-sharp against the background. Tiles that were supposed to be out of focus kept their edges, and nothing done later in the blur could take them away.
docs/history/rewrite-plan-2026-07-06.md:118-123
That is why the renderer is built the way it is. Softness of this kind cannot be painted over the strip afterwards; every pixel has to arrive already carrying how far away it stood. Bands cut across the screen, rectangles scissored around the sharp part, a list of which tiles to blur handed up from the processor — all tried, all named and retired.
The build before this one failed on both counts at once. It looked worse than it should and it ran slower than it should, and on review it was scrapped for the two together. That is what commissioned the rebuild.
The lens before this one was built for a blur it never had to make. It ground each frame down through four sizes so every ring of the pattern could pick a matching one, which is what you do to open a disc thirty or a hundred pixels wide. At 720p this one never opens one past fourteen. The old one was paying, every frame, for machinery it could not use, across seven passes, and a good part of what it cost went on starting and stopping those passes before a single useful sample.
Fourteen pixels is the ceiling at 720p, and it scales with the picture, so docked it goes up by half. The light theme pulls it back in, because a disc that reads as depth on black reads as a printing fault on white.
Because it is a scene and not a picture, you can walk around inside it. Photo mode hands you the camera. The right stick orbits the library while it is still running; the triggers reach for a wider or a longer lens. A rail down the side holds focus, aperture and zoom, then six ways to grade the picture, and six film looks on the end — the first of them being none at all. The aperture opens further here than the menu ever asks for on its own. Then it writes the picture to the card with its own controls taken back out of the frame.
Before a change to the blur goes near a console, a script on a desktop works out what a real round aperture would have done to the same picture. It cuts the scene into layers by depth, spreads each layer by the disc that depth earns, and stacks them back up from the far one to the near one. Beside it the shader's own formula answers the same question, line for line. Then the two are compared.
It asks four things. Only the first sweeps an area of the picture — everything standing on the focal plane. The other three read a single line across it and measure how far the two answers drift apart along that line, so whatever went wrong off the line is never seen.
| Gate | The question | Allowed | Recorded |
|---|---|---|---|
| G1 | Does what sits on the focal plane come through untouched, with nothing near it spilling on? | ≤ 1e−4 | 0.00000 |
| G2 | Does a near tile melt over the page the way glass does? | ≤ 0.06 | 0.018 |
| G3 | Can the focus rack past a sharp edge without eating into it? | ≤ 2 px | 0 px |
| G4 | Does the far field carry the right amount of light? | ≤ 0.15 | 0.00 |
Recorded 2026-07-05 on both test scenes, and re-run 2026-08-12 against the shipped script, where all four came back to the digit. python tools/lens2_sandbox.py. The written contract asks for a slightly tighter figure on the second than the gate that runs does; the one that runs is the one that decides.
That check runs before anything is merged. It checks the maths and not the shader that ships, so a change made only in the shader sails straight through. Catching that one still takes a console.
The lens signs its answer. A pixel in front of the focal plane and a pixel behind it get discs of opposite sign, and by the ladder every tile in the ribbon is behind. In the one frame that was captured, every blurred pixel leans the same way, and it is the wrong way. Either that frame caught the focus mid-rack, or the ribbon is not standing where the ladder says it is. One frame, one number, open. Nothing in this repository holds that frame, so the question cannot be reopened from here.
Every stage checks that it has what it needs. One that is missing something is skipped and the frame carries on. An effect can go missing; the frame does not. Under all of it is a flat path with no depth at all, and under that a plain error page. Neither is a black screen.
The renderer can produce the real ones. A flag file on the card turns on a self-shot instrument, raw buffers are written out on numbered frames, and tools/decode_probes.py reads them back on a desk. That path is in the tree. The figure above is not its output.
One check against a real one did not come out clean, and it is the check on the coarse grid the figure draws. A small part of that grid matches neither of the two answers the shader is supposed to be able to give. The drawing shows the tidy version, because the tidy version is what the shader says it does; this paragraph is what the console said back. That capture was not kept either.
The chip is from 2015, and everything in it queues for the same memory: the graphics, the processor, the rest of the console. Which makes the interesting question not whether it is fast enough, but what the thing is physically willing to do. Stock, handheld, it runs its graphics at 307 MHz. The frame here is written against 460, which the menu asks 4IFIR for at boot rather than taking what it is given. It runs out of room to move data long before it runs out of arithmetic, and that decided most of what follows.
Five stages of the frame were timed on a console, once, in July. The stopwatch was wrong. It divided by the wrong number, so every figure on record reads low, by more than a third. The reader was fixed in August, five weeks after the capture. Nobody has timed the frame again since.
The corrected arithmetic settled something that had never made sense. The commit that halved the motion blur says it dropped frames, and the counter that said so was on a different clock and was never wrong. At the old scale the two records disagreed: the timings said the frame fitted and the console said it did not. At the true scale they agree. The console was right.
Two files disagree about which stage one of those five numbers belongs to, and nothing in the tree settles it.
Overclock the console and the menu starts building 1080p frames on its own. It asks 4IFIR what the GPU clock is doing and follows that, not the dock. It changes its mind slowly on purpose, because rebuilding for a new size shows as a hitch — quick to notice you have gone up, reluctant to accept you have come back down. A console already overclocked when the menu starts gets the right size first time and never rebuilds.
Leave it alone for a minute and it settles to thirty frames a second. Nothing is moving. Touch anything and it comes straight back.
Four things that were built, looked at, and taken back out.
A depth buffer you mean to look at again has to be asked for the slow way. Ask for the fast one and it hands back the value it was cleared to, at every pixel, for ever. Nothing errors. Nothing warns. The blur quietly leaves the whole screen, and the only sign of it is that the picture is wrong: the depth of field is all but gone. Colour is fine kept that way. Depth you intend to read is not.
source/gfx/gfx.c:553-563 · docs/tegra-x1.md:7-23
Compute shaders were tried. On this chip, telling one of them to wait for the one before it means stopping the entire graphics pipe and waiting for it to go quiet, and a frame wanted six of those in a row. No cleverness gets around it. There are fifteen shaders here and not one of them is compute. The hardware decided that, not the author.
A modern upscaler had been built on top of them and went out with them. Its slot is still in the graph, empty. What outlived it in the configuration file is one dead key and two dead values of a live one. All three are read, noted in the log and ignored, so an old file cannot break the menu. The note on the dead key almost never gets written: it has to be spelled out by hand, and every other route leaves it at zero, which is nothing to report.
The port those measurements came off is not in this repository, and the figures stand as they were written down at the time.
docs/tegra-x1.md:44-64 · docs/tegra-x1.md:74-81 · source/data/settings.c:93-107 · source/gfx/gfx.c:691-695
Turning the gather pattern independently at every pixel is the textbook way to trade banding for noise, and on a part this short of memory it also throws away the one thing that makes those reads cheap: neighbours reaching for the same places. There was a good argument against it here. That argument won on paper and lost on the panel. Taking it out was the first change of that series and cost more than any of the others, and it went back the way it had been.
So the shipped pattern turns twice: once for every pixel, and once for every frame, stepped around by the golden angle over a sixty-four frame cycle.
source/shaders/lens_gather_fsh.glsl:136-143 · docs/tegra-x1.md:145-157
It lasted one build. It reads as upscaler mud rather than as motion, and things that are standing perfectly still going soft is worse than leaving them alone. Motion blur may smear things that are actually moving. Nothing else.
source/ui/ui.c:2139-2141
Open the menu and the tiles are dealt in. They come from behind the page and travel out to where they will stand, each one starting a moment after the one before it, from the cursor outwards, while the rows of the panel above arrive a little apart from each other. Which of the three openings you get is decided at boot, and it is over inside a second.
None of that has ever been filmed. The self-shot instrument in the renderer writes single frames and nothing else, and the first one it takes lands in the middle of the deal — a decent still, and no kind of video.
The first attempt at motion blur smeared every pixel along its own direction. Orbit the camera and the whole picture came apart. It read as damage, not as movement, and it was taken straight back out.
What ships smears things that are actually moving, and nothing else. The base pass leaves behind one arrow a tile, pointing back to where that tile was a frame ago, and the blur follows those. The shutter is exactly one frame long, the way a camera's is, and anything travelling less than half a pixel is left alone.
That half-pixel rule is also a guard. Ask the arithmetic how fast a still pixel is moving against its still neighbour, and it divides nothing by nothing and hands back a value that is not a number. One of those in the sum and the pixel is gone. A pixel standing perfectly still can poison every pixel around it.
Then it was measured, and the first version of it cost too much. The blur was taking whole milliseconds while the strip moved, and the frame was dropping. Fifteen taps became eleven, and each tap stopped asking the velocity buffer for its own reading and took the neighbourhood's instead.
Those milliseconds came off the broken stopwatch too, and corrected they are larger. The dropped frame was counted on a different clock and was never in doubt.
The plan this renderer was built from asks the temporal pass for a jitter it never grew; there is no such term anywhere in the shader. What ships holds on to the last frame, lets go of it as the scene picks up speed, and switches itself off entirely once nothing is moving. The plan is kept, and it carries its own list of the places it stopped being true. That is one of them.
| What | Value | Where |
|---|---|---|
| Version | 3.6.1 | one string, set once in the build, so the status bar cannot disagree with the file |
| Shipped binary | 2 360 960 B | hbmenu.nro, one file, no installer |
| Shaders | 15 | GLSL in source/shaders/, 50 432 B of them compiled into the binary |
| Compute shaders | 0 | see §4 |
| Host tests | 138 | the parts that can be checked without a console |
| First-party source | 15 796 lines | across 64 files; vendored code is not counted |
| Inherited from upstream | ~48 lines | all of it in the routine that hands control to the program you launch, counted file by file in PROVENANCE.md |
| Internal resolution | 1280×720 1920×1080 | the layout is literal at 720p, not a 1080p design shrunk down. Overclock the GPU and the menu starts building 1080p frames on its own, following the clock rather than the dock |
| Present resolution | 1080p above 700 MHz | follows the GPU clock, not the dock, so an overclocked handheld draws more picture than the panel can show and gets a cleaner one back |
| Widest blur | 14 px @ 720p | ×1.5 docked and pulled in on the light theme, all in one expression. Photo mode replaces that expression rather than joining it, with whatever the aperture rail is holding |
| Gather | 36 places / 20 sparse | three rings of 8, 12 and 16, each turned against the next by the golden angle |
| Tile | 16×16 px | 22 of its 256 pixels read: a lattice, four edges and two corners |
| Motion blur | 11 taps | was fifteen |
One note under that table, on the resolution rule. Changing the size of the picture means tearing down everything the frame is built out of and building it again, and that shows. The clock the menu is reading moves continuously, so a single threshold would flap. It goes up at one figure and comes back down at a lower one. Going up it waits a second and a half to be sure; coming down it waits eight. The lower figure sits underneath every clock step that clusters near the line, so a console parked just above cannot oscillate across both. The very first reading is taken at face value, which is how a console that was already overclocked at boot builds the right frame once and never rebuilds.
This is a preview. Here is what is not done.