Skip to content

Examples

AboveColin edited this page Jul 26, 2026 · 1 revision

Examples

Recipes for things people actually want to do. Every flow here was run against a real console.

Look at the screen and press something

capture_screen()                     # 640x360 by default — enough to read a menu
tap(640, 360)                        # coordinates are ALWAYS native 1280x720
screen_changed()                     # wait for the UI to settle

screen_changed returns one image only if the screen actually changed, and no image at all if nothing did. That second case is how you find out an input did not register — much cheaper than screenshotting in a loop.

Navigating a menu:

send_input_sequence([
  {"buttons": ["DDOWN"], "wait_ms": 150},
  {"buttons": ["DDOWN"], "wait_ms": 150},
  {"buttons": ["A"],     "wait_ms": 400}
])
screen_changed(timeout_ms=5000)

Rather than describing the presses, do them once on the console and keep them:

record_input(duration_ms=8000)       # press buttons on the console
# -> returns a `sequence` you can pass straight to send_input_sequence

Launch a game and wait for it

launch_title("0100000011d90000")
wait_event(event="app_start", timeout_ms=20000)

app_start is level-triggered: it answers "is a game running?", returning immediately if one already is. Launch first, then wait — the agent handles one command at a time, so you cannot trigger something while a wait is blocking the connection.

Homebrew cannot be launched this way. launch_title refuses the hbloader ID 0142b048fd620000, because doing it spawns a process that renders nothing and then blocks the real launch path. Use the Album applet.

Find a value in a running game

The flagship workflow. A single scan is never enough — searching for 100 matches tens of thousands of addresses. You converge by telling it which way the value moved:

live_meta()                                  # title, heap extents, build ID
find_value(100)                              # -> 8,400 candidates
   ...take damage in the game...
narrow_search(op="decreased")                # -> 213 candidates
   ...take damage again...
narrow_search(op="decreased")                # -> 4 candidates
search_results()                             # the surviving addresses
live_read_mem("0x361c00a40", 4)              # confirm

Operators eq, ne, gt, lt take a value. changed, unchanged, increased, decreased compare against the previous scan — these are what make it converge, because you rarely know the new number but always know the direction it moved.

Candidates live on the console between calls, so each step costs one round-trip. Only real mappings are scanned: the heap extent reported by live_meta is a reserved ~8 GB range that is almost entirely unmapped.

All of this reads the game without pausing it. A debugger attach freezes the target, so a draining health bar or a running countdown is invisible that way.

Triage a crash

diagnose()                    # sysinfo + running app + crash reports + screenshot
get_crash_reports()           # list, then fetch one by name
fatal_reports()               # different directory — written when the SYSTEM goes down

Turning an address into something useful:

debug_attach(pid=138)
debug_modules()               # base, size, build ID per module
debug_backtrace()             # walks the frame-pointer chain
debug_detach()                # ALWAYS — this is what resumes the target

Subtract a module base from a backtrace address to get an RVA like main+0x1a2f4, which is what Ghidra and IDA expect. The build ID identifies the exact binary, so it is the key for matching a symbol map.

Register writes need a thread stopped at a debug event, not merely a paused attach — the kernel refuses otherwise. Reads work on any paused attach.

Browse save data

No title ID needed up front:

list_saves()                        # every save: game, system, BCAT, device, cache
list_saves(type="account")          # just user game saves
mount_save(title_id="0100000011d90000", uid_hi="...", uid_lo="...")
fs_list("/", device="save")
fs_read_text("/SaveData.bin", device="save")
unmount_save()

The mount is read-only, and every write command refuses a non-SD device, so nothing here can corrupt a save. Use backup_save / restore_save if you genuinely need to write one back.

Some system saves will not mount: the owning service holds them open and a save cannot be opened twice. The error says so explicitly.

Find things on the SD card

Both of these run on the console, so you are not pulling a directory tree over Wi-Fi to filter it locally:

fs_find(name_contains=".nro", files_only=true)
fs_grep("/atmosphere/config/system_settings.ini", "usb")
fs_free_space()

fs_find is depth- and scan-capped, and says explicitly when it truncated rather than returning a partial answer that looks complete.

Do something destructive, safely

preflight()                    # battery, charger, free space, emuMMC vs sysMMC
reboot()                       # -> refused, returns a confirmation token
reboot(confirm="a1b2c3d4e5f6") # -> actually reboots

The token is bound to the exact effect, so one issued to delete /harmless.txt cannot be replayed to delete something else. It expires after five minutes. Set SWITCH_MCP_ALLOW_DESTRUCTIVE=1 to skip the dance entirely for unattended use.

Preview without acting — any mutating command takes dry_run:

fs_delete("/switch/old.nro", dry_run=true)   # says what it would do, does nothing

And afterwards:

journal()      # on-device audit trail of every mutating command, with outcomes
watchdog()     # boots with no client connecting — a rising count means unreachable

Check what you are allowed to do

capabilities()

Reports the tier, whether this is emuMMC, and the exact list of commands permitted right now. Worth calling before planning anything invasive: a command missing from that list will be refused however you call it, and the fix is a config change on the SD card plus a restart — not a different tool call.

Clone this wiki locally