English · 中文
Nintendo 3DS SSH client — pinyin IME · voice input · ANSI terminal
Top screen runs a citro2d ANSI terminal · bottom screen draws its own
soft keyboard · RSA public-key auth over libssh2 + mbedTLS
Press START to dictate Chinese into Claude Code; type pinyin on
the soft keyboard for the rest.
Run tmux + claude-code from a 3DS — code from
the couch without ever opening the laptop.

Real New 2DS XL · top screen ANSI terminal · bottom screen soft
keyboard + clock + crab
Full 1m42s demo video (10 MB MP4)

Real New 2DS XL · top: typing「你好啊!请问您是谁,你可以做什么」into
Claude Code · bottom: letter page + CHN mode + Shift held
- Full ANSI / VT100 terminal — tmux status bar, claude-code spinner, box-drawing borders, 256-color, TrueColor, Braille; everything renders.
- Chinese rendering — bundled Zpix 12px pixel font covers 21,000+ CJK unified ideographs, Terminus 6×12 for ASCII; mixed CJK/ASCII baselines align cleanly on the same line.
- Self-drawn soft keyboard — iOS-style 3px rounded keys with smooth press-down animation; letters / symbols pages.
- Pinyin input method — top 300k entries from rime-ice, plus
abbreviation matching (
nh→ 你好), prefix fallback (nihaozauto-falls-back tonihao), and a candidate cursor. - Voice input (v1.0) — press START, speak a Chinese sentence,
press START again; ~1-2 s later the transcribed text drops
straight into the SSH terminal. Default backend is OpenRouter
Whisper Large V3 Turbo over the cloud (
$0.04per audio-hour); a self-hosted whisper.cpp track is available if you'd rather not depend on an external API. - Voice AI ask (NEW in v1.1) — hold L and press START to ask DeepSeek-Chat a question by voice; the answer pops up in a bottom-screen modal with markdown-styled rendering (headers in yellow, code in cyan, bullets, etc.) without disturbing the SSH session above. Press A in the modal to keep history for follow- up questions, B to clear and start a new conversation.
- RSA-4096 public-key auth — libssh2 + mbedTLS, private key read from the SD card.
- Full physical-key mapping — D-pad arrow keys, hold-style modifiers (L = Shift, Y = Ctrl, X = Alt), Circle Pad scrollback / mouse-wheel.
- Anthropic-red crab mascot — scampers along the bottom row, dodges when you tap it 🦀.
- Hidden debug page — double-tap the ENG/CHN badge to see the live SSH byte stream, full key-binding cheat sheet, and a mascot toggle.
- Install
- Server-side setup
- Configure config.ini
- Voice input
- Key bindings
- Using the IME
- Debug page
- Build from source
- Project layout
- Credits
- License
DSSH runs on a modded 3DS / 2DS / New 3DS. You need either the Homebrew Launcher (HBL) or a CIA installer like FBI.
- Grab
DSSH.cia(~14 MB) from the latest release. - Copy it anywhere on the SD card (e.g.
/cias/DSSH.cia). - Open FBI → SD → select
DSSH.cia→Install CIA. - The orange DSSH icon shows up on the HOME menu.
- Grab
3dssh.3dsxfrom the latest release. - Copy to
/3ds/dssh/dssh.3dsxon the SD card. - Open HBL → pick DSSH.
# On the 3DS: launch HBL, press Y → "Waiting for 3dslink..."
3dslink -a <3DS-LAN-IP> 3dssh.3dsxThe 3DS libssh2 build uses mbedTLS as its crypto backend and hardcodes-disables ed25519. So you generate a fresh RSA-4096 keypair just for the 3DS — your existing ed25519 key on the PC keeps working untouched:
# 1. Generate a 3DS-only RSA key on your PC
ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa_3ds -C "3ds-ssh-client"
# 2. Copy the public half into the server's authorized_keys
ssh-copy-id -i ~/.ssh/id_rsa_3ds.pub user@your-server.example.com
# 3. Verify the new RSA key works from your PC
ssh -i ~/.ssh/id_rsa_3ds user@your-server.example.com 'echo OK'Recommended hardening: prepend the new line in the server's
~/.ssh/authorized_keys with from="<your-home-public-IP>" so a lost
SD card can only log in from your home network.
Copy the private key ~/.ssh/id_rsa_3ds onto the SD card at
/3ds/3dssh/id_rsa (the path is fixed even when DSSH is installed as a
.cia — config + key always read from sdmc:/3ds/3dssh/).
⚠️ The SD card stores the key in plain text. Anyone holding the SD can log in to your server. Addfrom="..."IP restriction orcommand="..."lockdown inauthorized_keys.
Copy sd_template/3ds/3dssh/config.ini.example to the SD card at
/3ds/3dssh/config.ini and edit the values:
host = your-server.example.com
port = 22
user = ubuntu
key_path = sdmc:/3ds/3dssh/id_rsa
passphrase =| Field | Meaning |
|---|---|
host |
Server IP or hostname |
port |
Port (default 22) |
user |
SSH login user |
key_path |
Private key path; sdmc:/... is the 3DS standard SD prefix |
passphrase |
Optional key passphrase; leave empty (typing one on the soft keyboard is awkward) |
Final SD layout:
sdmc:/3ds/3dssh/
├── config.ini
└── id_rsa
TL;DR: install the API track (one curl + one API key, ~30 KB on disk, ~1-2 s end-to-end). The self-hosted track exists for completeness but is not recommended for typical servers — see the warning at the bottom of this section.
Press START on the 3DS, speak a Chinese sentence, press START again — the transcribed UTF-8 text drops into the SSH terminal as if you had typed it. Full sentences flow into Claude Code without ever opening the soft keyboard.
The 3DS records 16 kHz PCM mono via its built-in microphone, ships up to 8 seconds of audio over a second libssh2 channel on the same SSH session (no new ports, no new auth, no firewall changes), and a small server-side shim transcribes via Whisper.
Status indicator (top-left of the soft keyboard top row):
- 🔴 REC (red, pulsing) — recording in progress
- ⠋⠙⠹⠸ (cyan, spinning) — uploading + transcribing
- ERR (red, 2 s) — request failed; press START again to retry
The voice features need two API keys on the server. Both together cost a few cents per month for personal use:
| Key | Where to get it | What it powers | Required? |
|---|---|---|---|
| OpenRouter | openrouter.ai/settings/keys | Whisper Large V3 Turbo (speech → text) | Required for voice IME (START) and AI ask (L+START) |
| DeepSeek | platform.deepseek.com/api_keys | DeepSeek-Chat (AI question answering) | Optional — only needed for L+START AI ask; voice IME works without it |
Pricing: OpenRouter Whisper Turbo is $0.04 / audio-hour (~a few
cents per month for an individual); DeepSeek-Chat is roughly
$0.0001 per question. Both providers give a small free credit on
sign-up, more than enough to test.
SSH into your server, then:
git clone https://github.com/Fishason/DSSH.git ~/dssh-repo
bash ~/dssh-repo/tools/install_whisper_api.shThe installer will interactively prompt for both keys:
▶ checking prerequisites...
✓ python3 3.10
▶ installing daemon + shim + dssh-whisper CLI ...
▶ writing track config...
Need your OpenRouter API key (https://openrouter.ai/settings/keys).
Used for Whisper transcription. Looks like: sk-or-v1-...
Paste your OpenRouter key (or empty to skip): █
✓ api-key saved at /home/you/.config/dssh-whisper/api-key (chmod 0600)
Optional: DeepSeek API key (https://platform.deepseek.com/api_keys).
Used only by the L+START voice AI-ask modal — skip if you only
want plain voice IME. Looks like: sk-...
Paste your DeepSeek key (or empty to skip): █
✓ deepseek-key saved at /home/you/.config/dssh-whisper/deepseek-key (chmod 0600)
✓ dssh-whisper-api installed (lightweight, OpenRouter Whisper Turbo).
Non-interactive install (handy for CI / Ansible / clean rebuilds):
OPENROUTER_API_KEY="sk-or-v1-..." \
DEEPSEEK_API_KEY="sk-..." \
bash ~/dssh-repo/tools/install_whisper_api.sh$ dssh-whisper status
active track: api
(API-only install — no local daemon)
api-key: ✓ /home/you/.config/dssh-whisper/api-keyThe 3DS calls ~/.local/bin/dssh-whisper-shim over SSH-exec on every
START press; no further server-side action is needed.
~/.config/dssh-whisper/
├── track # "api" or "local"
├── api-key # chmod 0600 — OpenRouter
└── deepseek-key # chmod 0600 — DeepSeek (optional)
~/.local/bin/
├── dssh-whisper # CLI wrapper (start/stop/status/switch/uninstall)
└── dssh-whisper-shim # symlink the 3DS reaches over SSH-exec
~/.local/share/dssh-whisper/
└── whisper_shim.py # the actual Python that talks to both APIs
Footprint: ~30 KB. No daemon, no model, nothing to monitor.
Compromised key, expired credit, swapping providers — overwrite the file:
echo 'sk-or-v1-NEW_KEY' > ~/.config/dssh-whisper/api-key
chmod 0600 ~/.config/dssh-whisper/api-key
# (no service to restart — the shim re-reads the key on every call)| Field | Value |
|---|---|
| Inference | OpenRouter Whisper Large V3 Turbo (cloud) + DeepSeek-Chat |
| Latency (4 s clip) | ~1-2 s STT, +1-2 s LLM for AI ask |
| Cost | $0.04 / audio-hour STT + ~$0.0001 / AI question |
| Server install size | ~30 KB |
| Server CPU load | negligible |
| Internet | required (HTTPS to openrouter.ai + api.deepseek.com) |
That's enough for 99% of users — install it, press START, done.
dssh-whisper status # active track + daemon status + key presence
dssh-whisper switch # toggle api ↔ local
dssh-whisper switch [api|local] # set explicit track
dssh-whisper start # start local daemon (dual install only)
dssh-whisper stop | close # stop local daemon
dssh-whisper restart
dssh-whisper logs [-f] # tail systemd-user logs (dual install)
dssh-whisper uninstall # remove all dssh-whisper files
Daemon-related commands degrade gracefully on the API-only install (they print "no daemon to start", non-fatal).
Stuck in yazi and forgot the keybind for hidden files? Need a
one-liner regex for vim? Hold L and press START — you're
now asking the AI instead of typing into the SSH session. A modal
pops up on the bottom screen with the answer; the SSH terminal on top
is left untouched.
| In modal | Effect |
|---|---|
| A | close, keep the Q&A in history; next L+START continues the conversation |
| B or any touch on the bottom screen | close, clear history; next L+START is fresh |
Conversation history caps at 5 turns; if you keep pressing A past that, the oldest turn drops off (FIFO).
The model is deepseek-chat (DeepSeek direct API, not via
OpenRouter) — picked for its sub-second latency and very low pricing
(~$0.0001 per question for personal use). Answers are 6-15 sentences
typically; long answers wrap inside the modal and overflow truncates
with a trailing ....
DeepSeek tends to use markdown. The modal renders common forms in colour rather than showing raw syntax characters:
| Markdown source | Modal rendering |
|---|---|
# Heading / ## Heading / ### Heading |
yellow accent, # markers stripped |
`inline code` |
cyan, backticks stripped |
``` …code block… ``` |
cyan multi-line, fences stripped |
- item / * item |
• item (dim grey bullet + normal text) |
**bold** / *italic* |
normal text — syntax stripped, no emphasis (no bold font at this size) |
[link text](url) |
link text only — URL dropped |
~~strikethrough~~ |
normal text — syntax stripped |
You'll need a DeepSeek API key from
platform.deepseek.com/api_keys.
The installer prompts for it alongside the OpenRouter key (skippable
— plain voice IME works without it). Stored at
~/.config/dssh-whisper/deepseek-key chmod 0600 — see Rotating a
key later above to change it.
- 3DS firmware caps recording at ~32 s per press (the 1 MB mic buffer fills at 16 kHz × 16-bit). Tap START earlier to commit any time.
- Use HOME (not START) to exit DSSH — START is dedicated to voice.
~/.config/dssh-whisper/is on.gitignorealready; rotating the API key is oneecho > api-keyaway.- The 3DS code calls
~/.local/bin/dssh-whisper-shimover a libssh2 exec channel. The shim reads the active track and dispatches — switching tracks doesn't require restarting the 3DS or the SSH session.
⚠️ Heads-up: the self-hosted track loads thewhisper-smallmodel (~1 GB resident) and runs CPU inference for every recording. On a 2-vCPU AWS t3.medium with VS Code Remote, claude-code, tmux, and chrome-devtools-mcp running, transcribing 4 seconds of audio took ~40 seconds — vs. ~1.5 seconds through OpenRouter at the same moment. The cost difference is so small ($0.04 per audio hour ≈ pennies/month for personal use) that we strongly recommend the cloud path unless you have a hard reason to keep audio on-prem.If you do go local, plan on:
- 4+ idle vCPU cores at 3+ GHz — anything less and the 3DS UX spinner becomes painful.
- 2+ GB free RAM for the small.zh model + buffers.
- No competing CPU consumers during transcription windows.
- ~600 MB disk for the model + venv.
If you genuinely want the offline path, the same dssh-whisper CLI
manages both tracks side-by-side — install the dual variant and flip
on demand:
git clone https://github.com/Fishason/DSSH.git ~/dssh-repo
bash ~/dssh-repo/tools/install_whisper_dual.shThe dual install still defaults to track=api; flip to local only when
needed:
dssh-whisper switch local # next START press → self-hosted whisper.cpp
dssh-whisper switch api # next START press → OpenRouter (default)
dssh-whisper switch # no arg = toggle| Button | Function | Notes |
|---|---|---|
| A | Enter (EN) / emit pinyin buffer as English (IME) | Accidentally typed English in CN mode? Press A and the buffer flies to SSH as raw ASCII — no need to backspace and switch modes. |
| B | Backspace / consume one pinyin letter | Hold-style auto-repeat (peaks at 60 / sec) |
| X | Alt modifier | Hold-style — held when the next key fires |
| Y | Ctrl modifier | Hold-style — Y + tap c → Ctrl-C |
| L | Shift modifier / + Circle Pad → right pane | See tmux split scrolling below |
| R | Toggle CN/EN input mode | Top-right ENG/CHN reflects the current mode |
| SELECT | Esc | Tap fires immediately |
| START | Voice input toggle | Press once to start recording; press again to stop and transcribe. See Voice input. |
| Space (soft keyboard) | Plain space (EN) / commit highlighted candidate (IME) | Matches sogou / fcitx convention |
| Shift + . | 。 (full-width Chinese period, U+3002) | Works in both EN and CN modes |
| D-pad ↑↓ | Arrow keys / IME page nav | When the IME buffer is active, ↑↓ paginates candidates |
| D-pad ←→ | Arrow keys / IME selection cursor | When active, ←→ moves the candidate cursor within the page |
| Circle Pad ↑↓ | Scrollback / tmux mouse-wheel | Default targets the left/top pane; hold L → right/bottom pane |
Long-press D-pad or B: 250 ms initial delay, ramps up to 12 / sec at 0.5 s, peaks at 60 / sec after 1.5 s.
The 3DS has no real cursor, so tmux's mouse-wheel events get routed by
the (col, row) we send. DSSH defaults to (1, 1) → hits the
left/top pane; holding L sends (60, 12) → hits the right/bottom
pane. In a vertical-split tmux:
| Action | Effect |
|---|---|
| Circle Pad ↑↓ | Scrolls the left pane |
| L held + Circle Pad ↑↓ | Scrolls the right pane |
The bottom screen is the soft keyboard — two pages:
- Letters page (default): QWERTY layout with
,.punctuation, Tab, and a wide Space. - Symbols page (toggle via the bottom-left
123key):1234567890,!@#$%^&*()cleanly aligned on two rows, plus other common punctuation including?and\.
Any key supports hold-style modifier combos. Example:
hold Y + tap b = Ctrl-B (the tmux prefix).
┌──────────────────────────────────────────────────────┐
│ [SFT] candidate strip / pinyin buffer / cands [CHN]│
└──────────────────────────────────────────────────────┘
- Left slot [STA]: 3-letter modifier indicator (SFT/CTL/ALT stays lit
while held; ENT/BSP/ESC/
R→Cflashes for 200 ms on transient events). - Middle: pinyin buffer + candidates in CN mode; empty in EN mode.
- Right slot [ENG/CHN]: current IME mode. Double-tap to enter the debug page.
Tapping letters in CN mode brings up the candidate strip:
ni → 年 你 牛奶 娘 念 (page 1/52, total 256)
nihao → 你好 你好吗 你好啊 拟好 你好呀
shijie → 世界 世界上 世界杯 世界各地 世界里
- A or Space commits the currently highlighted candidate.
- D-pad ←→ moves the highlight within the current page.
- D-pad ↑↓ flips between pages.
- Tap a candidate to commit it directly.
- B consumes one letter from the pinyin buffer.
Every multi-syllable word gets an extra entry keyed by its initials — typing the initials still surfaces it (with weight × 0.3, so the full-pinyin form still ranks first when typed in full):
nh → 你好 (around the 8th candidate)
wm → 我们 (top candidate)
sj → 世界
zw → 中文
xx → 谢谢
Page or cursor over to your target, then commit with A.
Typed an extra letter past a valid prefix? The engine automatically matches the longest valid prefix and shows the surplus letters in red:
buffer: niha[oz] ← niha in green + oz in red
candidates: still showing what nihao would produce
Press B to chew the red tail back to a clean prefix.
In CN mode, hold Y + tap c still sends Ctrl-C; hold L + tap a
still sends A. Modifiers take priority over IME routing, so
vim / tmux / claude-code shortcuts keep working.
CN mode + non-empty buffer + press A = the buffer flies to SSH as
raw ASCII letters and clears. Example: you accidentally typed
cd /etc while in CN mode and the candidate strip is showing strange
Chinese. One press of A delivers cd /etc to the shell — no
backspacing, no mode-toggle, no retyping.
Difference: Space commits the highlighted candidate (Chinese chars on screen). A sends the typed letters as-is.
Double-tap the ENG/CHN badge in the top-right corner (two taps within 500 ms) to enter the debug overlay. Single-tap the badge again to leave.
What it shows:
- Title + exit hint.
- recv hex: the last 32 bytes received from SSH — for diagnosing ANSI / SCS / mouse-protocol issues at the byte level.
- Physical key cheat sheet: a condensed version of the bindings table above.
- MASCOT: ON/OFF toggle button. Default is ON.
- Linux x86_64 (tested on Ubuntu 22.04; other distros need the obvious package-name adjustments).
- devkitPro / devkitARM release 65+, GCC 14.2.0.
- Python 3.10+ with Pillow (for font + dictionary generators).
# 1. Install devkitPro
wget https://apt.devkitpro.org/install-devkitpro-pacman
bash install-devkitpro-pacman
sudo dkp-pacman -S 3ds-dev 3ds-mbedtls 3ds-libpng 3ds-zlib
# 2. Clone + cd
git clone https://github.com/Fishason/DSSH.git
cd DSSH
# 3. Cross-compile libssh2 (one-time, drops into $DEVKITPRO/portlibs/3ds/lib/)
bash build-libssh2.sh
# 4. Install system fonts (Terminus provides ASCII / box-drawing)
sudo apt install fonts-terminus
# 5. Fetch font sources (Zpix)
bash tools/fetch_fonts.sh
# 6. Generate the font atlas (→ source/font_data.c, ~3 MB)
python3 tools/gen_font.py
# 7. Fetch + build the pinyin dictionary (→ romfs/pinyin_dict.bin, ~13 MB)
bash tools/fetch_pinyin_dict.sh
python3 tools/gen_pinyin_dict.py
# 8. Build the .3dsx
make
# 9. (Optional) build the .cia
bash tools/install_cia_tools.sh # installs bannertool + makerom into ~/bin
make cia # → DSSH.ciamake test-imeCompiles tools/test_ime.c linked against source/ime_pinyin.c and
runs nine smoke-test queries (ni → 你, nihao → 你好, nh → 你好,
etc.).
DSSH/
├── 69633.PNG # Source icon (162×102)
├── icon.png # 48×48 icon for .3dsx / SMDH (derived)
├── app.rsf # makerom CIA spec
├── Makefile # Top-level build (make / make cia / make test-ime)
├── build-libssh2.sh # libssh2 + mbedTLS ARM cross-compile
├── source/
│ ├── main.c # Main loop, SSH receive, UTF-8 reassembly
│ ├── ssh_client.{c,h} # libssh2 wrapper
│ ├── config.{c,h} # SD-card config.ini parser
│ ├── terminal.{c,h} # ANSI/VT100 parser (forked from skmtrd)
│ ├── renderer.{c,h} # citro2d rendering (terminal, text, CJK)
│ ├── keyboard.{c,h} # Physical buttons + IME routing
│ ├── softkb.{c,h} # Soft keyboard + candidate strip + debug page
│ ├── ime_pinyin.{c,h} # Pinyin engine
│ ├── mascot.{c,h} # Crab mascot
│ ├── font_atlas.{c,h} # Codepoint → glyph index
│ └── font_data.c # Font bitmaps (gen_font.py output)
├── tools/
│ ├── fetch_fonts.sh # Download Zpix
│ ├── gen_font.py # Font atlas generator
│ ├── fetch_pinyin_dict.sh # Download rime-ice
│ ├── gen_pinyin_dict.py # Dictionary → binary
│ ├── test_ime.{c,sh} # Host-side IME smoke test
│ ├── gen_cia_assets.py # Icon / banner derivation
│ └── install_cia_tools.sh # bannertool + makerom installer
├── romfs/ # gitignored — packs pinyin_dict.bin
├── data/ # gitignored — font + dict sources
└── sd_template/ # SD-card deployment template
├── README.md
└── 3ds/3dssh/config.ini.example
SSH server (somewhere on the internet)
▲ libssh2 over mbedTLS-RSA-4096
│
┌────┴──────────────────────────────────────────────────┐
│ main.c poll loop @ 60 fps │
│ ├─ ssh_read → softkb_record_recv → utf8 reassemble │
│ │ ↓ │
│ │ terminal_write_n → ANSI parser → cell grid │
│ ├─ hidScanInput → keyboard_handle_input │
│ │ └─ IME mode? → ime_input_letter / page / select │
│ ├─ hidTouchRead → softkb_touch │
│ │ ├─ candidate strip hit → ime_select │
│ │ ├─ key hit → keyboard_emit_for / ime_input │
│ │ └─ badge double-tap → debug_mode toggle │
│ └─ render: top = renderer_draw_terminal │
│ bot = softkb_draw + clock + mascot │
└───────────────────────────────────────────────────────┘
│
citro2d (3DS 2D rendering)
│
GPU (top 400×240 + bottom 320×240, 24-bit color)
The build went through milestones M0 → M9; see the commit history for the full progression.
- skmtrd/3dssh — the original Japanese-localized 3DS SSH client; DSSH reuses its ANSI/VT100 parser, UTF-8 reassembly, and citro2d framing.
- rime-ice — pinyin dictionary
source (pinned at commit
3f57a6f6). - Zpix Pixel Font — 12 px CJK pixel font (OFL 1.1).
- Terminus TTF — ASCII and box-drawing pixel font.
- libssh2 + mbedTLS — SSH / TLS protocol stack.
- devkitPro libctru / citro2d / citro3d — 3DS user-mode runtime and rendering.
- carstene1ns/3ds-bannertool
- 3DSGuy/Project_CTR makerom — CIA packaging tools.
MIT — see LICENSE.
The bundled fonts, dictionary, and upstream SSH/TLS libraries each have their own licenses (OFL / GPL / BSD / MIT / Apache). Respect those when redistributing the binary.