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

  • Thread starter Thread starter Papux200
  • Start date Start date
  • Views Views 156
  • Replies Replies 2
  • Likes Likes 1

Papux200

New Member
Newbie
Joined
Sep 11, 2026
Messages
2
Reaction score
3
Trophies
0
Age
21
XP
10
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.
 
  • Like
Reactions: k66
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

Site & Scene News

Popular threads in this forum