nx-hbmenu Latitude

nx-hbmenu

Latitude

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

loading the stage…

live — press ←→

←→ move x pin y delete − filter + menu full screen

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.

A drawing of the Latitude library screen: a sharp selected tile with a coral ring, flanked by tiles that step back and soften with distance, above a status bar and an A/B/X/Y action bar, with a panel holding the selected app's path, dates and launch count. A drawing of the Latitude library screen: a sharp selected tile with a coral ring, flanked by tiles that step back and soften with distance, above a status bar and an A/B/X/Y action bar, with a panel holding the selected app's path, dates and launch count.
The library screen, drawn. The tile under the cursor stands on the focal plane; the ones past it step back and soften as they go. Watch the cursor: it stays where it is, and the strip slides underneath. The stage above is the same interaction, live, in flat web — this figure adds the one thing flat web cannot show. A drawing, made from the shipped layout and the shipped ladder. The softness is drawn to them, and nothing in it came off a console.

1One axis

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.

BucketPredicateSecondsNote
Pinnedstarred—off the axis, always leftmost
Todaydt < 1 day86 400
This weekdt < 7 days604 800
This monthdt < 30 days2 592 000
Earlierotherwise—the axis has no right edge
Untouchedatime == 0—never launched under this menu
Real elapsed time against screen position in the Latitude strip Two parallel rulers. TIME SINCE LAST OPENED · LOG SCALE TODAY WEEK MONTH EARLIER 1 h 1 d 1 w 1 mo 1 y PINNED no time UNTOUCHED SCREEN POSITION · 180 px PER APP, ALWAYS rank, not distance four apps opened within a day get four full tiles; a five-month gap gets one
Real time against screen position. The gaps are wildly uneven: an hour, a week, half a year. The strip flattens them, and every app takes the same width of screen whatever the real gap. The order tells the truth; the spacing is a courtesy. Pinned apps come off the axis altogether.

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.

Fourteen days, four bytes

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.

The fourteen-cell shift register: a day rollover shifts the whole word left by one, the mask drops whatever passes the fourteenth day, and a launch sets bit zero at the right. The fourteen-cell shift register: a day rollover shifts the whole word left by one, the mask drops whatever passes the fourteenth day, and a launch sets bit zero at the right.
The register ageing one day at a time. Today is the cell on the right; a rollover shifts the whole word left, and the mask drops whatever passes the fourteenth day.

The stage at the top of this page

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.

2Why a menu has a lens

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.

Focus racking along the strip. The card under the cursor sits on the focal plane; every other card carries the disc its own depth earns, and the dot below each one tracks that depth down the ladder.
The ribbon's depth plotted against distance from the cursor: a quartic well that is flat for the three tiles nearest the focal page and steep beyond them, against a linear ladder for comparison. The ribbon's depth plotted against distance from the cursor: a quartic well that is flat for the three tiles nearest the focal page and steep beyond them, against a linear ladder for comparison.
The ladder the tiles stand on. The focal plane is the floor and the cursor's tile sits on it; the dashed line is the straight ladder this one replaced, run between the same two ends. Plotted from the shipped curve.

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.

Two drawings of the same row of homebrew tiles. In the upper one every tile is equally sharp. In the lower one the tile under the cursor is sharp and the tiles soften with distance in both directions, the outermost pair melting into the edges, with the blur in pixels printed under each place. Below both, the gather kernel: thirty-six sample points in three rings inside one disc, drawn beside the same disc at true size. Two drawings of the same row of homebrew tiles. In the upper one every tile is equally sharp. In the lower one the tile under the cursor is sharp and the tiles soften with distance in both directions, the outermost pair melting into the edges, with the blur in pixels printed under each place. Below both, the gather kernel: thirty-six sample points in three rings inside one disc, drawn beside the same disc at true size.
Two rows, one difference. Above, the strip with no depth in it at all: a selection ring, and every tile equally sharp behind it. Below, the same strip on the ladder, softening by the same amount in both directions, with the number under each place saying how many pixels. Watch where the sharp place is. It does not travel with the selection, which is why those numbers never move. Underneath, the disc a far point opens into at true size, and beside it the thirty-six places the shader reads that disc, at eight times. A drawing, made from the shipped ladder and the shipped tap table. The softness is drawn to them, not rendered.

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.

Depth-of-field chain: resolution, kernel and signed CoC Three stacked figures. Depth of field — pass by pass each pass runs at a different resolution full res 1280 × 720 half res 640 × 360 · 1/4 the pixels tile res 80 × 45 · 1/256 the pixels scene colour + depth every fragment writes z base pass MRT colour + signed CoC half-res downsample lens_prefilter_fsh tile stats, 16 x 16 max |CoC|, max velocity lens_tiles_fsh dilated tile stats 3 x 3 neighbourhood max lens_dilate_fsh bokeh, defocused 36 taps, or 20 when sparse lens_gather_fsh final frame bilateral, guided by full-res z lens_recombine_fsh colour + CoC at half res radius + tap budget sharp full-res colour + depth, carried past the gather runs on 1/4 of the pixels; only recombine touches full res The gather kernel, real tap positions 36 taps · 8 / 12 / 16 radii 1/3, 2/3, 1 · ring phase turned by the golden angle 20 taps · 8 / 12, scaled 1.5× rings 1+2 only, ring 2 now reaches the full radius — hollow interior orange = the 20 taps that survive; grey = ring 3, dropped drops to sparse when R < 2px |vel| > 24 R > 6px Signed CoC against depth — the near side grows faster +14 -14 -480 +384 focal plane · z = 0 · CoC 0 · sharp near side × 1.25 — saturates at +384, not 480 far field · z negative toward the camera · z positive CoC max 14 px at 720p · dof_range 480 · every fragment writes depth
The chain, and the pattern it gathers with. The lens works at half size, over a coarse grid that records how much blur each patch of screen is about to want, and comes back up against the sharp frame at the end. The pattern is thirty-six points in three rings of eight, twelve and sixteen, each ring turned against the next by the golden angle.

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.

Walking around inside it

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.

The check that runs before the console

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.

GateThe questionAllowedRecorded
G1Does what sits on the focal plane come through untouched, with nothing near it spilling on?≤ 1e−40.00000
G2Does a near tile melt over the page the way glass does?≤ 0.060.018
G3Can the focus rack past a sharp edge without eating into it?≤ 2 px0 px
G4Does the far field carry the right amount of light?≤ 0.150.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.

One sign that does not check out

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.

3The frame

The per-frame pass chain: eight stages from the multiple-render-target base pass through the lens front, bloom, lens back, temporal resolve, motion blur and the two present passes, with the read and write edges between them and which stages are gated. The per-frame pass chain: eight stages from the multiple-render-target base pass through the lens front, bloom, lens back, temporal resolve, motion blur and the two present passes, with the read and write edges between them and which stages are gated.
Eight stages, what each one writes, and which reads feed the next. The buffers run along the top, and every stage carries the name of the stopwatch bracketing it. A drawing of the chain as the code builds it: nothing on it is measured, and no stage is drawn any larger for costing more.

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.

One drawn frame of the tile ribbon, shown twice: as the finished picture, and cut into five vertical bands — scene colour, velocity, depth, circle of confusion and the bloom bright pass — above the coarse sixteen-pixel grid that the depth of field and the motion blur both read. One drawn frame of the tile ribbon, shown twice: as the finished picture, and cut into five vertical bands — scene colour, velocity, depth, circle of confusion and the bloom bright pass — above the coarse sixteen-pixel grid that the depth of field and the motion blur both read.
One frame, read five ways. Colour is what reaches the screen. Velocity is one arrow a tile, pointing back to where that tile was a frame ago. It reads zero everywhere until something moves, which is why the drawing steps the strip along: standing still, that band is empty. Depth is one flat value a tile. The circle of confusion is the disc that depth earns, drawn at true size beside the ceiling it never passes. The last band is whatever was bright enough to bloom. On the light theme that would be the page rather than the type, which is exactly why the glow fades out to nothing as the theme brightens. Under all five runs the coarse grid that the lens and the blur both read. Drawn from the shipped formats and the shipped ladder, like the rest of the figures here.

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.

What it is running on

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.

The stopwatch was broken

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.

What it does while you are not looking

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.

4What the hardware refused

Four things that were built, looked at, and taken back out.

Killed — device-verified 2026-06-10

Depth kept the cheap way

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

Killed — measured 2026-06

Compute shaders, and the upscaler behind them

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

Killed in b107 — and put back

Turning the pattern for every pixel

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

Killed — b101, owner verdict

Softening the whole frame while the strip moved

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

5Motion

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.

6Numbers

WhatValueWhere
Version3.6.1one string, set once in the build, so the status bar cannot disagree with the file
Shipped binary2 360 960 Bhbmenu.nro, one file, no installer
Shaders15GLSL in source/shaders/, 50 432 B of them compiled into the binary
Compute shaders0see §4
Host tests138the parts that can be checked without a console
First-party source15 796 linesacross 64 files; vendored code is not counted
Inherited from upstream~48 linesall of it in the routine that hands control to the program you launch, counted file by file in PROVENANCE.md
Internal resolution1280×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 resolution1080p above 700 MHzfollows 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 blur14 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
Gather36 places / 20 sparsethree rings of 8, 12 and 16, each turned against the next by the golden angle
Tile16×16 px22 of its 256 pixels read: a lattice, four edges and two corners
Motion blur11 tapswas 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.

7Not done

This is a preview. Here is what is not done.