Browse Source

Merge pull request #1 from ian-h-chamberlain/feature/citra-xvfb

pull/7/head
Ian Chamberlain 1 year ago committed by GitHub
parent
commit
9ac325178a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .dockerignore
  2. 12
      .github/actions/citra/action.yml
  3. 46
      .github/actions/setup/action.yml
  4. 84
      .github/workflows/ci.yml
  5. 17
      .gitignore
  6. 14
      Cargo.toml
  7. 34
      Dockerfile
  8. 27
      README.md
  9. 496
      citra/qt-config.ini
  10. 26
      docker-compose.yml
  11. 20
      docker/citra.dockerfile
  12. 15
      docker/download_citra.sh
  13. 10
      docker/driver.dockerfile
  14. 50
      docker/entrypoint.sh
  15. 12
      docker/sdl2-config.ini
  16. 13
      docker/test-runner.gdb
  17. BIN
      driver/citra-controls.png
  18. BIN
      driver/citra-hotkeys.png
  19. 16
      driver/main.vdo
  20. 8
      driver/vncdo.sh
  21. 20
      run.sh
  22. 51
      src/console.rs
  23. 48
      src/gdb.rs
  24. 158
      src/lib.rs
  25. 27
      src/socket.rs
  26. 13
      tests/integration.rs

2
.dockerignore

@ -0,0 +1,2 @@
**
!docker/

12
.github/actions/citra/action.yml

@ -0,0 +1,12 @@
name: 'Run 3DS Executable'
description: 'Run a given 3DS executable with citra and GDB'
inputs:
executable:
description: >
The 3DS executable(s) to run. Globs and space separated lists allowed.
required: true
runs:
using: docker
image: ../../../Dockerfile
args:
- ${{ inputs.executable }}

46
.github/actions/setup/action.yml

@ -0,0 +1,46 @@
name: Setup Rust3DS
description: Set up CI environment for Rust + 3DS development
inputs:
toolchain:
description: The Rust toolchain to use for the steps
required: true
default: nightly
runs:
using: composite
steps:
# https://github.com/nektos/act/issues/917#issuecomment-1074421318
- if: ${{ env.ACT }}
shell: bash
name: Hack container for local development
run: |
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt-get install -y nodejs
- name: Setup default Rust toolchain
# Use this helper action so we get matcher support
# https://github.com/actions-rust-lang/setup-rust-toolchain/pull/15
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: clippy, rustfmt, rust-src
toolchain: ${{ inputs.toolchain }}
- name: Set up Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: rust3ds
- name: Install build tools for host
shell: bash
run: sudo apt-get update && sudo apt-get install -y build-essential
- name: Install cargo-3ds
shell: bash
# TODO: replace with crates.io version once published
run: cargo install --git https://github.com/rust3ds/cargo-3ds --branch feature/verbose-flag
- name: Set PATH to include devkitARM
shell: bash
# For some reason devkitARM/bin is not part of the default PATH in the container
run: echo "${DEVKITARM}/bin" >> $GITHUB_PATH

84
.github/workflows/ci.yml

@ -0,0 +1,84 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
env:
# actions-rust-lang/setup-rust-toolchain sets some default RUSTFLAGS, which we don't want to use
RUSTFLAGS: ""
jobs:
lint:
strategy:
matrix:
toolchain:
- nightly-2023-06-01
runs-on: ubuntu-latest
container: devkitpro/devkitarm
steps:
- name: Checkout branch
uses: actions/checkout@v2
- uses: ./.github/actions/setup
with:
toolchain: ${{ matrix.toolchain }}
- name: Check formatting
run: cargo fmt --all --verbose -- --check
- name: Run clippy
# We have to build the test crate here since it's not included in build-std by default
run: cargo 3ds clippy -Zbuild-std=std,test --color=always --verbose --all-targets
test:
strategy:
matrix:
toolchain:
# Oldest supported nightly
- nightly-2023-06-01
- nightly
continue-on-error: ${{ matrix.toolchain == 'nightly' }}
runs-on: ubuntu-latest
container: devkitpro/devkitarm
steps:
- name: Checkout branch
uses: actions/checkout@v3
- uses: ./.github/actions/setup
with:
toolchain: ${{ matrix.toolchain }}
- name: Build lib tests
run: cargo 3ds test --no-run --lib
- name: Build integration tests
run: cargo 3ds test --no-run --test integration
- name: Build doc tests
run: cargo 3ds test --no-run --doc
- name: Run lib + integration tests
uses: ./.github/actions/citra
with:
executable: ./target/armv6k-nintendo-3ds/debug/deps/*.elf
# TODO: run doc tests. We might be able to do something with e.g.
# cargo's "runner" configuration, but it seems we also need a test
# runtime and stuff for that to work.
- name: Upload citra logs and capture videos
uses: actions/upload-artifact@v3
if: success() || failure()
with:
name: citra-logs-${{ matrix.toolchain }}
path: |
target/armv6k-nintendo-3ds/debug/deps/*.txt
target/armv6k-nintendo-3ds/debug/deps/*.webm

17
.gitignore vendored

@ -1,3 +1,14 @@
# Ignore files generated by citra or the VNC client # Generated by Cargo
citra/out # will have compiled files and executables
driver/out debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb

14
Cargo.toml

@ -0,0 +1,14 @@
[package]
name = "test-runner"
version = "0.1.0"
edition = "2021"
[features]
console = []
gdb = []
socket = []
[dependencies]
ctru-rs = { git = "https://github.com/rust3ds/ctru-rs" }
ctru-sys = { git = "https://github.com/rust3ds/ctru-rs" }
libc = "0.2.147"

34
Dockerfile

@ -0,0 +1,34 @@
FROM buildpack-deps:latest as builder
ARG CITRA_CHANNEL=nightly
ARG CITRA_RELEASE=1962
WORKDIR /tmp
COPY ./docker/download_citra.sh /usr/local/bin/download_citra
RUN apt-get update -y && apt-get install -y jq
RUN download_citra ${CITRA_CHANNEL} ${CITRA_RELEASE}
FROM devkitpro/devkitarm:latest as devkitarm
FROM ubuntu:latest
RUN --mount=type=cache,sharing=locked,target=/var/cache/apt \
apt-get update -y && \
apt-get install -y \
libswscale5 \
libsdl2-2.0-0 \
libavformat58 \
libavfilter7 \
xvfb
COPY --from=devkitarm /opt/devkitpro /opt/devkitpro
ENV PATH=/opt/devkitpro/devkitARM/bin:${PATH}
COPY --from=builder /tmp/citra.AppImage /usr/local/bin/citra
COPY ./docker/sdl2-config.ini /app/
COPY ./docker/test-runner.gdb /app/
COPY ./docker/entrypoint.sh /app/
WORKDIR /app
ENTRYPOINT [ "/app/entrypoint.sh" ]

27
README.md

@ -3,23 +3,14 @@
A set of tools for running automated Rust tests against Citra (3DS emulator). A set of tools for running automated Rust tests against Citra (3DS emulator).
## Usage ## Components
`./run.sh 3DSX_FILE` * `test-runner`: a Rust crate for writing tests for 3DS homebrew
* `Dockerfile`: builds a container for running test executables with Citra.
* GitHub Actions:
* `.github/actions/setup`: action for setting up the Rust 3DS toolchain in
workflows
* `.github/actions/citra`: action for running test executables with Citra in
workflows
## Goals <!-- TODO: usage section for github actions -->
* Docker container for manually running tests against Citra
* GitHub Action for running automated tests
* Rust testing framework (custom runner) for use with the 3ds
* (maybe) Acceptance testing framework or glue for one?
## Workflow / Notes
1. Build a test executable (type tbd)
1. `citra-emu` container: bind-mount test executable and choose it
1. `driver` container perform input / output as needed for test, via VNC
* possible extension: `3dslink -s` to get actual stdout/stderr (return code?)
* acceptance testing of images, hopefully via screenshot

496
citra/qt-config.ini

@ -1,496 +0,0 @@
[Audio]
enable_audio_stretching=true
enable_audio_stretching\default=true
enable_dsp_lle=false
enable_dsp_lle\default=true
enable_dsp_lle_multithread=false
enable_dsp_lle_multithread\default=true
mic_input_device=Default
mic_input_device\default=true
mic_input_type=0
mic_input_type\default=true
output_device=auto
output_device\default=true
output_engine=auto
output_engine\default=true
volume=@Variant(\0\0\0\x87?\x80\0\0)
volume\default=true
[Camera]
camera_inner_config=
camera_inner_config\default=true
camera_inner_flip=0
camera_inner_flip\default=true
camera_inner_name=blank
camera_inner_name\default=true
camera_outer_left_config=
camera_outer_left_config\default=true
camera_outer_left_flip=0
camera_outer_left_flip\default=true
camera_outer_left_name=blank
camera_outer_left_name\default=true
camera_outer_right_config=
camera_outer_right_config\default=true
camera_outer_right_flip=0
camera_outer_right_flip\default=true
camera_outer_right_name=blank
camera_outer_right_name\default=true
[Controls]
profile=0
profile\default=true
profiles\1\button_a="code:65,engine:keyboard"
profiles\1\button_a\default=true
profiles\1\button_b="code:83,engine:keyboard"
profiles\1\button_b\default=true
profiles\1\button_debug="code:79,engine:keyboard"
profiles\1\button_debug\default=true
profiles\1\button_down="code:71,engine:keyboard"
profiles\1\button_down\default=true
profiles\1\button_gpio14="code:80,engine:keyboard"
profiles\1\button_gpio14\default=true
profiles\1\button_home="code:66,engine:keyboard"
profiles\1\button_home\default=true
profiles\1\button_l="code:81,engine:keyboard"
profiles\1\button_l\default=true
profiles\1\button_left="code:70,engine:keyboard"
profiles\1\button_left\default=true
profiles\1\button_r="code:87,engine:keyboard"
profiles\1\button_r\default=true
profiles\1\button_right="code:72,engine:keyboard"
profiles\1\button_right\default=true
profiles\1\button_select="code:78,engine:keyboard"
profiles\1\button_select\default=true
profiles\1\button_start="code:77,engine:keyboard"
profiles\1\button_start\default=true
profiles\1\button_up="code:84,engine:keyboard"
profiles\1\button_up\default=true
profiles\1\button_x="code:90,engine:keyboard"
profiles\1\button_x\default=true
profiles\1\button_y="code:88,engine:keyboard"
profiles\1\button_y\default=true
profiles\1\button_zl="code:49,engine:keyboard"
profiles\1\button_zl\default=true
profiles\1\button_zr="code:50,engine:keyboard"
profiles\1\button_zr\default=true
profiles\1\c_stick="down:code$075$1engine$0keyboard,engine:analog_from_button,left:code$074$1engine$0keyboard,modifier:code$068$1engine$0keyboard,modifier_scale:0.500000,right:code$076$1engine$0keyboard,up:code$073$1engine$0keyboard"
profiles\1\c_stick\default=true
profiles\1\circle_pad="down:code$016777237$1engine$0keyboard,engine:analog_from_button,left:code$016777234$1engine$0keyboard,modifier:code$068$1engine$0keyboard,modifier_scale:0.500000,right:code$016777236$1engine$0keyboard,up:code$016777235$1engine$0keyboard"
profiles\1\circle_pad\default=true
profiles\1\motion_device="engine:motion_emu,update_period:100,sensitivity:0.01,tilt_clamp:90.0"
profiles\1\motion_device\default=true
profiles\1\name=default
profiles\1\name\default=true
profiles\1\touch_device=engine:emu_window
profiles\1\touch_device\default=true
profiles\1\touch_from_button_map=0
profiles\1\touch_from_button_map\default=true
profiles\1\udp_input_address=127.0.0.1
profiles\1\udp_input_address\default=true
profiles\1\udp_input_port=26760
profiles\1\udp_input_port\default=true
profiles\1\udp_pad_index=0
profiles\1\udp_pad_index\default=true
profiles\1\use_touch_from_button=false
profiles\1\use_touch_from_button\default=true
profiles\size=1
touch_from_button_maps\1\entries\size=0
touch_from_button_maps\1\name=default
touch_from_button_maps\1\name\default=true
touch_from_button_maps\size=1
[Core]
cpu_clock_percentage=100
cpu_clock_percentage\default=true
use_cpu_jit=true
use_cpu_jit\default=true
[Data%20Storage]
nand_directory=/root/.local/share/citra-emu/nand/
nand_directory\default=true
sdmc_directory=/root/.local/share/citra-emu/sdmc/
sdmc_directory\default=true
use_virtual_sd=true
use_virtual_sd\default=true
[Debugging]
LLE\AC=false
LLE\AC\default=true
LLE\ACT=false
LLE\ACT\default=true
LLE\AM=false
LLE\AM\default=true
LLE\BOSS=false
LLE\BOSS\default=true
LLE\CAM=false
LLE\CAM\default=true
LLE\CDC=false
LLE\CDC\default=true
LLE\CECD=false
LLE\CECD\default=true
LLE\CFG=false
LLE\CFG\default=true
LLE\CSND=false
LLE\CSND\default=true
LLE\DLP=false
LLE\DLP\default=true
LLE\DSP=false
LLE\DSP\default=true
LLE\ERR=false
LLE\ERR\default=true
LLE\FRD=false
LLE\FRD\default=true
LLE\FS=false
LLE\FS\default=true
LLE\GPIO=false
LLE\GPIO\default=true
LLE\GSP=false
LLE\GSP\default=true
LLE\HID=false
LLE\HID\default=true
LLE\HTTP=false
LLE\HTTP\default=true
LLE\I2C=false
LLE\I2C\default=true
LLE\IR=false
LLE\IR\default=true
LLE\LDR=false
LLE\LDR\default=true
LLE\MCU=false
LLE\MCU\default=true
LLE\MIC=false
LLE\MIC\default=true
LLE\MP=false
LLE\MP\default=true
LLE\MVD=false
LLE\MVD\default=true
LLE\NDM=false
LLE\NDM\default=true
LLE\NEWS=false
LLE\NEWS\default=true
LLE\NFC=false
LLE\NFC\default=true
LLE\NIM=false
LLE\NIM\default=true
LLE\NS=false
LLE\NS\default=true
LLE\NWM=false
LLE\NWM\default=true
LLE\PDN=false
LLE\PDN\default=true
LLE\PM=false
LLE\PM\default=true
LLE\PS=false
LLE\PS\default=true
LLE\PTM=false
LLE\PTM\default=true
LLE\PXI=false
LLE\PXI\default=true
LLE\QTM=false
LLE\QTM\default=true
LLE\SOC=false
LLE\SOC\default=true
LLE\SPI=false
LLE\SPI\default=true
LLE\SSL=false
LLE\SSL\default=true
gdbstub_port=24689
gdbstub_port\default=true
record_frame_times=false
use_gdbstub=false
use_gdbstub\default=true
[Layout]
custom_bottom_bottom=480
custom_bottom_bottom\default=true
custom_bottom_left=40
custom_bottom_left\default=true
custom_bottom_right=360
custom_bottom_right\default=true
custom_bottom_top=240
custom_bottom_top\default=true
custom_layout=false
custom_layout\default=true
custom_top_bottom=240
custom_top_bottom\default=true
custom_top_left=0
custom_top_left\default=true
custom_top_right=400
custom_top_right\default=true
custom_top_top=0
custom_top_top\default=true
factor_3d=0
factor_3d\default=true
filter_mode=true
filter_mode\default=true
layout_option=0
pp_shader_name=none (builtin)
pp_shader_name\default=true
render_3d=0
render_3d\default=true
swap_screen=false
swap_screen\default=true
upright_screen=false
upright_screen\default=true
[Miscellaneous]
log_filter=*:Info
log_filter\default=true
[Renderer]
bg_blue=0.545098066329956
bg_blue\default=false
bg_green=0.545098066329956
bg_green\default=false
bg_red=0.545098066329956
bg_red\default=false
frame_limit=100
frame_limit\default=true
frame_limit_alternate=200
frame_limit_alternate\default=true
resolution_factor=1
resolution_factor\default=true
shaders_accurate_mul=true
shaders_accurate_mul\default=true
texture_filter_name=none
texture_filter_name\default=true
use_disk_shader_cache=true
use_disk_shader_cache\default=true
use_frame_limit_alternate=false
use_frame_limit_alternate\default=true
use_hw_renderer=true
use_hw_renderer\default=true
use_hw_shader=true
use_hw_shader\default=true
use_shader_jit=true
use_shader_jit\default=true
use_vsync_new=true
use_vsync_new\default=true
[System]
init_clock=0
init_clock\default=true
init_time=946681277
init_time\default=true
is_new_3ds=true
is_new_3ds\default=true
region_value=-1
region_value\default=true
[UI]
GameList\hideNoIcon=false
GameList\hideNoIcon\default=true
GameList\iconSize=2
GameList\iconSize\default=true
GameList\row1=2
GameList\row1\default=true
GameList\row2=0
GameList\row2\default=true
GameList\singleLineMode=false
GameList\singleLineMode\default=true
Multiplayer\game_id=0
Multiplayer\game_id\default=true
Multiplayer\host_type=0
Multiplayer\host_type\default=true
Multiplayer\ip=
Multiplayer\ip\default=true
Multiplayer\ip_ban_list\size=0
Multiplayer\max_player=8
Multiplayer\max_player\default=true
Multiplayer\nickname=
Multiplayer\nickname\default=true
Multiplayer\port=24872
Multiplayer\port\default=true
Multiplayer\room_description=
Multiplayer\room_description\default=true
Multiplayer\room_name=
Multiplayer\room_name\default=true
Multiplayer\room_nickname=
Multiplayer\room_nickname\default=true
Multiplayer\room_port=24872
Multiplayer\room_port\default=true
Multiplayer\username_ban_list\size=0
Paths\gamedirs\1\deep_scan=false
Paths\gamedirs\1\deep_scan\default=true
Paths\gamedirs\1\expanded=true
Paths\gamedirs\1\expanded\default=true
Paths\gamedirs\1\path=INSTALLED
Paths\gamedirs\2\deep_scan=false
Paths\gamedirs\2\deep_scan\default=true
Paths\gamedirs\2\expanded=true
Paths\gamedirs\2\expanded\default=true
Paths\gamedirs\2\path=SYSTEM
Paths\gamedirs\size=2
Paths\language=en
Paths\language\default=false
Paths\moviePlaybackPath=
Paths\movieRecordPath=
Paths\recentFiles=@Invalid()
Paths\romsPath=
Paths\screenshotPath=
Paths\symbolsPath=
Paths\videoDumpingPath=
Shortcuts\Main%20Window\Advance%20Frame\Context=2
Shortcuts\Main%20Window\Advance%20Frame\Context\default=true
Shortcuts\Main%20Window\Advance%20Frame\KeySeq=\\
Shortcuts\Main%20Window\Advance%20Frame\KeySeq\default=true
Shortcuts\Main%20Window\Capture%20Screenshot\Context=2
Shortcuts\Main%20Window\Capture%20Screenshot\Context\default=true
Shortcuts\Main%20Window\Capture%20Screenshot\KeySeq=Ctrl+P
Shortcuts\Main%20Window\Capture%20Screenshot\KeySeq\default=true
Shortcuts\Main%20Window\Continue\Pause%20Emulation\Context=1
Shortcuts\Main%20Window\Continue\Pause%20Emulation\Context\default=true
Shortcuts\Main%20Window\Continue\Pause%20Emulation\KeySeq=F4
Shortcuts\Main%20Window\Continue\Pause%20Emulation\KeySeq\default=true
Shortcuts\Main%20Window\Decrease%20Speed%20Limit\Context=2
Shortcuts\Main%20Window\Decrease%20Speed%20Limit\Context\default=true
Shortcuts\Main%20Window\Decrease%20Speed%20Limit\KeySeq=-
Shortcuts\Main%20Window\Decrease%20Speed%20Limit\KeySeq\default=true
Shortcuts\Main%20Window\Exit%20Citra\Context=1
Shortcuts\Main%20Window\Exit%20Citra\Context\default=true
Shortcuts\Main%20Window\Exit%20Citra\KeySeq=Ctrl+Q
Shortcuts\Main%20Window\Exit%20Citra\KeySeq\default=true
Shortcuts\Main%20Window\Exit%20Fullscreen\Context=1
Shortcuts\Main%20Window\Exit%20Fullscreen\Context\default=true
Shortcuts\Main%20Window\Exit%20Fullscreen\KeySeq=Esc
Shortcuts\Main%20Window\Exit%20Fullscreen\KeySeq\default=true
Shortcuts\Main%20Window\Fullscreen\Context=1
Shortcuts\Main%20Window\Fullscreen\Context\default=true
Shortcuts\Main%20Window\Fullscreen\KeySeq=F11
Shortcuts\Main%20Window\Fullscreen\KeySeq\default=true
Shortcuts\Main%20Window\Increase%20Speed%20Limit\Context=2
Shortcuts\Main%20Window\Increase%20Speed%20Limit\Context\default=true
Shortcuts\Main%20Window\Increase%20Speed%20Limit\KeySeq=+
Shortcuts\Main%20Window\Increase%20Speed%20Limit\KeySeq\default=true
Shortcuts\Main%20Window\Load%20Amiibo\Context=2
Shortcuts\Main%20Window\Load%20Amiibo\Context\default=true
Shortcuts\Main%20Window\Load%20Amiibo\KeySeq=F2
Shortcuts\Main%20Window\Load%20Amiibo\KeySeq\default=true
Shortcuts\Main%20Window\Load%20File\Context=1
Shortcuts\Main%20Window\Load%20File\Context\default=true
Shortcuts\Main%20Window\Load%20File\KeySeq=Ctrl+O
Shortcuts\Main%20Window\Load%20File\KeySeq\default=true
Shortcuts\Main%20Window\Load%20from%20Newest%20Slot\Context=1
Shortcuts\Main%20Window\Load%20from%20Newest%20Slot\Context\default=true
Shortcuts\Main%20Window\Load%20from%20Newest%20Slot\KeySeq=Ctrl+V
Shortcuts\Main%20Window\Load%20from%20Newest%20Slot\KeySeq\default=true
Shortcuts\Main%20Window\Remove%20Amiibo\Context=2
Shortcuts\Main%20Window\Remove%20Amiibo\Context\default=true
Shortcuts\Main%20Window\Remove%20Amiibo\KeySeq=F3
Shortcuts\Main%20Window\Remove%20Amiibo\KeySeq\default=true
Shortcuts\Main%20Window\Restart%20Emulation\Context=1
Shortcuts\Main%20Window\Restart%20Emulation\Context\default=true
Shortcuts\Main%20Window\Restart%20Emulation\KeySeq=F6
Shortcuts\Main%20Window\Restart%20Emulation\KeySeq\default=true
Shortcuts\Main%20Window\Rotate%20Screens%20Upright\Context=1
Shortcuts\Main%20Window\Rotate%20Screens%20Upright\Context\default=true
Shortcuts\Main%20Window\Rotate%20Screens%20Upright\KeySeq=F8
Shortcuts\Main%20Window\Rotate%20Screens%20Upright\KeySeq\default=true
Shortcuts\Main%20Window\Save%20to%20Oldest%20Slot\Context=1
Shortcuts\Main%20Window\Save%20to%20Oldest%20Slot\Context\default=true
Shortcuts\Main%20Window\Save%20to%20Oldest%20Slot\KeySeq=Ctrl+C
Shortcuts\Main%20Window\Save%20to%20Oldest%20Slot\KeySeq\default=true
Shortcuts\Main%20Window\Stop%20Emulation\Context=1
Shortcuts\Main%20Window\Stop%20Emulation\Context\default=true
Shortcuts\Main%20Window\Stop%20Emulation\KeySeq=F5
Shortcuts\Main%20Window\Stop%20Emulation\KeySeq\default=true
Shortcuts\Main%20Window\Swap%20Screens\Context=1
Shortcuts\Main%20Window\Swap%20Screens\Context\default=true
Shortcuts\Main%20Window\Swap%20Screens\KeySeq=F9
Shortcuts\Main%20Window\Swap%20Screens\KeySeq\default=true
Shortcuts\Main%20Window\Toggle%20Alternate%20Speed\Context=2
Shortcuts\Main%20Window\Toggle%20Alternate%20Speed\Context\default=true
Shortcuts\Main%20Window\Toggle%20Alternate%20Speed\KeySeq=Ctrl+Z
Shortcuts\Main%20Window\Toggle%20Alternate%20Speed\KeySeq\default=true
Shortcuts\Main%20Window\Toggle%20Filter%20Bar\Context=1
Shortcuts\Main%20Window\Toggle%20Filter%20Bar\Context\default=true
Shortcuts\Main%20Window\Toggle%20Filter%20Bar\KeySeq=Ctrl+F
Shortcuts\Main%20Window\Toggle%20Filter%20Bar\KeySeq\default=true
Shortcuts\Main%20Window\Toggle%20Frame%20Advancing\Context=2
Shortcuts\Main%20Window\Toggle%20Frame%20Advancing\Context\default=true
Shortcuts\Main%20Window\Toggle%20Frame%20Advancing\KeySeq=Ctrl+A
Shortcuts\Main%20Window\Toggle%20Frame%20Advancing\KeySeq\default=true
Shortcuts\Main%20Window\Toggle%20Screen%20Layout\Context=1
Shortcuts\Main%20Window\Toggle%20Screen%20Layout\Context\default=true
Shortcuts\Main%20Window\Toggle%20Screen%20Layout\KeySeq=F10
Shortcuts\Main%20Window\Toggle%20Screen%20Layout\KeySeq\default=true
Shortcuts\Main%20Window\Toggle%20Status%20Bar\Context=1
Shortcuts\Main%20Window\Toggle%20Status%20Bar\Context\default=true
Shortcuts\Main%20Window\Toggle%20Status%20Bar\KeySeq=Ctrl+S
Shortcuts\Main%20Window\Toggle%20Status%20Bar\KeySeq\default=true
Shortcuts\Main%20Window\Toggle%20Texture%20Dumping\Context=2
Shortcuts\Main%20Window\Toggle%20Texture%20Dumping\Context\default=true
Shortcuts\Main%20Window\Toggle%20Texture%20Dumping\KeySeq=Ctrl+D
Shortcuts\Main%20Window\Toggle%20Texture%20Dumping\KeySeq\default=true
UILayout\gameListHeaderState=@ByteArray(\0\0\0\xff\0\0\0\0\0\0\0\x1\0\0\0\x1\0\0\0\x5\x1\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x4\x98\0\0\0\x5\x1\x1\0\x1\0\0\0\0\0\0\0\0\0\0\0\0h\xff\xff\xff\xff\0\0\0\x81\0\0\0\0\0\0\0\x5\0\0\x2\x80\0\0\0\x1\0\0\0\0\0\0\0h\0\0\0\x1\0\0\0\0\0\0\0h\0\0\0\x1\0\0\0\0\0\0\0h\0\0\0\x1\0\0\0\0\0\0\0\xe0\0\0\0\x1\0\0\0\0\0\0\x3\xe8\0\0\0\0h)
UILayout\geometry="@ByteArray(\x1\xd9\xd0\xcb\0\x3\0\0\0\0\0\xe3\0\0\0\x99\0\0\x4r\0\0\x2\x31\0\0\0\xe4\0\0\0\xad\0\0\x4q\0\0\x2,\0\0\0\0\0\0\0\0\x5V\0\0\0\xe4\0\0\0\xad\0\0\x4q\0\0\x2,)"
UILayout\geometryRenderWindow=@ByteArray(\x1\xd9\xd0\xcb\0\x3\0\0\0\0\0\0\0\0\0\x14\0\0\0\x63\0\0\0\x31\0\0\0\0\0\0\0\x14\0\0\0\x63\0\0\0\x31\0\0\0\0\0\0\0\0\x5V\0\0\0\0\0\0\0\x14\0\0\0\x63\0\0\0\x31)
UILayout\microProfileDialogGeometry=@ByteArray(\x1\xd9\xd0\xcb\0\x3\0\0\0\0\0\0\0\0\0\x14\0\0\x3\xe7\0\0\x2k\0\0\0\0\0\0\0\x14\0\0\x3\xe7\0\0\x2k\0\0\0\0\0\0\0\0\x5V\0\0\0\0\0\0\0\x14\0\0\x3\xe7\0\0\x2k)
UILayout\microProfileDialogVisible=false
UILayout\microProfileDialogVisible\default=true
UILayout\state=@ByteArray(\0\0\0\xff\0\0\0\0\xfd\0\0\0\x2\0\0\0\0\0\0\0\0\0\0\0\0\xfc\x2\0\0\0\x1\xfb\0\0\0\x1c\0W\0\x61\0i\0t\0T\0r\0\x65\0\x65\0W\0i\0\x64\0g\0\x65\0t\0\0\0\0\0\xff\xff\xff\xff\0\0\0V\0\xff\xff\xff\0\0\0\x1\0\0\0\0\0\0\0\0\xfc\x2\0\0\0\b\xfb\0\0\0\x18\0\x41\0R\0M\0R\0\x65\0g\0i\0s\0t\0\x65\0r\0s\0\0\0\0\0\xff\xff\xff\xff\0\0\0h\0\xff\xff\xff\xfb\0\0\0 \0G\0r\0\x61\0p\0h\0i\0\x63\0s\0\x44\0\x65\0\x62\0u\0g\0g\0\x65\0r\0\0\0\0\0\xff\xff\xff\xff\0\0\0V\0\xff\xff\xff\xfb\0\0\0\"\0P\0i\0\x63\0\x61\0 \0\x43\0o\0m\0m\0\x61\0n\0\x64\0 \0L\0i\0s\0t\0\0\0\0\0\xff\xff\xff\xff\0\0\0\x8d\0\xff\xff\xff\xfb\0\0\0*\0P\0i\0\x63\0\x61\0\x42\0r\0\x65\0\x61\0k\0P\0o\0i\0n\0t\0s\0W\0i\0\x64\0g\0\x65\0t\0\0\0\0\0\xff\xff\xff\xff\0\0\0\x8d\0\xff\xff\xff\xfb\0\0\0 \0P\0i\0\x63\0\x61\0V\0\x65\0r\0t\0\x65\0x\0S\0h\0\x61\0\x64\0\x65\0r\0\0\0\0\0\xff\xff\xff\xff\0\0\x1\xb8\0\xff\xff\xff\xfb\0\0\0\x12\0\x43\0i\0T\0r\0\x61\0\x63\0i\0n\0g\0\0\0\0\0\xff\xff\xff\xff\0\0\0G\0\xff\xff\xff\xfb\0\0\0.\0L\0L\0\x45\0S\0\x65\0r\0v\0i\0\x63\0\x65\0M\0o\0\x64\0u\0l\0\x65\0s\0W\0i\0\x64\0g\0\x65\0t\0\0\0\0\0\xff\xff\xff\xff\0\0\0V\0\xff\xff\xff\xfb\0\0\0\x16\0I\0P\0\x43\0R\0\x65\0\x63\0o\0r\0\x64\0\x65\0r\0\0\0\0\0\xff\xff\xff\xff\0\0\0\xcb\0\xff\xff\xff\0\0\x3\x8e\0\0\x1J\0\0\0\x4\0\0\0\x4\0\0\0\b\0\0\0\b\xfc\0\0\0\0)
Updater\check_for_update_on_start=true
Updater\check_for_update_on_start\default=true
Updater\update_on_close=false
Updater\update_on_close\default=true
calloutFlags=1
calloutFlags\default=false
confirmClose=true
confirmClose\default=true
displayTitleBars=true
displayTitleBars\default=true
enable_discord_presence=false
enable_discord_presence\default=false
firstStart=false
firstStart\default=false
fullscreen=false
fullscreen\default=true
hideInactiveMouse=false
hideInactiveMouse\default=true
pauseWhenInBackground=false
pauseWhenInBackground\default=true
screenshot_resolution_factor=0
screenshot_resolution_factor\default=true
showConsole=false
showConsole\default=true
showFilterBar=true
showFilterBar\default=true
showStatusBar=true
showStatusBar\default=true
singleWindowMode=true
singleWindowMode\default=true
theme=colorful_dark
theme\default=false
[Utility]
custom_textures=false
custom_textures\default=true
dump_textures=false
dump_textures\default=true
preload_textures=false
preload_textures\default=true
[VideoDumping]
audio_bitrate=64000
audio_bitrate\default=true
audio_encoder=libvorbis
audio_encoder\default=true
audio_encoder_options=
audio_encoder_options\default=true
format_options=
output_format=webm
output_format\default=true
video_bitrate=2500000
video_bitrate\default=true
video_encoder=libvpx-vp9
video_encoder\default=true
video_encoder_options="quality:realtime,speed:6,tile-columns:4,frame-parallel:1,threads:8,row-mt:1"
video_encoder_options\default=true
[WebService]
citra_token=
citra_username=
enable_telemetry=false
enable_telemetry\default=false
web_api_url=https://api.citra-emu.org
web_api_url\default=true

26
docker-compose.yml

@ -1,26 +0,0 @@
version: '2'
services:
citra:
build:
context: .
dockerfile: docker/citra.dockerfile
shm_size: 2G
environment:
OPENBOX_ARGS: '--startup "citra-qt ${TEST_FILE:?}"'
RESOLUTION: 1366x768
ports:
- "6080:80"
- "5900:5900"
volumes:
- "./citra:/tmp/citra"
- "${TEST_FILE:?}:${TEST_FILE:?}:ro"
driver:
build:
context: .
dockerfile: docker/driver.dockerfile
volumes:
- "./driver:/tmp/driver"
working_dir: /tmp/driver/out
command: ["/usr/libexec/vncdo.sh", "/tmp/driver/main.vdo"]

20
docker/citra.dockerfile

@ -1,20 +0,0 @@
FROM dorowu/ubuntu-desktop-lxde-vnc
RUN apt-get update -y && \
apt-get install -y \
libqt5gui5 \
libqt5multimedia5
ARG CITRA_RELEASE=nightly-1763
ARG CITRA_RELEASE_FILE=citra-linux-20220503-856b3d6.tar.xz
WORKDIR /tmp
RUN wget https://github.com/citra-emu/citra-nightly/releases/download/${CITRA_RELEASE}/${CITRA_RELEASE_FILE}
RUN mkdir -p citra && \
tar --strip-components 1 -C citra -xvf ${CITRA_RELEASE_FILE} && \
cp citra/citra-qt citra/citra /usr/local/bin
COPY citra/qt-config.ini /root/.config/citra-emu/
ENV OPENBOX_ARGS='--startup "citra-qt /root/hello-world.3dsx"'

15
docker/download_citra.sh

@ -0,0 +1,15 @@
#!/bin/bash
set -euxo pipefail
CITRA_CHANNEL=$1
CITRA_RELEASE=$2
RELEASE_API="https://api.github.com/repos/citra-emu/citra-${CITRA_CHANNEL}/releases/tags/${CITRA_CHANNEL}-${CITRA_RELEASE}"
curl "${RELEASE_API}" |
jq --raw-output '.assets[].browser_download_url' |
grep -E 'citra-linux-.*.tar.gz' |
xargs wget -O citra-linux.tar.gz
tar --strip-components 1 -xvf citra-linux.tar.gz

10
docker/driver.dockerfile

@ -1,10 +0,0 @@
FROM ghcr.io/ian-h-chamberlain/rust-devkitarm
RUN apt-get update -y && \
apt-get install -y python3 python3-pip python3-pil
RUN pip3 install vncdotool
COPY driver/vncdo.sh /usr/libexec/
CMD [ "vncdo", "--version" ]

50
docker/entrypoint.sh

@ -0,0 +1,50 @@
#!/bin/bash
# Clean up child processes on exit: https://stackoverflow.com/a/2173421/14436105
trap "pkill -P $$" EXIT INT TERM
mkdir -p ~/.config/citra-emu
cp /app/sdl2-config.ini ~/.config/citra-emu
# For some reason, log file is only written when this dir already exists,
# but it is only created after the first run of citra (our only run, in the container)
mkdir -p ~/.local/share/citra-emu/
ERRS=0
# shellcheck disable=SC2068
for EXE in $@; do
VIDEO_OUT="$(dirname "$EXE")/$(basename "$EXE" .elf)_capture.webm"
# colored logs would be nice, but we can always just grab the plaintext log file
xvfb-run citra \
--appimage-extract-and-run \
--dump-video="$VIDEO_OUT" \
"$EXE" \
&>/dev/null &
PID=$!
# Citra takes a little while to start up, so wait a little before we try to connect
sleep 3
arm-none-eabi-gdb --silent --batch-silent --command /app/test-runner.gdb "$EXE"
STATUS=$?
if [ $STATUS -ne 0 ]; then
echo >&2 "FAILED (exit status $STATUS): $EXE"
ERRS=$(( ERRS + 1 ))
fi
kill -INT $PID &>/dev/null
sleep 1
if kill -0 $PID &>/dev/null; then
kill -KILL $PID &>/dev/null
fi
CITRA_LOG=~/.local/share/citra-emu/log/citra_log.txt
CITRA_LOG_OUT="$(dirname "$EXE")/$(basename "$EXE" .elf)_citra_log.txt"
if test -f "$CITRA_LOG"; then
cp "$CITRA_LOG" "$CITRA_LOG_OUT"
else
echo "WARNING: citra log not found"
fi
done
exit $ERRS

12
docker/sdl2-config.ini

@ -0,0 +1,12 @@
[Miscellaneous]
log_filter = *:Info Debug.Emulated:Debug
[Debugging]
use_gdbstub=true
gdbstub_port=4000
[WebService]
enable_telemetry = 0
[Video Dumping]
output_format = webm

13
docker/test-runner.gdb

@ -0,0 +1,13 @@
# https://github.com/devkitPro/libctru/blob/master/libctru/source/system/stack_adjust.s#LL28C23-L28C23
# or should this be `_exit` ?
break __ctru_exit
commands
# ARM calling convention will put the exit code in r0 when __ctru_exit is called.
# Just tell GDB to exit with the same code, since it doesn't get passed back when
# the program exits
quit $r0
end
target extended-remote :4000
continue
quit

BIN
driver/citra-controls.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

BIN
driver/citra-hotkeys.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

16
driver/main.vdo

@ -1,16 +0,0 @@
# Wait for citra to finish startup
sleep 3
# Main test logic
key ctrl-p type "/tmp/citra/out/screenshot0.png" key enter
keydown a keyup a
keydown s keyup s
keydown z keyup z
keydown q
keydown a keyup a
keydown s keyup s
keydown z keyup z
keyup q
key ctrl-p type "/tmp/citra/out/screenshot1.png" key enter
capture final.png

8
driver/vncdo.sh

@ -1,8 +0,0 @@
#!/bin/bash
while ! vncdo -s citra pause 0 &>/dev/null; do
echo "waiting for VNC server..."
sleep 1
done
exec vncdo -s citra --nocursor --delay 400 "$@"

20
run.sh

@ -1,20 +0,0 @@
#!/bin/bash
set -eux -o pipefail
if [[ $# -lt 1 ]]; then
echo "Usage: run.sh 3DSX_FILE"
exit 1
fi
trap 'docker-compose down' EXIT
rm -rf citra/out driver/out
mkdir -p citra/out driver/out
TEST_FILE=$(realpath "$1")
export TEST_FILE
docker-compose build
docker-compose up -d citra
docker-compose run driver

51
src/console.rs

@ -0,0 +1,51 @@
use ctru::prelude::*;
use ctru::services::gfx::{Flush, Swap};
use super::TestRunner;
pub struct ConsoleRunner {
gfx: Gfx,
hid: Hid,
apt: Apt,
}
impl Default for ConsoleRunner {
fn default() -> Self {
let gfx = Gfx::new().unwrap();
let hid = Hid::new().unwrap();
let apt = Apt::new().unwrap();
gfx.top_screen.borrow_mut().set_wide_mode(true);
Self { gfx, hid, apt }
}
}
impl TestRunner for ConsoleRunner {
type Context<'this> = Console<'this>;
fn setup(&mut self) -> Self::Context<'_> {
Console::new(self.gfx.top_screen.borrow_mut())
}
fn cleanup(mut self, _test_result: std::io::Result<bool>) {
// We don't actually care about the test result, either way we'll stop
// and show the results to the user
// Wait to make sure the user can actually see the results before we exit
println!("Press START to exit.");
while self.apt.main_loop() {
let mut screen = self.gfx.top_screen.borrow_mut();
screen.flush_buffers();
screen.swap_buffers();
self.gfx.wait_for_vblank();
self.hid.scan_input();
if self.hid.keys_down().contains(KeyPad::START) {
break;
}
}
}
}

48
src/gdb.rs

@ -0,0 +1,48 @@
use ctru::error::ResultCode;
use super::TestRunner;
#[derive(Default)]
pub struct GdbRunner;
impl Drop for GdbRunner {
fn drop(&mut self) {
unsafe { ctru_sys::gdbHioDevExit() }
}
}
impl TestRunner for GdbRunner {
type Context<'this> = ();
fn setup(&mut self) -> Self::Context<'_> {
// TODO: `ctru` expose safe API to do this and call that instead
|| -> ctru::Result<()> {
unsafe {
ResultCode(ctru_sys::gdbHioDevInit())?;
// TODO: should we actually redirect stdin or nah?
ResultCode(ctru_sys::gdbHioDevRedirectStdStreams(true, true, true))?;
}
Ok(())
}()
.expect("failed to redirect I/O streams to GDB");
}
fn cleanup(self, test_result: std::io::Result<bool>) {
// GDB actually has the opportunity to inspect the exit code,
// unlike other runners, so let's follow the default behavior of the
// stdlib test runner.
match test_result {
Ok(success) => {
if success {
std::process::exit(0);
} else {
std::process::exit(101);
}
}
Err(err) => {
eprintln!("Error: {err}");
std::process::exit(101);
}
}
}
}

158
src/lib.rs

@ -0,0 +1,158 @@
//! Custom test runner for building/running tests on the 3DS.
//!
//! This library can be used with
//! [`custom_test_frameworks`](https://doc.rust-lang.org/unstable-book/language-features/custom-test-frameworks.html)
//! to enable normal Rust testing workflows for 3DS homebrew.
#![feature(test)]
#![feature(custom_test_frameworks)]
#![test_runner(run_gdb)]
extern crate test;
mod console;
mod gdb;
mod socket;
use console::ConsoleRunner;
use gdb::GdbRunner;
use socket::SocketRunner;
use test::{ColorConfig, OutputFormat, TestDescAndFn, TestFn, TestOpts};
/// Show test output in GDB, using the [File I/O Protocol] (called HIO in some 3DS
/// homebrew resources). Both stdout and stderr will be printed to the GDB console.
///
/// [File I/O Protocol]: https://sourceware.org/gdb/onlinedocs/gdb/File_002dI_002fO-Overview.html#File_002dI_002fO-Overview
pub fn run_gdb(tests: &[&TestDescAndFn]) {
run::<GdbRunner>(tests)
}
/// Run tests using the `ctru` [`Console`] (print results to the 3DS screen).
/// This is mostly useful for running tests manually, especially on real hardware.
///
/// [`Console`]: ctru::console::Console
pub fn run_console(tests: &[&TestDescAndFn]) {
run::<ConsoleRunner>(tests)
}
/// Show test output via a network socket to `3dslink`. This runner is only useful
/// on real hardware, since `3dslink` doesn't work with emulators.
///
/// See [`Soc::redirect_to_3dslink`] for more details.
///
/// [`Soc::redirect_to_3dslink`]: ctru::services::soc::Soc::redirect_to_3dslink
pub fn run_socket(tests: &[&TestDescAndFn]) {
run::<SocketRunner>(tests)
}
fn run<Runner: TestRunner>(tests: &[&TestDescAndFn]) {
std::env::set_var("RUST_BACKTRACE", "1");
let mut runner = Runner::default();
let ctx = runner.setup();
let opts = TestOpts {
force_run_in_process: true,
run_tests: true,
// TODO: color doesn't work because of TERM/TERMINFO.
// With RomFS we might be able to fake this out nicely...
color: ColorConfig::AlwaysColor,
format: OutputFormat::Pretty,
test_threads: Some(1),
// Hopefully this interface is more stable vs specifying individual options,
// and parsing the empty list of args should always work, I think.
// TODO Ideally we could pass actual std::env::args() here too
..test::test::parse_opts(&[]).unwrap().unwrap()
};
let tests = tests.iter().map(|t| make_owned_test(t)).collect();
let result = test::run_tests_console(&opts, tests);
drop(ctx);
runner.cleanup(result);
}
/// Adapted from [`test::make_owned_test`].
/// Clones static values for putting into a dynamic vector, which `test_main()`
/// needs to hand out ownership of tests to parallel test runners.
///
/// This will panic when fed any dynamic tests, because they cannot be cloned.
fn make_owned_test(test: &TestDescAndFn) -> TestDescAndFn {
let testfn = match test.testfn {
TestFn::StaticTestFn(f) => TestFn::StaticTestFn(f),
TestFn::StaticBenchFn(f) => TestFn::StaticBenchFn(f),
_ => panic!("non-static tests passed to test::test_main_static"),
};
TestDescAndFn {
testfn,
desc: test.desc.clone(),
}
}
/// A helper trait to make the behavior of test runners consistent.
trait TestRunner: Sized + Default {
/// Any context the test runner needs to remain alive for the duration of
/// the test. This can be used for things that need to borrow the test runner
/// itself.
// TODO: with associated type defaults this could be `= ();`
type Context<'this>
where
Self: 'this;
/// Create the [`Context`](Self::Context), if any.
fn setup(&mut self) -> Self::Context<'_>;
/// Handle the results of the test and perform any necessary cleanup.
/// The [`Context`](Self::Context) will be dropped just before this is called.
fn cleanup(self, test_result: std::io::Result<bool>);
}
/// This module has stubs needed to link the test library, but they do nothing
/// because we don't actually need them for the runner to work.
mod link_fix {
#[no_mangle]
extern "C" fn execvp(
_argc: *const libc::c_char,
_argv: *mut *const libc::c_char,
) -> libc::c_int {
-1
}
#[no_mangle]
extern "C" fn pipe(_fildes: *mut libc::c_int) -> libc::c_int {
-1
}
#[no_mangle]
extern "C" fn sigemptyset(_arg1: *mut libc::sigset_t) -> ::libc::c_int {
-1
}
}
/// Verify that doctests work as expected
/// ```
/// assert_eq!(2 + 2, 4);
/// ```
///
/// ```should_panic
/// assert_eq!(2 + 2, 5);
/// ```
#[cfg(doctest)]
struct Dummy;
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
#[test]
#[should_panic]
fn it_fails() {
assert_eq!(2 + 2, 5);
}
}

27
src/socket.rs

@ -0,0 +1,27 @@
use ctru::prelude::*;
use super::TestRunner;
pub struct SocketRunner {
soc: Soc,
}
impl Default for SocketRunner {
fn default() -> Self {
Self {
soc: Soc::new().expect("failed to initialize network service"),
}
}
}
impl TestRunner for SocketRunner {
type Context<'this> = ();
fn setup(&mut self) -> Self::Context<'_> {
self.soc
.redirect_to_3dslink(true, true)
.expect("failed to redirect to socket");
}
fn cleanup(self, _test_result: std::io::Result<bool>) {}
}

13
tests/integration.rs

@ -0,0 +1,13 @@
#![feature(custom_test_frameworks)]
#![test_runner(test_runner::run_gdb)]
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
#[test]
#[should_panic]
fn it_panics() {
assert_eq!(2 + 2, 5);
}
Loading…
Cancel
Save