A lightweight local network content delivery and installation system for Nintendo 3DS consoles. It dynamically extracts Title IDs on a host Python server, streams .cia files over HTTP using libcurl, and commits them directly to SD card storage via libctru Application Manager (AM) services, bypassing manual SD card file transfers.
- Direct Network Streaming: Streams
.ciapayloads straight into system storage without requiring intermediary staging space on the SD card. - Dynamic Title ID Extraction: Host server reads CTR-CIA headers (TMD and Ticket chunks) on the fly to accurately extract 64-bit Title IDs.
- Buffered 128 KB Aligned Writes: Implements page-aligned memory buffers for
FSFILE_WriteIPC calls, preventing cluster allocation faults and media full errors. - Automatic Collision & Overwrite Handling: Automatically cleans lingering staging files (
AM_DeletePendingTitle) and registered titles prior to installation. - Real-Time HUD: Features a dynamic ANSI progress bar, percentage tracker, downloaded megabytes counter, and live network transfer throughput (KB/s or MB/s).
- Manual Title Management: Single-button title and ticket purging directly from the browser file list using the [X] key.
- Python 3.8+
- Flask:
pip install flask
- devkitPro & devkitARM toolchain with MSYS2.
- Required 3DS development packages installed inside the devkitPro MSYS2 shell:
pacman -S 3ds-curl 3ds-jansson 3ds-zlib 3ds-bzip2 3ds-mbedtls
- Custom Firmware (Luma3DS).
- Title Takeover configured: To acquire necessary
am:u/am:apppermissions for title installation, the Homebrew Launcher must be launched via Title Takeover (e.g., using Download Play) rather than standard Applet Mode.
Create a file named server.py on your host machine:
import os
import struct
from flask import Flask, jsonify, request, send_file
app = Flask(__name__)
GAMES_DIR = "C:/path/to/your/games"
TID_CACHE = {}
def extract_cia_title_id(filepath):
if filepath in TID_CACHE:
return TID_CACHE[filepath]
tid_result = "0"
try:
with open(filepath, "rb") as f:
header_chunk = f.read(0x20000)
if len(header_chunk) >= 0x20:
u32s = struct.unpack_from("<5I", header_chunk, 0)
header_size = u32s[0]
cert_size = u32s[2]
ticket_size = u32s[3]
tmd_size = u32s[4]
align = lambda x: (x + 0x3F) & ~0x3F
aligned_header = align(header_size)
aligned_cert = align(cert_size)
aligned_ticket = align(ticket_size)
if ticket_size > 0:
ticket_offset = aligned_header + aligned_cert
target = ticket_offset + 0x1DC
if len(header_chunk) >= target + 8:
tid_bytes = header_chunk[target : target + 8]
tid_hex = f"{struct.unpack('>Q', tid_bytes)[0]:016X}"
if tid_hex.startswith("0004"):
tid_result = tid_hex
if tid_result == "0" and tmd_size > 0:
tmd_offset = aligned_header + aligned_cert + aligned_ticket
target = tmd_offset + 0x18C
if len(header_chunk) >= target + 8:
tid_bytes = header_chunk[target : target + 8]
tid_hex = f"{struct.unpack('>Q', tid_bytes)[0]:016X}"
if tid_hex.startswith("0004"):
tid_result = tid_hex
except Exception as e:
print(f"[!] Header error on {os.path.basename(filepath)}: {e}")
TID_CACHE[filepath] = tid_result
return tid_result
@app.route("/api/browse")
def browse():
rel_path = request.args.get("path", "").strip("/\\")
full_path = os.path.normpath(os.path.join(GAMES_DIR, rel_path))
if not os.path.abspath(full_path).startswith(os.path.abspath(GAMES_DIR)):
return jsonify({"items": [], "error": "Invalid path"}), 400
if not os.path.isdir(full_path):
return jsonify({"items": []}), 200
items = []
try:
entries = sorted(os.scandir(full_path), key=lambda e: (not e.is_dir(), e.name.lower()))
for entry in entries:
if entry.name.startswith("."):
continue
item_rel = os.path.relpath(entry.path, GAMES_DIR).replace("\\", "/")
if entry.is_dir():
items.append({
"name": entry.name,
"type": "directory",
"path": item_rel,
"size_mb": 0.0,
"title_id": "0"
})
elif entry.name.lower().endswith(".cia"):
try:
size_mb = round(entry.stat().st_size / (1024 * 1024), 2)
except OSError:
size_mb = 0.0
tid = extract_cia_title_id(entry.path)
items.append({
"name": entry.name,
"type": "file",
"path": item_rel,
"size_mb": size_mb,
"title_id": tid
})
except Exception as e:
print(f"[!] Scan error: {e}")
return jsonify({"items": [], "error": str(e)}), 500
return jsonify({"items": items}), 200
@app.route("/download")
def download():
rel_path = request.args.get("path", "").strip("/\\")
full_path = os.path.normpath(os.path.join(GAMES_DIR, rel_path))
if not os.path.abspath(full_path).startswith(os.path.abspath(GAMES_DIR)):
return "Forbidden", 403
if not os.path.isfile(full_path):
return "File Not Found", 404
return send_file(full_path, as_attachment=True, conditional=True)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, threaded=True)Ensure your SERVER_URL points to your host PC's local IP address. Build using standard devkitPro makefiles.
- Open the devkitPro MSYS2 terminal and navigate to your project workspace:
cd /c/path/to/3ds-shop - Compile the binary:
make clean make
- Transfer the compiled
3ds-shop.3dsxto your 3DS SD card:sdmc:/3ds/3ds-shop/3ds-shop.3dsx
- Start the server on your host machine:
python server.py
- Connect your Nintendo 3DS to the same local Wi-Fi network.
- Launch Download Play on the 3DS.
- Open the Rosalina Menu (
L + D-Pad Down + Select) -> Miscellaneous options -> Switch the hb. title to the current app. - Exit Rosalina, close Download Play, and relaunch Download Play to access the Homebrew Launcher with elevated privileges.
- Launch 3DS Local Shop.
- Controls:
- D-Pad Up / Down: Navigate directories and game selections.
- [A]: Enter directory / Download & Install selected
.cia. - [B]: Navigate back up one directory level.
- [X]: Delete installed title, tickets, and staging remnants.
- [START]: Exit client.
- Cause: The title is already registered or an uncommitted temporary staging file remains from an interrupted transfer[cite: 1, 2, 3].
- Resolution: Highlight the file and press [X] to invoke
AM_DeletePendingTitle, or reboot the 3DS console to clear open file descriptor locks.
- Cause: Permission mismatch or invalid authorization level in
am:u/am:app[cite: 4]. - Resolution: Ensure the Homebrew Launcher is launched via Title Takeover (e.g., Download Play) rather than applet mode.
- Cause: Storage media full or unaligned IPC block writes exhausting cluster allocation maps.
- Resolution: Addressed in client builds via 128 KB page-aligned heap buffering (
memalign). Ensure your SD card has adequate free blocks and verify cluster formatting if persistent.
- Cause: HTTP communication failure or network timeout.
- Resolution: Verify local network connectivity, confirm your PC's firewall allows incoming traffic on port
5000, and test the browse endpoint viacurl http://<HOST_IP>:5000/api/browse?path=.