Homebrew I'm trying to build a Native-res capture program that streams 1080p 60hz video to any PC through USB. This is my progress so far

Papux200

New Member
Newbie
Joined
Sep 11, 2026
Messages
4
Reaction score
4
Trophies
0
Age
21
XP
13
Country
Colombia
Repo: https://github.com/TomasUribe/switch-frame-tap (GPL-2.0)

What I'm trying to build: a homebrew sysmodule that streams the Switch's screen to any PC over a USB cable at the game's native resolution — 1080p, 60 fps — with no capture card and no quality loss. The Switch would effectively be its own capture card: plug in a cable, open a small receiver app on the PC, and get a clean, low-latency 1080p60 feed you could play from, record, or stream.

Right now the best option is SysDVR, which is great but tops out at 720p30. I wanted to see whether that limit could be broken, and if not, understand exactly why. This post is where I've got to so far.

Upfront, because it matters: I built this together with Claude, Anthropic's AI, through Claude Code. Claude wrote nearly all of the code and the docs, did the source-reading, designed the probes and interpreted the logs. I set the direction, ran every hardware test on my own console, and made the calls on what to push and what to drop. This post was drafted with it too. If you'd rather skip AI-assisted work, fair enough — but everything marked "verified on hardware" below really was run on a real console, and the mistakes, including the AI's own bugs that froze my console, are all written down in the repo.

That 720p30 cap (game layer only) isn't SysDVR's fault — it reads grc:d, the game-recording encoder, and the encoder's config is fixed in firmware. So the obvious question is whether you can take the frame earlier: read the game's own swapchain and push it through the Tegra X1's fixed-function blocks yourself.

Claude and I worked that all the way down on a Mariko running 22.5.0 / Atmosphère 1.11.2. This is a work in progress, not a finished streamer — but a few pieces are done and verified on hardware, and the negative results are worth publishing on their own, because we couldn't find them written down anywhere.

What works, on real hardware​


A transparent vi:u mitm that sees every frame at a sustained 60 fps. It wraps GetDisplayServiceIApplicationDisplayServiceGetRelayServiceIHOSBinderDriver and intercepts TransactParcelAuto, invisible to the game. From the binder traffic it recovers the exact layout of every frame: setPreallocatedBuffer carries a flattened NvGraphicBuffer, and queueBuffer names the swapchain slot. For MK8: one nvmap object, 3 × 1920x1080 A8B8G8R8, block-linear kind 0xFE, block_height_log2 4, pitch 7680.

A complete VIC pipeline driven from a sysmodule, byte-exact. The Video Image Compositor is the block that does block-linear → linear, scaling and format conversion in hardware, no GPU involved. Over raw nvdrv ioctls:

Code:
heap alloc -> svcSetMemoryAttribute(Uncached) -> nvmap CREATE/ALLOC
  -> MAP_CMD_BUFFER pin -> host1x cmdbuf (SETCL + methods + INCR_SYNCPT)
  -> CHANNEL_SUBMIT -> syncpoint wait -> cache invalidate -> read back

A fill and a real blit both reproduce their expected output byte for byte. Output order is A,R,G,B (AV_PIX_FMT_ARGB). NVENC opens too.

Four things cost us days each, so in case they save you the same:

  • SETCL is mandatory and nobody tells you. METHOD_OFFSET (0x10) and METHOD_DATA (0x11) are registers of the current host1x class. libdrm never emits SETCL because the DRM kernel driver sets the class itself. nvservices' CHANNEL_SUBMIT does not. Without SETCL(0, 0x5D, 0) every method write lands on meaningless registers — while INCR_SYNCPT (register 0x00, present in every class) still fires, so the job looks like it completed successfully. Six runs.
  • Relocs are inert on Horizon. The command buffer is never patched. Pin with MAP_CMD_BUFFER, inline the returned address, submit with num_relocs = 0.
  • The syncpoint increment has to be in the command stream. syncpt_incrs in the submit only raises the syncpoint's max. Leave out the NONINCR(UCLASS_INCR_SYNCPT, 1) and nvnflinger — which composites on the same VIC syncpoint — waits forever and the console hard-freezes.
  • A zero address doesn't fail politely. The VIC hangs, and a hung VIC takes the compositor with it. Froze my console twice this way, both times in AI-written code — the second time right after the AI had diagnosed exactly that failure from the first.

A libstratosphere patch that upstream doesn't have. 55 lines. Atmosphère's mitm framework can forward commands it doesn't implement, but only for objects on a domain session. vi:u hands out its sub-objects on a non-domain session, so there's nowhere to put the forward service and every undeclared command on a wrapped sub-object fails instead of passing through. If you've ever tried to mitm vi and given up, this is probably why.

Some undocumented vi ABIs, confirmed on hardware: CreateIndirectLayer (2050), CreateIndirectProducerEndPoint (2052), CreateIndirectConsumerEndPoint (2054), all {u64, u64} -> u64. Also GetDisplayService's command id is the service type, not 0 — vi:u=0, vi:s=1, vi:m=2.

What's blocked, and why​


A sysmodule can process frames at full speed. Getting hold of one is the problem. Three routes, each taken to a definite verdict:

  • Import the game's swapchain nvmap handle. FROM_ID succeeds, MAP_CMD_BUFFER returns phys=0 silently and the IOVA allocator doesn't even advance. Survives is_compr, MAP_CMD_BUFFER_EX, relocs, the full 0xFFFFFFFF nvdrv:t permission mask, and the game's exact aruid adopted before any Open. It's structural: at Initialize nvservices is handed CUR_PROCESS_HANDLE and maps client memory through that. The game's pages are in the game's process.
  • vi indirect layers. 0x60A PreconditionViolation. The whole object graph builds and the layer is just empty — wiring an application's layer to an indirect layer is AM's job, and a sysmodule can't drive AM.
  • Display controller readback. No such ioctl exists anywhere in nvdrv. nvdisp-disp0 is FLIP / SET_MODE / GET_WINDOW; nvdcutil is DSI/EDID test plumbing.

(caps CaptureRawImage is [1.0.0], removed long before 22.5.0.)

So we're fairly confident this is why SysDVR is stuck at 720p30. It isn't that nobody tried — the platform doesn't let a sysmodule reach another process's framebuffer through the graphics stack.

Where it is now​


The live route goes around the graphics stack entirely: the kernel debug SVCs, the same ones Atmosphère's own cheat engine uses to read a running game at 60 Hz. Reading mesosphere answers most of it up front — MemoryAttribute_DeviceShared, the thing that killed the nvmap route, isn't consulted by ReadDebugMemory's permission check. That code is written and awaiting its first hardware run.

If you want to fork it​


Everything's GPL-2.0 and the repo is mostly a research log — every dead end is recorded with its evidence specifically so nobody repeats it. Things I'd love help with or would be glad to see someone take further:

  • The debug-SVC capture path, if you've got a console you don't mind freezing.
  • NVENC. The channel opens, the encode isn't written.
  • The 8200-series shared-buffer commands (BindSharedLowLevelLayerToIndirectLayer and friends) — the last speculative avenue for making an indirect layer actually populate.
  • Anyone who knows why nvservices refuses foreign handles at the source level. We inferred the mechanism from behaviour; I'd like to be corrected if it's wrong.

Reach me here, at [email protected], or on GitHub issues.

Usual disclaimer: this interposes on the graphics stack and drives hardware engines directly. It froze my console twice. Don't run it on something you care about, and have a NAND backup.
 
Update: it works. We're reading the game's framebuffer at native 1080p, 59 fps.

Since the first post, the capture problem is solved. Not through the graphics stack - all three routes there stayed closed - but around it, via the kernel debug SVCs, the same ones Atmosphere's own cheat engine uses to read a live game.

What's working now​


  • The swapchain is located at runtime, by scanning the game's memory map for a device-shared region of exactly 26,542,080 bytes - three 8,847,360 B slots, matching what NVMAP_IOC_PARAM reported for the handle. Addresses move every boot (ASLR), so it is found fresh each run, in 3 reads.
  • ContinueDebugEvent(ExceptionHandled | ContinueAll) resumes the game while we keep the debug handle. Reading does not require freezing the target - the pixels move underneath us while attached.
  • Sustained capture: 120 consecutive full 1920x1080 frames, 0 missed, 120 distinct, 59 fps. Slots read 40/40/40, so we are following the swapchain rotation properly off the queueBuffer intercept.
  • Per-frame cost ~9 ms against a 16.67 ms budget, 0 of 120 frames over. Reads run 1100-1800 MB/s; a whole slot lands in 5-7 ms. The game gives up 1-2 fps while we do it.

And it produces real pictures. Dumping a slot while the game is stopped, then de-swizzling the Tegra block-linear layout on the PC, gives a clean native 1920x1080 frame - HUD, lap counter, minimap, all sharp. One is in the repo now:
https://github.com/TomasUribe/switch-frame-tap/blob/master/docs/frame-1080p.png

Three things worth knowing if you go down this road​


  • Granting svcDebugActiveProcess in "syscalls" is not enough. mesosphere also wants a debug_flags capability - kern_svc_debug.cpp:38 needs IsPermittedDebug || CanForceDebug || CanForceDebugProd. Without "force_debug": true in the NPDM the attach cannot work, whatever the syscall mask says. One wasted build for me.
  • MemoryAttribute_DeviceShared does not block ReadDebugProcessMemory. kern_k_page_table_base.cpp:2743 checks state and permission with an attribute mask of None. That attribute is exactly what defeated the nvmap route, and it simply does not apply here - which is why this path works where pinning the foreign handle never could.
  • svcMapProcessMemory is not a shortcut, tempting as it looks: kern_svc_process_memory.cpp:92 requires KMemoryAttribute_None on the source, and nvmap-pinned pages always carry DeviceShared. Permanently closed - don't spend a build on it.

Also: in handheld, MK8 renders 1280x720 into the 1080p buffer (the content bounding box is exactly x 0..1279, y 0..719). Docked gives true 1920x1080. If you are measuring capture quality, dock first.

Still not a streamer​


Capture is done; the pipeline is not. Open: the VIC's block-linear source layout (the blit completes and writes the right byte count, but gathers pixels in the wrong order - libdrm marks the two fields I need "XXX" because it only handles pitch-linear, so I am sweeping the candidates on hardware), then NVENC, then transport.

Same disclosure as the first post: built with Claude, and the mistakes are its mistakes too - including one run where the tolerance in my own check was loose enough to print "GAME UNAFFECTED" over a real 4 fps dip. That is fixed, and the delta is now printed as a signed number so a label cannot hide it.

Full research log, with every dead end and its evidence: https://github.com/TomasUribe/switch-frame-tap
 
  • Like
Reactions: k66 and Raul8
And how do you want to stream this over USB to any PC without using Display Port mode when Switch's USB in standard mode is too slow for 1080p60? You said 1100 MB/s at minimum. In USB 3 mode max speed is 625 MB/s.
Post automatically merged:

Ah, now i see. So on top of capturing you want also to encode it. First you stated "clean 1080p60" and later you want to use nvenc. And everything done via sysmodule which on 22.5.0 will take half of available space for sysmodules to just store raw single 1080p buffer alone, then more space to use as cache for nvenc. Sounds like waste of time for the end goal outside of Switches with modded RAM. Getting 1080p clean screenshot will already be a good use for this.
 
Last edited by masagrator,
  • Like
Reactions: k66
Both points are fair, and one of them I need to concede outright. Thanks for engaging with the actual numbers.

First, a correction that's mine, not yours: the 1100 MB/s figure isn't a transport rate. That's how fast ReadDebugProcessMemory pulls a frame out of the game's address space - a memory read, not something going over a cable. Raw 1080p60 is 498 MB/s. I should have made that distinction clearly in the post rather than leaving a number lying around that reads like a bandwidth claim.

But correcting it doesn't rescue the point, it just moves it. 498 MB/s raw still exceeds practical USB 3, and usb:ds in normal mode is USB 2.0 anyway - call it 30-40 MB/s. So raw transport was never viable at any resolution. Encoding isn't an optimisation for this, it's load-bearing, which is where you landed too.

On memory you're right, and I have the receipt. From my own log:

Code:
SetMemoryHeapSize(8 MB) rc=0x1003
SetMemoryHeapSize(6 MB) rc=0x1003
SetMemoryHeapSize(4 MB) rc=0x1003
SetMemoryHeapSize(2 MB) rc=0x0

2 MB is the ceiling. A single 1080p frame is 7.91 MB - four times the entire heap. That's why everything works in 983,040-byte strips (one block-row: full 1920 width by 128 rows, which is exactly how the block-linear layout tiles). And I've already had the sharp edge of this: an earlier build took 4 MB of .bss and fataled a different sysmodule at boot with LimitReached, because .bss comes out of the same shared system pool.

What I hadn't quantified is your extension of it - NVENC's own working set on top: input surface, bitstream buffer, and reference frames for inter-frame prediction. If one frame already exceeds the heap 4x, that's a real question rather than a detail. Three things follow, and I'd rather test them than argue:

  • Measure NVENC's actual footprint before writing an encoder, not after.
  • All-intra becomes the default plan, not a fallback - I-frames need no reference frames, which removes the largest consumer. Costs bitrate, but bitrate is the budget I have more room to trade than memory.
  • Check whether the 2 MB ceiling is actually fixed. pool_partition is a one-line NPDM change and I've never varied it.

And your last line is the one I think is most likely to be right. A clean native-1080p screenshot tool has no bandwidth problem and no encoder problem, and it already works - I have 1920x1080 frames read straight out of the game's swapchain, de-swizzled and verified against the hardware blit at zero error. If NVENC turns out not to fit in a sysmodule's budget, that's a finished useful thing rather than a dead end, and I'd rather ship it than pretend the streaming goal was always realistic.

The capture side is solved and documented either way - the kernel debug SVC route, the NPDM force_debug requirement, and why nvmap pinning of a foreign handle can't work. That part stands regardless of what happens downstream.
 
the best way to do this is to simply rip the frames straight from the display controller with MMIO, you have access to it from a sysmodule. No kernel/secmon mod is needed for that. and also GL with NVENC, as its all undocumented (noveau doesnt support it so you either have to use leaked src or RE nvservices to figure it out). plus many games use it
 
the best way to do this is to simply rip the frames straight from the display controller with MMIO, you have access to it from a sysmodule. No kernel/secmon mod is needed for that. and also GL with NVENC, as its all undocumented (noveau doesnt support it so you either have to use leaked src or RE nvservices to figure it out). plus many games use it

You're right about the MMIO and I had it wrong - thanks, that's a genuine correction and I've recorded it.

I'd written display-controller access off as out of reach for a sysmodule. It isn't, and Atmosphere's own boot module proves it: boot.json declares a map capability for 0x54200000, size 0x3000, is_io true - that's DISPLAY_A - and boot_display.cpp maps it with dd::QueryIoMapping. I already declare svcQueryIoMapping (0x55); the only missing piece was the map capability itself. npdmtool compiles one fine, and the kernel's PhysicalMapAllowedMask is (1<<36)-1 so the address passes validation. No kernel or secmon patch, exactly as you said.

Where I get stuck is the step after, and I'd like your read on it. The DC holds no pixels - it's a scanout engine reading DRAM, and it reads through the SMMU. boot_display.cpp hands it a framebuffer with CreateDeviceAddressSpace -> AttachDeviceAddressSpace(DeviceName_Dc) -> MapDeviceAddressSpaceAligned, so WINBUF_START_ADDR holds a device virtual address rather than a physical one. Reading the register gets me a pointer I can't dereference without the IOMMU page tables.

I do hold 0x56/0x57/0x5a and could attach my own address space to the DC, but nvservices already owns that attachment, and I've hard-frozen the console twice fighting the live display path.

So - is there a way to resolve that DC VA back to something mappable, or did you mean a different register path? If there's a known trick I'd rather learn it than guess at it.

On NVENC, agreed, and it matches what I found: nouveau has no Tegra NVENC support at all. I have the msenc channel open with a syncpoint and the host1x class id (0x21), but no method table, so REing nvservices looks like the only honest route.
Post automatically merged:

Update: there is a working stream now.

Live video from the console to the PC over USB at 480x270 / 59.4 fps, with a small libusb + SDL2 viewer. Same arrangement as the first post: Claude (Anthropic's AI) wrote the code and did the source-reading, I set the direction and ran every hardware test on my own console.

Repo: https://github.com/TomasUribe/switch-frame-tap

1. The memory ceiling was two gates, not one

The earlier conclusion that a sysmodule cannot hold a 1080p frame was right, but for a reason that turned out to be fixable. There are two independent limits:

- pool_partition picks the physical pool. We were on 2 (System), ~15 MB free shared across every sysmodule on the console.
- application_type picks the resource-limit group. Also System, and LimitReached is that gate - it is what killed am when I over-allocated and fataled the console.

Moving only the first is not enough. I did exactly that and wrongly announced it as solved. With both on Applet (pool_partition 1 + application_type 2, the same combination memlet uses) the probe-time budget goes from 5,472 KB to 411,260 KB, and a sysmodule now holds 16 MB with the System pool untouched to the kilobyte.

@masagrator - your objection held for every run up to that point, and the reason it held so long is that both gates had to move. You are owed the numbers.

2. Capture is fast enough

A full 8,847,360-byte slot via svcReadDebugProcessMemory in 5.6 ms, against a 16.67 ms frame budget. ContinueDebugEvent keeps the game running while we stay attached.

3. The VIC is unusable as a per-frame stage

This one cost me a frozen console. A full-frame 1920x1080 -> 480x270 blit measured 119 ms - seven times the entire 60 fps budget. Worse, nvnflinger composites on the same engine and the same syncpoint 12, so a tight blit loop starved the compositor: the game fell to 3.8 fps and never recovered.

It is fine for a one-shot blit. It is not usable for streaming. I replaced it with a CPU point-sampler reading straight out of the block-linear capture - at an exact 4x reduction every output pixel lands on a 16-byte group boundary, so it is one aligned 4-byte read per pixel, no engine, no contention.

4. A second self-inflicted freeze, worth sharing

Every thread in a sysmodule with kernel_flags lowest_cpu_id 3 / highest_cpu_id 3 is pinned to core 3 - including the mitm's own IPC thread that answers the game's vi:u calls. My stream loop ran at the same priority and burned ~10 ms per iteration, so the game blocked on a binder call nobody was scheduled to answer. It froze for exactly the duration of the loop and resumed the instant it ended. Fix: run the worker below IPC priority and yield every iteration.

@Souldbminer - you were right that MMIO is reachable from a sysmodule and I had it wrong. Atmosphere's own boot module proves it: boot.json declares a map capability for 0x54200000 (DISPLAY_A) and boot_display.cpp maps it with dd::QueryIoMapping. I already declared svcQueryIoMapping and was only missing the map capability itself. Where I am still stuck is that the DC reads through the SMMU, so WINBUF_START_ADDR holds a device virtual address rather than something I can dereference. If anyone knows how to resolve that back to something mappable, I would like to hear it.

Where it is stuck now: bandwidth

USB 2.0 bulk gives about 31 MB/s. That is the whole story:

Code:
720p60 RGBA            221 MB/s
720p60 NV12             83 MB/s
720p60 H.264 @ 20 Mbps   2.4 MB/s

So raw pixels are finished as a strategy. 480x270 at 60 fps is roughly what the link carries; 640x360 drops to about 40 fps.

Two ways out, neither opened yet:

SuperSpeed. I now declare the USB 3.0 descriptors and endpoint companions - usb:ds accepts them, rc=0x0 - and the link still negotiates High. My PC's root hubs report 10000/20000 Mbps so it is not the host; it is the cable or the console's device-mode capability, and I do not have a second USB 3.0 device to prove the cable. If anyone has gotten SuperSpeed out of usb:ds in device mode, I would love to know.

NVENC. Class id 0x21, the channel opens and accepts a submit, method table unknown. Souldbminer's point stands - nouveau has no Tegra NVENC support, so it is RE nvservices or leaked sources. That is next.

One awkward interaction: NVENC wants NV12 input and the VIC is the natural way to produce it, but per point 3 the VIC cannot run per-frame. Producing NV12 cheaply is an unsolved sub-problem sitting in front of the encoder.

Everything, including both console freezes and the mistakes that caused them, is written up in tier4/mitm/STATUS.md.
Post automatically merged:

Update: there is a working stream now.

Live video from the console to the PC over USB at 480x270 / 59.4 fps, with a small libusb + SDL2 viewer. Same arrangement as the first post: Claude (Anthropic's AI) wrote the code and did the source-reading, I set the direction and ran every hardware test on my own console.

Repo: https://github.com/TomasUribe/switch-frame-tap

1. The memory ceiling was two gates, not one

The earlier conclusion that a sysmodule cannot hold a 1080p frame was right, but for a reason that turned out to be fixable. There are two independent limits:

- pool_partition picks the physical pool. We were on 2 (System), ~15 MB free shared across every sysmodule on the console.
- application_type picks the resource-limit group. Also System, and LimitReached is that gate - it is what killed am when I over-allocated and fataled the console.

Moving only the first is not enough. I did exactly that and wrongly announced it as solved. With both on Applet (pool_partition 1 + application_type 2, the same combination memlet uses) the probe-time budget goes from 5,472 KB to 411,260 KB, and a sysmodule now holds 16 MB with the System pool untouched to the kilobyte.

@masagrator - your objection held for every run up to that point, and the reason it held so long is that both gates had to move. You are owed the numbers.

2. Capture is fast enough

A full 8,847,360-byte slot via svcReadDebugProcessMemory in 5.6 ms, against a 16.67 ms frame budget. ContinueDebugEvent keeps the game running while we stay attached.

3. The VIC is unusable as a per-frame stage

This one cost me a frozen console. A full-frame 1920x1080 -> 480x270 blit measured 119 ms - seven times the entire 60 fps budget. Worse, nvnflinger composites on the same engine and the same syncpoint 12, so a tight blit loop starved the compositor: the game fell to 3.8 fps and never recovered.

It is fine for a one-shot blit. It is not usable for streaming. I replaced it with a CPU point-sampler reading straight out of the block-linear capture - at an exact 4x reduction every output pixel lands on a 16-byte group boundary, so it is one aligned 4-byte read per pixel, no engine, no contention.

4. A second self-inflicted freeze, worth sharing

Every thread in a sysmodule with kernel_flags lowest_cpu_id 3 / highest_cpu_id 3 is pinned to core 3 - including the mitm's own IPC thread that answers the game's vi:u calls. My stream loop ran at the same priority and burned ~10 ms per iteration, so the game blocked on a binder call nobody was scheduled to answer. It froze for exactly the duration of the loop and resumed the instant it ended. Fix: run the worker below IPC priority and yield every iteration.

@Souldbminer - you were right that MMIO is reachable from a sysmodule and I had it wrong. Atmosphere's own boot module proves it: boot.json declares a map capability for 0x54200000 (DISPLAY_A) and boot_display.cpp maps it with dd::QueryIoMapping. I already declared svcQueryIoMapping and was only missing the map capability itself. Where I am still stuck is that the DC reads through the SMMU, so WINBUF_START_ADDR holds a device virtual address rather than something I can dereference. If anyone knows how to resolve that back to something mappable, I would like to hear it.

Where it is stuck now: bandwidth

USB 2.0 bulk gives about 31 MB/s. That is the whole story:

Code:
720p60 RGBA            221 MB/s
720p60 NV12             83 MB/s
720p60 H.264 @ 20 Mbps   2.4 MB/s

So raw pixels are finished as a strategy. 480x270 at 60 fps is roughly what the link carries; 640x360 drops to about 40 fps.

Two ways out, neither opened yet:

SuperSpeed. I now declare the USB 3.0 descriptors and endpoint companions - usb:ds accepts them, rc=0x0 - and the link still negotiates High. My PC's root hubs report 10000/20000 Mbps so it is not the host; it is the cable or the console's device-mode capability, and I do not have a second USB 3.0 device to prove the cable. If anyone has gotten SuperSpeed out of usb:ds in device mode, I would love to know.

NVENC. Class id 0x21, the channel opens and accepts a submit, method table unknown. Souldbminer's point stands - nouveau has no Tegra NVENC support, so it is RE nvservices or leaked sources. That is next.

One awkward interaction: NVENC wants NV12 input and the VIC is the natural way to produce it, but per point 3 the VIC cannot run per-frame. Producing NV12 cheaply is an unsolved sub-problem sitting in front of the encoder.

Everything, including both console freezes and the mistakes that caused them, is written up in tier4/mitm/STATUS.md.
 
Last edited by Papux200,

Site & Scene News

Popular threads in this forum