Browse Source

Rework a bunch of stuff in citra dockerfile

pull/1/head
Ian Chamberlain 2 years ago
parent
commit
4d2d4064b2
No known key found for this signature in database
GPG Key ID: AE5484D09405AA60
  1. 3
      .gitignore
  2. 16
      README.md
  3. 496
      citra/qt-config.ini
  4. 26
      docker-compose.yml
  5. 37
      docker/citra.dockerfile
  6. 15
      docker/citra/download_citra.sh
  7. 342
      docker/citra/sdl2-config.ini
  8. 10
      docker/driver.dockerfile
  9. BIN
      driver/citra-controls.png
  10. BIN
      driver/citra-hotkeys.png
  11. 16
      driver/main.vdo
  12. 8
      driver/vncdo.sh
  13. 20
      run.sh
  14. 1
      test-crate/.gitignore
  15. 119
      test-crate/Cargo.lock
  16. 9
      test-crate/Cargo.toml
  17. 53
      test-crate/src/main.rs

3
.gitignore vendored

@ -1,3 +0,0 @@
# Ignore files generated by citra or the VNC client
citra/out
driver/out

16
README.md

@ -19,7 +19,19 @@ A set of tools for running automated Rust tests against Citra (3DS emulator).
1. Build a test executable (type tbd) 1. Build a test executable (type tbd)
1. `citra-emu` container: bind-mount test executable and choose it 1. `citra-emu` container: bind-mount test executable and choose it
1. `driver` container perform input / output as needed for test, via VNC 1. `driver` container perform input / output as needed for test, via VNC
* possible extension: `3dslink -s` to get actual stdout/stderr (return code?) * possible extension: `3dslink -s` to get actual stdout/stderr (return code?)
* acceptance testing of images, hopefully via screenshot * acceptance testing of images, hopefully via screenshot
## To do work
* [ ] Reorganize docker build files vs runtime files a bit
* [ ] Make this repo useable as a github action
* [ ] Run itself as part of CI? I guess?
* [ ] Simpler user-run workflow:
* Ideally, a single command to spin everything up, build + load a 3dsx and run a vdo script.
* Maybe cargo args passed in as environment variable or something?
* [ ] Clearly defined dependencies + use cases:
* Should this be usable without Rust?
* Is docker the only real dependency?
* Does this need a separate binary, or can we just use native cargo test
capabilities?

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"]

37
docker/citra.dockerfile

@ -1,20 +1,29 @@
FROM dorowu/ubuntu-desktop-lxde-vnc FROM buildpack-deps:latest as builder
RUN apt-get update -y && \ # ARG CITRA_RELEASE=nightly-1783
apt-get install -y \ # ARG CITRA_RELEASE_FILE=citra-linux-20220902-746609f.tar.xz
libqt5gui5 \
libqt5multimedia5
ARG CITRA_CHANNEL=nightly
ARG CITRA_RELEASE=nightly-1763 ARG CITRA_RELEASE=1816
ARG CITRA_RELEASE_FILE=citra-linux-20220503-856b3d6.tar.xz
WORKDIR /tmp WORKDIR /tmp
RUN wget https://github.com/citra-emu/citra-nightly/releases/download/${CITRA_RELEASE}/${CITRA_RELEASE_FILE} COPY ./citra/download_citra.sh /usr/local/bin/download_citra
RUN mkdir -p citra && \ RUN apt-get update -y && apt-get install -y jq
tar --strip-components 1 -C citra -xvf ${CITRA_RELEASE_FILE} && \ RUN download_citra ${CITRA_CHANNEL} ${CITRA_RELEASE}
cp citra/citra-qt citra/citra /usr/local/bin
FROM ubuntu:latest
RUN apt-get update -y && \
apt-get install -y \
libswscale5 \
libsdl2-2.0-0 \
libavformat58 \
libavfilter7 \
xvfb
COPY --from=builder /tmp/citra /usr/local/bin
COPY ./citra/sdl2-config.ini /root/.config/citra-emu/
COPY citra/qt-config.ini /root/.config/citra-emu/ WORKDIR /app
ENV OPENBOX_ARGS='--startup "citra-qt /root/hello-world.3dsx"' CMD [ "citra", "--version" ]

15
docker/citra/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.xz' |
xargs wget -O citra-linux.tar.xz
tar --strip-components 1 -xvf citra-linux.tar.xz

342
docker/citra/sdl2-config.ini

@ -0,0 +1,342 @@
[Controls]
# The input devices and parameters for each 3DS native input
# It should be in the format of "engine:[engine_name],[param1]:[value1],[param2]:[value2]..."
# Escape characters $0 (for ':'), $1 (for ',') and $2 (for '$') can be used in values
# for button input, the following devices are available:
# - "keyboard" (default) for keyboard input. Required parameters:
# - "code": the code of the key to bind
# - "sdl" for joystick input using SDL. Required parameters:
# - "joystick": the index of the joystick to bind
# - "button"(optional): the index of the button to bind
# - "hat"(optional): the index of the hat to bind as direction buttons
# - "axis"(optional): the index of the axis to bind
# - "direction"(only used for hat): the direction name of the hat to bind. Can be "up", "down", "left" or "right"
# - "threshold"(only used for axis): a float value in (-1.0, 1.0) which the button is
# triggered if the axis value crosses
# - "direction"(only used for axis): "+" means the button is triggered when the axis value
# is greater than the threshold; "-" means the button is triggered when the axis value
# is smaller than the threshold
button_a=
button_b=
button_x=
button_y=
button_up=
button_down=
button_left=
button_right=
button_l=
button_r=
button_start=
button_select=
button_debug=
button_gpio14=
button_zl=
button_zr=
button_home=
# for analog input, the following devices are available:
# - "analog_from_button" (default) for emulating analog input from direction buttons. Required parameters:
# - "up", "down", "left", "right": sub-devices for each direction.
# Should be in the format as a button input devices using escape characters, for example, "engine$0keyboard$1code$00"
# - "modifier": sub-devices as a modifier.
# - "modifier_scale": a float number representing the applied modifier scale to the analog input.
# Must be in range of 0.0-1.0. Defaults to 0.5
# - "sdl" for joystick input using SDL. Required parameters:
# - "joystick": the index of the joystick to bind
# - "axis_x": the index of the axis to bind as x-axis (default to 0)
# - "axis_y": the index of the axis to bind as y-axis (default to 1)
circle_pad=
c_stick=
# for motion input, the following devices are available:
# - "motion_emu" (default) for emulating motion input from mouse input. Required parameters:
# - "update_period": update period in milliseconds (default to 100)
# - "sensitivity": the coefficient converting mouse movement to tilting angle (default to 0.01)
# - "tilt_clamp": the max value of the tilt angle in degrees (default to 90)
# - "cemuhookudp" reads motion input from a udp server that uses cemuhook's udp protocol
motion_device=
# for touch input, the following devices are available:
# - "emu_window" (default) for emulating touch input from mouse input to the emulation window. No parameters required
# - "cemuhookudp" reads touch input from a udp server that uses cemuhook's udp protocol
# - "min_x", "min_y", "max_x", "max_y": defines the udp device's touch screen coordinate system
touch_device=
# Most desktop operating systems do not expose a way to poll the motion state of the controllers
# so as a way around it, cemuhook created a udp client/server protocol to broadcast the data directly
# from a controller device to the client program. Citra has a client that can connect and read
# from any cemuhook compatible motion program.
# IPv4 address of the udp input server (Default "127.0.0.1")
udp_input_address=
# Port of the udp input server. (Default 26760)
udp_input_port=
# The pad to request data on. Should be between 0 (Pad 1) and 3 (Pad 4). (Default 0)
udp_pad_index=
[Core]
# Whether to use the Just-In-Time (JIT) compiler for CPU emulation
# 0: Interpreter (slow), 1 (default): JIT (fast)
use_cpu_jit =
# Change the Clock Frequency of the emulated 3DS CPU.
# Underclocking can increase the performance of the game at the risk of freezing.
# Overclocking may fix lag that happens on console, but also comes with the risk of freezing.
# Range is any positive integer (but we suspect 25 - 400 is a good idea) Default is 100
cpu_clock_percentage =
[Renderer]
# Whether to render using GLES or OpenGL
# 0 (default): OpenGL, 1: GLES
use_gles =
# Whether to use software or hardware rendering.
# 0: Software, 1 (default): Hardware
use_hw_renderer =
# Whether to use hardware shaders to emulate 3DS shaders
# 0: Software, 1 (default): Hardware
use_hw_shader =
# Whether to use separable shaders to emulate 3DS shaders (macOS only)
# 0: Off (Default), 1 : On
separable_shader =
# Whether to use accurate multiplication in hardware shaders
# 0: Off (Faster, but causes issues in some games) 1: On (Default. Slower, but correct)
shaders_accurate_mul =
# Whether to use the Just-In-Time (JIT) compiler for shader emulation
# 0: Interpreter (slow), 1 (default): JIT (fast)
use_shader_jit =
# Forces VSync on the display thread. Usually doesn't impact performance, but on some drivers it can
# so only turn this off if you notice a speed difference.
# 0: Off, 1 (default): On
use_vsync_new =
# Reduce stuttering by storing and loading generated shaders to disk
# 0: Off, 1 (default. On)
use_disk_shader_cache =
# Resolution scale factor
# 0: Auto (scales resolution to window size), 1: Native 3DS screen resolution, Otherwise a scale
# factor for the 3DS resolution
resolution_factor =
# Texture filter name
texture_filter_name =
# Limits the speed of the game to run no faster than this value as a percentage of target speed.
# Will not have an effect if unthrottled is enabled.
# 5 - 995: Speed limit as a percentage of target game speed. 0 for unthrottled. 100 (default)
frame_limit =
# Overrides the frame limiter to use frame_limit_alternate instead of frame_limit.
# 0: Off (default), 1: On
use_frame_limit_alternate =
# Alternate speed limit to be used instead of frame_limit if use_frame_limit_alternate is enabled
# 5 - 995: Speed limit as a percentage of target game speed. 0 for unthrottled. 200 (default)
frame_limit_alternate =
# The clear color for the renderer. What shows up on the sides of the bottom screen.
# Must be in range of 0.0-1.0. Defaults to 0.0 for all.
bg_red = 0.5
bg_blue = 0.5
bg_green = 0.5
# Whether and how Stereoscopic 3D should be rendered
# 0 (default): Off, 1: Side by Side, 2: Anaglyph, 3: Interlaced, 4: Reverse Interlaced
render_3d =
# Change 3D Intensity
# 0 - 100: Intensity. 0 (default)
factor_3d =
# The name of the post processing shader to apply.
# Loaded from shaders if render_3d is off or side by side.
# Loaded from shaders/anaglyph if render_3d is anaglyph
pp_shader_name =
# Whether to enable linear filtering or not
# This is required for some shaders to work correctly
# 0: Nearest, 1 (default): Linear
filter_mode =
[Layout]
# Layout for the screen inside the render window.
# 0 (default): Default Top Bottom Screen, 1: Single Screen Only, 2: Large Screen Small Screen, 3: Side by Side
layout_option =
# Toggle custom layout (using the settings below) on or off.
# 0 (default): Off, 1: On
custom_layout =
# Screen placement when using Custom layout option
# 0x, 0y is the top left corner of the render window.
custom_top_left =
custom_top_top =
custom_top_right =
custom_top_bottom =
custom_bottom_left =
custom_bottom_top =
custom_bottom_right =
custom_bottom_bottom =
# Swaps the prominent screen with the other screen.
# For example, if Single Screen is chosen, setting this to 1 will display the bottom screen instead of the top screen.
# 0 (default): Top Screen is prominent, 1: Bottom Screen is prominent
swap_screen =
# Toggle upright orientation, for book style games.
# 0 (default): Off, 1: On
upright_screen =
# Dumps textures as PNG to dump/textures/[Title ID]/.
# 0 (default): Off, 1: On
dump_textures =
# Reads PNG files from load/textures/[Title ID]/ and replaces textures.
# 0 (default): Off, 1: On
custom_textures =
# Loads all custom textures into memory before booting.
# 0 (default): Off, 1: On
preload_textures =
[Audio]
# Whether or not to enable DSP LLE
# 0 (default): No, 1: Yes
enable_dsp_lle =
# Whether or not to run DSP LLE on a different thread
# 0 (default): No, 1: Yes
enable_dsp_lle_thread =
# Which audio output engine to use.
# auto (default): Auto-select, null: No audio output, sdl2: SDL2 (if available)
output_engine =
# Whether or not to enable the audio-stretching post-processing effect.
# This effect adjusts audio speed to match emulation speed and helps prevent audio stutter,
# at the cost of increasing audio latency.
# 0: No, 1 (default): Yes
enable_audio_stretching =
# Which audio device to use.
# auto (default): Auto-select
output_device =
# Output volume.
# 1.0 (default): 100%, 0.0; mute
volume =
[Data Storage]
# Whether to create a virtual SD card.
# 1 (default): Yes, 0: No
use_virtual_sd =
# The path of the virtual SD card directory.
# empty (default) will use the user_path
sdmc_directory =
# The path of NAND directory.
# empty (default) will use the user_path
nand_directory =
[System]
# The system model that Citra will try to emulate
# 0: Old 3DS, 1: New 3DS (default)
is_new_3ds =
# The system region that Citra will use during emulation
# -1: Auto-select (default), 0: Japan, 1: USA, 2: Europe, 3: Australia, 4: China, 5: Korea, 6: Taiwan
region_value =
# The clock to use when citra starts
# 0: System clock (default), 1: fixed time
init_clock =
# Time used when init_clock is set to fixed_time in the format %Y-%m-%d %H:%M:%S
# set to fixed time. Default 2000-01-01 00:00:01
# Note: 3DS can only handle times later then Jan 1 2000
init_time =
[Camera]
# Which camera engine to use for the right outer camera
# blank (default): a dummy camera that always returns black image
camera_outer_right_name =
# A config string for the right outer camera. Its meaning is defined by the camera engine
camera_outer_right_config =
# The image flip to apply
# 0: None (default), 1: Horizontal, 2: Vertical, 3: Reverse
camera_outer_right_flip =
# ... for the left outer camera
camera_outer_left_name =
camera_outer_left_config =
camera_outer_left_flip =
# ... for the inner camera
camera_inner_name =
camera_inner_config =
camera_inner_flip =
[Miscellaneous]
# A filter which removes logs below a certain logging level.
# Examples: *:Debug Kernel.SVC:Trace Service.*:Critical
log_filter = *:Info Debug.Emulated:Debug
# Kernel:Debug Service.APT:Trace
[Debugging]
# Record frame time data, can be found in the log directory. Boolean value
record_frame_times =
# Port for listening to GDB connections.
use_gdbstub=false
gdbstub_port=24689
# To LLE a service module add "LLE\<module name>=true"
[WebService]
# Whether or not to enable telemetry
# 0: No, 1 (default): Yes
enable_telemetry = 1
# URL for Web API
web_api_url = https://api.citra-emu.org
# Username and token for Citra Web Service
# See https://profile.citra-emu.org/ for more info
citra_username =
citra_token =
[Video Dumping]
# Format of the video to output, default: webm
output_format =
# Options passed to the muxer (optional)
# This is a param package, format: [key1]:[value1],[key2]:[value2],...
format_options =
# Video encoder used, default: libvpx-vp9
video_encoder = libvpx-vp9
# Options passed to the video codec (optional)
video_encoder_options = quality:realtime,speed:6,tile-columns:4,frame-parallel:1,threads:8,row-mt:1
# Video bitrate, default: 2500000
video_bitrate =
# Audio encoder used, default: libvorbis
audio_encoder = libvorbis
# Options passed to the audio codec (optional)
audio_encoder_options =
# Audio bitrate, default: 64000
audio_bitrate =

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" ]

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

1
test-crate/.gitignore vendored

@ -0,0 +1 @@
/target

119
test-crate/Cargo.lock generated

@ -0,0 +1,119 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "const-zero"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d3d68618a1f2c2d86e0bd2adec82e31960ca11aaeb5f353ff01746a4e08f36"
[[package]]
name = "ctru-rs"
version = "0.7.1"
source = "git+https://github.com/Meziu/ctru-rs.git#ac6c81e7819185be46576af3441f5260d39a2320"
dependencies = [
"bitflags",
"cfg-if",
"const-zero",
"ctru-sys",
"libc",
"linker-fix-3ds",
"once_cell",
"pthread-3ds",
"toml",
"widestring",
]
[[package]]
name = "ctru-sys"
version = "0.4.1"
source = "git+https://github.com/Meziu/ctru-rs.git#ac6c81e7819185be46576af3441f5260d39a2320"
dependencies = [
"libc",
]
[[package]]
name = "libc"
version = "0.2.132"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5"
[[package]]
name = "linker-fix-3ds"
version = "0.1.0"
source = "git+https://github.com/Meziu/rust-linker-fix-3ds.git#d5d3be4a0da876df6d6ac55cc8b48488713e149a"
dependencies = [
"ctru-sys",
"libc",
]
[[package]]
name = "once_cell"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87f3e037eac156d1775da914196f0f37741a274155e34a0b7e427c35d2a2ecb9"
[[package]]
name = "pthread-3ds"
version = "0.1.0"
source = "git+https://github.com/Meziu/pthread-3ds.git#42a80c0e816251138df535648258671d93e047a6"
dependencies = [
"ctru-sys",
"libc",
"spin",
"static_assertions",
]
[[package]]
name = "serde"
version = "1.0.137"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1"
[[package]]
name = "spin"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c530c2b0d0bf8b69304b39fe2001993e267461948b890cd037d8ad4293fa1a0d"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "test-crate"
version = "0.1.0"
dependencies = [
"ctru-rs",
"ctru-sys",
]
[[package]]
name = "toml"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7"
dependencies = [
"serde",
]
[[package]]
name = "widestring"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7157704c2e12e3d2189c507b7482c52820a16dfa4465ba91add92f266667cadb"

9
test-crate/Cargo.toml

@ -0,0 +1,9 @@
[package]
name = "test-crate"
version = "0.1.0"
edition = "2021"
authors = [""]
[dependencies]
ctru-rs = { git = "https://github.com/Meziu/ctru-rs.git" }
ctru-sys = { git = "https://github.com/Meziu/ctru-rs.git" }

53
test-crate/src/main.rs

@ -0,0 +1,53 @@
use std::time::Duration;
use ctru::console::Console;
use ctru::gfx::Gfx;
use ctru::services::apt::Apt;
use ctru::services::hid::{Hid, KeyPad};
fn main() {
ctru::init();
let gfx = Gfx::init().expect("Couldn't obtain GFX controller");
let hid = Hid::init().expect("Couldn't obtain HID controller");
let apt = Apt::init().expect("Couldn't obtain APT controller");
// let _console = Console::init(gfx.top_screen.borrow_mut());
let _ = unsafe { ctru_sys::consoleDebugInit(ctru_sys::debugDevice_SVC) };
std::env::set_var("RUST_BACKTRACE", "full");
let res = unsafe { ctru_sys::gdbHioDevInit() };
if res != 0 {
eprintln!("failed to init gdbHIO: {res}");
} else {
eprintln!("init gdb hio");
}
// let res = unsafe { ctru_sys::gdbHioDevRedirectStdStreams(false, true, true) };
// if res != 0 {
// eprintln!("failed to redirect gdbHIO: {res}");
// } else {
// eprintln!("redirected gdb hio");
// }
println!("hey stdout");
eprintln!("hey stderr");
// Main loop
while apt.main_loop() {
//Scan all the inputs. This should be done once for each frame
hid.scan_input();
if hid.keys_down().contains(KeyPad::KEY_START) {
break;
}
// Flush and swap framebuffers
gfx.flush_buffers();
gfx.swap_buffers();
//Wait for VBlank
gfx.wait_for_vblank();
}
unsafe { ctru_sys::gdbHioDevExit() };
}
Loading…
Cancel
Save