Skip to content

Nintendo Switch support, a render thread, and an interface for other renderers - #25

Closed
columna1 wants to merge 67 commits into
TheMoonPeople:mainfrom
columna1:switch-port
Closed

columna1 wants to merge 67 commits into
TheMoonPeople:mainfrom
columna1:switch-port

Conversation

@columna1

@columna1 columna1 commented Oct 19, 2026 •

Copy link
Copy Markdown

I spent some time vibe coding a nintendo-switch port. This is a huge PR and may be a burden to maintain so feel free to let this PR rot but I figured I'd PR in case anyone is interested.

This is the engine half of Chronicle's Nintendo Switch port (homebrew, NVK on the Tegra X1). It adds a Horizon platform layer, renderer changes that the Switch's GPU and driver need, recording on one thread while another renders, and a few small interfaces so that a renderer other than the Vulkan one (Chronicle's port/gl) can sit behind the gfx API.

Without __SWITCH__ / NINTENDO_SWITCH, behaviour is unchanged apart from the shared changes listed below.

Chronicle side: columna1/Chronicle-nintendo-switch switch-port (separate PR).

Platform (Horizon)

  • File commits on Horizon. It cannot open a directory as a file, so there is no directory sync, and its rename never replaces a file. A replacing commit therefore moves the old file aside first, removing it once the new one is in place.
  • libnx's absolute device paths (sdmc:/…) accepted as absolute.
  • The pipeline cache written with FilesWrite, so it is committed like any other save file.

Renderer

  • draw.frag without per-draw branches. NVK's compiler (NAK) puts a sync at each uniform branch, and on the Tegra X1 a fragment costs about its instruction count. Per-draw choices are now values the shader selects between.
  • RendererConfig::expand_palettes. Palette textures are expanded to RGBA on the CPU and filtered by the sampler, instead of being looked up four times a pixel.
  • DrawState::premultiplied, so an effect can composite in one draw where the GS needed two.
  • Frames in flight: 4 on the Switch, 3 on Android and 2 elsewhere. A tick submits twice, so two slots kept only one tick in flight and the CPU waited for the GPU.
  • Depth queries answered when their frame is done (kAsyncDepthQueries, on for the Switch). The renderer no longer waits for the whole frame on the GPU; the answer arrives a tick or two later.
  • RendererConfig::target_scale. This keeps the old meaning of render_scale: render targets' pixels per logical texel. The Android merge turned render_scale into a multiplier on the main targets' size, so a caller that set it to 1.5 got 1920x1080 main targets. target_scale defaults to 0, the old rounding of window height over logical height. render_scale still multiplies it.
  • KZ_PORT_INTERPOLATION_TINT (CMake option). It builds the smooth-motion debugging tint into draw.frag. It is on by default except for NINTENDO_SWITCH, where the tint's selects, which run for every pixel of every draw, made a busy town's frames about 3 ms slower.
  • DrawState::depth_coverage. A 2D draw that reads the main depth buffer instead of testing it: its colour and alpha are scaled by the fraction of up to 8 given depths at or nearer than the depth at each pixel. It draws in one pass what a stack of depth-tested additive draws would. Chronicle's heat haze uses it for the 8 blended full-screen passes it drew per image; in Muska Lacka on the Switch that took the haze from 13 ms of GPU a frame to about 3, and the town from about 40 fps to 60. The pass leaves the depth buffer unattached, samples it in a read-only layout, and puts it back afterwards. It's one extra pipeline, built only where the device can sample its depth format (RendererFeatures::depth_coverage). Two texture slots are now reserved for the depth buffers, so kFirstUserSlot is 5.

Threads

One thread records while another renders (db656fe; gfx README, "Threads"):

  • recording state, frame markers and the published main size are atomics;
  • the renderer lock is recursive, with LockUnlessRecording() for calls that are recorded rather than run;
  • the texture and mesh tables are reserved up front so lookups never see a reallocation;
  • a texture or mesh doomed during a recording stays visible to that recording (RecordingList()) and to replays on their own thread (ReplayingHere()).

ThreadSanitizer is clean over the game's test scenes, and pictures match single-threaded rendering exactly.

For other renderers

  • WindowConfig::opengl: an OpenGL window, for a GL renderer.
  • CanRecordBinding checks through GetTextureInfo, which every renderer has, instead of LookupTexture, which returns the Vulkan renderer's own records.

Also on this branch

  • Upstream merge: a merge of Chronicle's current Kazarov pin, 611182d from the Android fork, which brings the src/cpp layout and the Android platform.
  • Cherry-pick: dee2dbc, the TextureDesc logical size change (gfx: TextureDesc logical size, for a texture with more pixels than th… #7). Chronicle's texture overrides need it, and 611182d does not contain it.
  • Reverted experiment: efbd047 (a shadow volume's two passes share their vertices) is reverted by 6c7281e. The sharing clashed with the new interpolation code, which reads every immediate mesh's own vertices. They can be squashed away.

The branch merges rather than rebases. I can squash or rebase it if you prefer.

Testing

  • Chronicle's PC test suite: 511 of 516 pass. The 5 failures are the same ones Chronicle's master fails on its own.
  • Played on Switch hardware with Chronicle's switch-port. Norune holds 60 fps handheld at 1280x720.
  • Chronicle's GL renderer builds against these headers, and its pictures match the Vulkan renderer's.

anxiousintrovert and others added 30 commits October 14, 2026 18:08
Interpolated() re-based every prog0 draw under a moving camera with two
4x4 inversions and copied the 2 KB block three times, even for unskinned
draws. Consecutive draws under the same transforms now share the cached
eye transforms, bone blending runs only for skinned blocks, overrides are
built in place, and the world-to-clip matrix is computed once per camera.
Display frames stay bit-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Horizon opens no directory as a file, so SyncDirectory failed every save; it
has no directory sync, and commits a name when the call that made it returns.
Its rename never replaces: that is the exclusive rename, and a replacing
commit moves the old file aside first, removing it once the new one has its
name, so an interruption leaves one of the two under a name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the Switch every path starts with its device (sdmc:/switch/...), which
std::filesystem over newlib reads as a relative first element, so fs::absolute
put the working directory in front of the save directory a second time and
the game stopped at startup: cannot create the save directory
sdmc:/switch/chronicle/sdmc:/switch/chronicle/save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
std::filesystem::rename never replaces a file on Horizon, so from the second
start on the Switch the cache was never saved (cannot write the pipeline cache:
File exists). FilesWrite moves it into place on every system.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Skinned prog0 draws carry their 32-bone Prog0Palette as a separate record in the
display list, bound from storage-buffer arrays with indices in push constants, so
plain and prog0 draws share one pipeline layout. Transient chunks prefer
device-local host-visible memory, display frames copy a grab twin only when the
re-grab does not cover it, and the renderer can be instrumented with Tracy zones
through the KZ_PORT_TRACY option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Device work can now run on a worker thread. RenderThreadMode::Off keeps
the single-threaded path and stays the default. Lockstep runs the same
work on the worker while the caller waits, and is byte-identical to Off.
Async lets the worker interpolate, replay and present display frames
while the game records the next tick; the game takes a render lease once
per tick around the canonical render and other per-tick device work.

Headless and screenshot runs always use Off. Tracy zones cover both
threads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tick submits twice, so two slots kept one tick in flight and the CPU
waited for the GPU's last tick before recording the next. On the Switch's
GPU that serialised the two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NAK turns a uniform branch into ssy/bra/sync and nops, and on the
Switch's GPU a fragment costs about its instruction count. The push
constants now carry the fog colour, blend factor, transform and target
choices as values to select between, and the alpha test as a bit per
less, equal and greater. Untextured draws go from 95 instructions to 43,
alpha-tested ones from 132 to 71, palette ones from 302 to about 180.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An index texture drawn with a palette is expanded on the CPU into an
RGBA texture the sampler filters, and expanded again when either is
uploaded to, instead of the shader looking the palette up four times a
pixel. An image written other than by an upload, a pair that changes
every few frames and a pair already drawn this frame keep the lookup;
expansions past a megatexel in a frame wait for the next. Off by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cs + Cd * (1 - As), which the GS's (A - B) * C + D cannot express: a
premultiplied image drawn over the target in one draw where the GS needs
two (scale the target by the coverage, then add the image). One more
blend state, after the GS's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WindowConfig::opengl makes an SDL_WINDOW_OPENGL window with a 4.3 core context's attributes and no
depth of its own, for a renderer that draws into its own targets (Chronicle's GL renderer for the
Switch's Mesa).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EndFrame waited for its frame to finish on the GPU whenever a depth query was made, so a game that
reads depth every tick (Chronicle's pick-Z, the sun's lens flare) never recorded a tick while the
GPU drew the last. On the Switch, whose GPU is the slower side, that held Norune at 30 frames a
second near the sun. There each frame keeps the queries it read, and they are answered once its
fence has passed (kAsyncDepthQueries): DepthResult holds the answer a tick or two later. Elsewhere
nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An immediate mesh with the same vertices as the last one recorded (a shadow volume's adding and
subtracting passes draw one corner array) keeps an index to that entry instead of its own copy,
unless it blends its vertices between ticks; the Vulkan replay uploads such an array once a frame.
In a dungeon about half of the immediate meshes share, which spares their copy into the list and
their upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A game can record a tick while the tick before renders and presents on a thread of its own
(README, "Threads"). Recording and frames belong to the thread that opened them: RecordingCalls,
Recording and InFrame answer for the calling thread (atomic thread markers), so a render on another
thread executes its entries instead of recording them. One recursive lock guards the renderer:
RenderList and PresentCanonical hold it throughout, BeginFrame until EndFrame, and every call that
changes the renderer outside a recording takes it (creation and destruction, immediate uploads,
readback, scale and layout, a list's release of what it doomed). Calls that only record take none.
They still look handles up, so the texture and mesh tables are reserved whole and never move, and
NamedRenderTarget finds an existing target without the lock. The main targets' size and the depth
query answers are published atomically for the recording thread. PrepareRecording takes the lock
only when the main targets must follow the window.

ThreadSanitizer finds no race between the two threads over a save's title, loading and town, and a
dungeon floor. Single-threaded, nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adubbz and others added 27 commits October 21, 2026 00:38
* Gfx: give Tracy's calibration buffer a pool that allows a reset

TracyVkContext without calibrated-timestamp functions measures the GPU clock
in the device time domain: it begins, submits and waits on the buffer three
times. The second and third begins reset it implicitly, which a buffer from
g_oneshot_pool (TRANSIENT only) may not do, so every renderer start logs two
VUID-vkBeginCommandBuffer-commandBuffer-00050 errors. Under the validation
layer every GPU test that counts validation messages fails.

The calibration buffer now comes from its own pool with
VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, destroyed once Tracy has
calibrated.

* Tests: give the unnamed arena overflow case a fixed address

OverflowOfAnUnnamedArenaGivesItsAddress expected the address of a static
in the parent's message. That holds only while the death test's child
shares the parent's layout: a fork, or a fresh process on Windows, where an
image keeps its base within a boot. With gtest's threadsafe style the
child re-runs the binary, and on Linux and macOS a position-independent
image gets a new base, so the case failed although the message was right.

The case now passes a fixed address that ArenaOverflow only prints, so
parent and child expect the same exact text, still spelled by the
platform's %p.

* Build: link Rust's macOS system libraries from the link's own SDK

Corrosion 0.6.1 gives every macOS Rust target
INTERFACE_LINK_DIRECTORIES /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib,
so each executable linking kazarov_core searched the Command Line Tools
SDK for -lSystem, -lc and -lm, whatever SDK the build uses. On the
macos-26 runner that SDK is MacOSX27.0, whose .tbd files list
arm64e.x1-macos, which the selected Xcode 26.6 linker cannot read:
"tapi error: malformed file ... unknown architecture" for darkchronicle,
dc2data and dc2data_bridge_test. The directory is dropped; the linker
finds the libraries in the sysroot it is given.

---------

Co-authored-by: carrdylan52 <17089587+carrdylan52@users.noreply.github.com>
Add a walk action (Left Shift) that halves keyboard left-stick
deflection outside menus, and HostSettings::keyboard_walk to swap
walking and running. Bind debug_menu to F1 as well as paddle 1.
InputSetTextEntry/InputTakeText let a game's text field take typed
characters while those keys stop pressing their pad bindings.
A host action for a game's in-mode debug menu, bound to F2 by default.
…ost action

The controller gets a binding set of its own (HostSettings::pad_bindings,
InputBindPadButtons): each pad button and host action takes SDL gamepad
button names or lefttrigger/righttrigger. An action without one keeps its
default, the pad's own layout (GamepadButtonPad) for the pad's buttons, so
nothing changes until a binding is set. The host actions' paddle defaults move
from the keyboard set to the controller's; Gamepad: names in the keyboard set
still work.

Adds the quick_loadout host action (Down, dpdown), a capture of any gamepad
button or trigger (InputTakeBindPadButton), the bound names for a bindings
menu (InputPadBindingNames) and the button a remapped action's prompt should
show (InputPadGlyphAction). The on-screen gamepad presses host actions bound
to the buttons it shows.
NVK's compiler (NAK) gives every uniform branch a sync and padding, and on
the Switch's Maxwell GPU a fragment costs about its instruction count. Fog,
TEXA, the alpha test's function, the blend factor, the transform and the
opaque target are now values the shader selects between; only the palette
paths and the filtered AEM gathers keep a branch (19 conditional branches
to 7).

Pictures are unchanged: Dark Chronicle's town, opening, dungeon map and a
seeded dungeon floor render bit-identical on the PC build. On the Switch a
fogged prog0 draw costs 11 to 25 percent less GPU time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An index texture drawn with a palette is expanded on the CPU into an
RGBA texture the sampler filters, and expanded again when either is
uploaded to, instead of the shader looking the palette up four times a
pixel. An image written other than by an upload, a pair that changes
every few frames and a pair already drawn this frame keep the lookup;
expansions past a megatexel in a frame wait for the next. Off by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cs + Cd * (1 - As), which the GS's (A - B) * C + D cannot express: a
premultiplied image drawn over the target in one draw where the GS needs
two (scale the target by the coverage, then add the image). One more
blend state, after the GS's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tint's selects in draw.frag run for every pixel of every draw, tint or not. On the Switch's
GPU they made Norune's frames about 3 ms slower (7.5 to 10.6 ms per submission, from 60 fps to
40-50). KZ_PORT_INTERPOLATION_TINT builds them in; it is on except for NINTENDO_SWITCH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SDL's Horizon joysticks report no gamepad type, so the handheld Joy-Con took the Xbox fallback.
On the console every pad is a Nintendo one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 2D draw with depth_coverage planes reads the main depth buffer instead of testing it: its
colour and alpha are scaled by the fraction of coverage_depths at or nearer than the depth at the
pixel. That is the image eight blended full-screen draws at those depths make, each adding its
share, which is how Chronicle's heat haze accumulates the frame beyond its planes; on the Switch
those took 13 ms of GPU a tick in Muska Lacka, and one draw per image takes about 3.

The draw renders without a depth attachment, the main or display depth buffer moved to
DEPTH_STENCIL_READ_ONLY_OPTIMAL and sampled through a depth-only view at two new reserved slots
(kFirstUserSlot is 5), and put back to rest when that rendering ends. draw.frag takes it as the
kDepthCoverage specialisation constant, with the depth slot in palette_slot and the plane count in
fog_color, which such a draw has no use for, and the depths in eight floats at the end of the push
block (104 bytes), which only draw.frag declares. The pipeline is one more after all the others,
only where the device samples its depth format (RendererFeatures::depth_coverage).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
libnx starts every thread on the process's core, where the game thread runs, so the worker only
interleaved with the game instead of running beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h platform support)

Upstream's own Switch platform commit (726b1ee) has the file commits on Horizon, libnx's device
paths and the pipeline cache through FilesWrite, and its master since has a render thread,
answers depth queries without waiting for the GPU and keeps four submissions in flight. This
branch now takes all of that from upstream and keeps only what it adds on top, replayed onto
726b1ee (the commit Chronicle pins):

- draw.frag without per-draw branches (the shader-only version, for upstream's push constants)
- RendererConfig::expand_palettes, on the render thread
- DrawState::premultiplied
- the interpolation tint as a build option, off on the Switch
- Switch glyphs for every pad on the Switch
- DrawState::depth_coverage, its depths in eight floats at the end of the push block
- the render worker on core 1 on the Switch

Left behind: the Android fork's commits (upstream has its own Android support), the game-side render
thread's locking (upstream's render thread replaces it), RendererConfig::target_scale (render_scale
means the render targets' scale again), and the OpenGL window and GetTextureInfo binding check that
only Chronicle's GL renderer used, which is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One conflict: the Switch's glyph family by default sits beside master's first gamepad type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…one branch

On the Switch's GPU (Maxwell, through NVK) draw.frag was bound by its arithmetic in busy scenes,
and two things in it were new since Chronicle's Switch build ran at 60:

- It decoded the draw's fog colour, FIX, the transform, TA0 and the alpha test's function from
  packed bits for every pixel. Integer conversions and variable shifts are slow there; the CPU now
  works them out once a draw (PushConstants::per_draw, and the alpha test's verdicts in alpha bits
  11 to 13).
- The fog that stays continuous across clipped edges (da0e9cf) passed eight values from every
  vertex and worked out every pixel's fog whether it was fogged or not. Its per-draw terms (limits,
  mode, constant F) now come in push constants, and the unlimited F, affine in 1/w, from a + b / w
  with gl_FragCoord.w, which is exact; the vertices pass three values. The block is behind one
  uniform branch, which costs less than the work for Dark Cloud's mostly unfogged pixels.

The push block is 128 bytes, Vulkan's guaranteed size; a depth_coverage draw keeps its planes in
per_draw. In Norune on the Switch a render submission takes about 11.8 ms of GPU, where it had
taken 17 to 18.5 and Chronicle's earlier Switch build 11.3; every fog test draws the same pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adubbz pushed a commit that referenced this pull request Oct 24, 2026
SDL's Horizon joysticks report no gamepad type, so the handheld Joy-Con
took the Xbox fallback. On the console every pad is a Nintendo one.

Partially integrates #25. The rest of that pull request is already on
master in its own form (Horizon file commits, device paths, the
pipeline cache through FilesWrite, four frames in flight, the render
thread, the TextureDesc logical size) or adds renderer interfaces that
nothing here uses yet.
@Adubbz

Adubbz commented Oct 24, 2026

Copy link
Copy Markdown
Member

Partially integrated into master in 25e8594: the Switch button glyphs for every pad on the Switch. The rest is already on master in its own form (Horizon file commits, sdmc: paths, pipeline cache through FilesWrite, four frames in flight, the render thread, TextureDesc logical size), and the other renderer interfaces need consumer code no project has yet. Thank you!

@Adubbz Adubbz closed this Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants