Game Testing Skill — Project VELES
You are testing a 3DS homebrew tactical game running in the Azahar emulator (Citra fork) inside a Docker container, streamed via Selkies-GStreamer. You control the game through WebSocket input commands, validate state via UDP memory reads, and capture screenshots via browserless (headless Chrome CDP).
Architecture Overview
┌─────────────────┐ WebSocket (kd/ku) ┌──────────────────────┐
│ Test Agent │ ──────────────────────────▶│ Selkies-GStreamer │
│ (this agent) │ │ ws://10.0.0.100:3300│
│ │ UDP port 45987 │ │ │
│ Python 3.12 │ ──────────────────────────▶│ Azahar Emulator │
│ via `uv run` │ │ (3DS emulation) │
│ │ HTTPS (CDP) │ │ │
│ │ ──────────────────────────▶│ Selkies HTTPS :3301 │
└─────────────────┘ └──────────────────────┘
│ │
│ browserless CDP (ws://10.0.0.100:3000) │
└──────────────────────────────────────────────┘
Screenshot capture
Quick Start
# 1. Ensure Python is available
source tools/ensure-python.sh
# 2. Run a test script
uv run --with websocket-client python3.12 qa/test_phaa33.py
# 3. Or use the harness interactively
uv run --with websocket-client python3.12 -c "
import sys; sys.path.insert(0, 'qa')
from azahar_harness import AzaharHarness
h = AzaharHarness()
h.connect()
h.press_button('A') # Press A button
h.circle_pad('RIGHT') # Push circle pad right
h.tap_tile(160, 120) # Tap bottom screen center
h.screenshot('screenshot.png') # Capture display
h.disconnect()
"
Critical Knowledge (Read This First)
Things that WILL trip you up
START exits the game.
main.c:92mapsKEY_STARTtobreak(quit main loop). The HBL bootstrap shows "Press START or Touch to Begin" — pressing START kills the game. Use A button to enter the game from the HBL title screen.The HBL title screen is NOT our game. When the .3dsx loads, the Homebrew Launcher shows a title card ("PROJECT VELES / Press START or Touch to Begin"). Press A to get past it into the actual game. The game itself starts immediately with the isometric grid — no title screen in our code.
If the game exits, you must reload it. Send Ctrl+O via WebSocket to open the file dialog, type
project-veles.3dsx, press Enter. Seescripts/reload_rom.py.Selkies requires HTTPS for screenshots. Port 3300 is HTTP but the Selkies JS rejects it ("requires secure connection"). Use port 3301 (HTTPS) with CDP cert bypass (
Security.setIgnoreCertificateErrors). The harnessscreenshot()method handles this.UDP scripting (memory reads) is unreliable for .3dsx files.
process_list()returns 0 bytes even with the game running. Memory reads at 0x100000 return ARM9 bootrom (060000ea= branch instruction), not game state. Use screenshots for verification, not memory reads.WebSocket connections drop after ~30s of heavy input. The Selkies WebSocket resets under sustained sequential commands. Add reconnect logic or space out inputs.
browserless needs
websocket-clientpackage. Always run scripts with:uv run --with websocket-client python3.12 <script>
Connection Details
| Service | Endpoint | Protocol |
|---|---|---|
| Selkies WebSocket (input) | ws://10.0.0.100:3300/websockets |
WebSocket text frames |
| Selkies HTTPS (screenshots) | https://10.0.0.100:3301/ |
HTTPS (self-signed cert) |
| Browserless CDP | ws://10.0.0.100:3000 |
Chrome DevTools Protocol |
| Azahar UDP scripting | azahar:45987 (Docker DNS) |
UDP, binary protocol |
| Shared ROM volume | /paperclip/roms/ |
Local filesystem |
| ROM in container | /config/Desktop/project-veles.3dsx |
Container filesystem |
The Test Harness (qa/azahar_harness.py)
Key Classes
AzaharHarness— Main test interface. Combines WebSocket input, UDP memory access, and screenshot capture.AzaharScripting— UDP client for Azahar's memory read/write API.SimpleWebSocket— Minimal WebSocket client (no external deps for input — only screenshots needwebsocket-client).
Button Input
h.press_button("A") # Press and release (80ms default)
h.press_button("B", duration=0.2) # Longer press
h.hold_button("L") # Hold down (no release)
h.release_button("L") # Release held button
Available buttons: A, B, X, Y, DPAD_LEFT, DPAD_RIGHT, DPAD_UP, DPAD_DOWN, L, R, ZR, START, SELECT, HOME, POWER, DEBUG
Analog Input
h.circle_pad("RIGHT", duration=0.1) # Push circle pad
h.circle_pad("UP_LEFT", duration=0.15) # Diagonal
h.c_stick("DOWN", duration=0.1) # C-stick
Directions: LEFT, RIGHT, UP, DOWN, UP_LEFT, UP_RIGHT, DOWN_LEFT, DOWN_RIGHT
Touch Screen
# Tap at 3DS bottom screen coordinates (0-319, 0-239)
h.tap_tile(160, 120) # Center of bottom screen
h.touch_drag(50, 50, 200, 200) # Drag gesture
The harness auto-converts 3DS coordinates to Selkies display coordinates using the computed screen layout.
Screenshots
# Requires: uv run --with websocket-client
h.screenshot("qa/screenshots/my_test.png")
h.screenshot("qa/screenshots/my_test.png", wait_secs=5) # Wait longer for page load
Screenshots capture the full Selkies desktop including the Azahar window chrome (title bar, menu). The game display is centered within this.
ROM Management
# If game exits, reload via Azahar File menu
# See scripts/reload_rom.py for the full reload sequence
Screen Layout
Display: 1224 x 1274 pixels (Selkies virtual desktop)
┌──────────────────────────────────┐
│ Azahar Title Bar │ ~50px
├──────────────────────────────────┤
│ │
│ ┌────────────────────┐ │
│ │ TOP SCREEN │ │ 3DS: 400x240
│ │ (game view) │ │ Scaled: ~1062x637
│ │ - isometric grid │ │
│ │ - unit sprites │ │
│ └────────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ BOTTOM SCREEN │ │ 3DS: 320x240
│ │ (UI/touch) │ │ Scaled: ~850x637
│ │ - action buttons│ │
│ │ - FPS counter │ │
│ └──────────────────┘ │
│ │
└──────────────────────────────────┘
Scale factor: min(1224/400, 1274/480) = 2.654
Touch coordinates are in 3DS bottom screen space (0-319, 0-239). The harness converts them:
sel_x = BOTTOM_SCREEN_X + int(ds_x * BOTTOM_SCREEN_W / 320)
sel_y = BOTTOM_SCREEN_Y + int(ds_y * BOTTOM_SCREEN_H / 240)
WebSocket Protocol (Selkies Legacy Mode)
Input commands are plain text WebSocket messages:
kd,<keycode> — Key down
ku,<keycode> — Key up
m,<x>,<y>,0,0,0 — Mouse move
md,<x>,<y>,0,0,1 — Mouse button down (1=left)
mu,<x>,<y>,0,0,1 — Mouse button up
Keycodes are X11 keysym values. The full mapping is in references/keymap.md.
Screenshot Pipeline (Browserless CDP)
The screenshot method uses Chrome DevTools Protocol via browserless:
- Connect to
ws://10.0.0.100:3000(browserless CDP) Target.createTarget→ new browser tabTarget.attachToTarget→ get session IDSecurity.setIgnoreCertificateErrors→ bypass self-signed certPage.navigate→https://10.0.0.100:3301/- Wait 2-3s for WebRTC stream to initialize
Page.captureScreenshot→ base64 PNG- Decode and save
The harness screenshot() method encapsulates all of this.
Writing Test Scripts
Template
"""Test: [description]"""
import sys, os, time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'qa'))
from azahar_harness import AzaharHarness
SS_DIR = os.path.join(os.path.dirname(__file__), '..', 'qa', 'screenshots')
os.makedirs(SS_DIR, exist_ok=True)
h = AzaharHarness()
h.connect()
# Always verify game is running (not on title screen or ROM browser)
h.screenshot(f'{SS_DIR}/00_initial_state.png')
# VISUAL CHECK: Should show isometric grid with unit sprites
# If showing title screen: h.press_button('A'); time.sleep(2)
# If showing ROM browser: see scripts/reload_rom.py
# --- Your test steps here ---
h.press_button('A')
time.sleep(0.5)
h.screenshot(f'{SS_DIR}/01_after_action.png')
h.disconnect()
Best Practices
- Always take a baseline screenshot first to verify game state
- Wait after inputs — the game runs at 60fps, give it time to process (0.3-0.5s for simple actions, 1-2s for animations)
- Space out WebSocket commands — rapid-fire commands cause connection resets
- Use
time.sleep(0.05)between sequential inputs to avoid drops - Check screenshots visually — read the PNG file to verify what actually happened
- Don't press START unless you want to exit the game
- Report honestly — if you can't verify something visually, say so
What You Can Verify
| Method | What it verifies | Reliability |
|---|---|---|
| Screenshots | Visual state, UI layout, grid rendering, unit positions | HIGH |
| Input + screenshot diff | Camera movement, button response, touch response | HIGH |
| UDP memory reads | Game state structs (when available) | LOW for .3dsx |
| FPS counter in screenshot | Performance | HIGH |
| Emulator log | Crashes, errors, warnings | MEDIUM |
What You Cannot Verify
- Frame-perfect timing (screenshots have 2-3s latency from CDP pipeline)
- Smooth animation quality (only static frames, not motion)
- Audio
- 3D stereoscopic depth
- Exact sub-pixel rendering
Common Test Scenarios
Camera Scroll Verification
# 1. Take baseline screenshot
# 2. Send circle pad input (e.g., 8x RIGHT at 0.15s each)
# 3. Take screenshot
# 4. Compare: grid should be shifted, different tiles/units visible
Tile Selection
# 1. Tap a tile on the top screen via bottom screen coordinates
# 2. Screenshot should show pulsing selection highlight on that tile
Unit Movement
# 1. Tap "Move" button on bottom screen
# 2. Tap destination tile on top screen
# 3. Wait 1-2s for movement animation
# 4. Screenshot should show unit at new position
Camera Auto-Center (L button)
# 1. Select a tile (tap it)
# 2. Scroll camera away
# 3. Press L
# 4. Wait 0.3s (18 frames)
# 5. Camera should snap back to selected tile
File Structure
skills/game-testing/
├── skill.md # This file — comprehensive guide
├── references/
│ ├── keymap.md # Full X11 keycode mapping (3DS → keyboard → keycode)
│ ├── screen-layout.md # Display dimensions, coordinate conversion
│ ├── selkies-protocol.md # WebSocket message format
│ ├── udp-scripting.md # Azahar memory read/write protocol
│ └── gotchas.md # Every pitfall we've discovered
└── scripts/
├── reload_rom.py # Reload game after exit
├── verify_boot.py # Verify game is loaded and running
└── test_camera.py # Camera scroll + L-button verification