Skip to content

Instantly share code, notes, and snippets.

@Skeeg
Last active July 12, 2025 01:46
Show Gist options
  • Save Skeeg/9b5a68f0d18c2df0b589274ca534363c to your computer and use it in GitHub Desktop.
Save Skeeg/9b5a68f0d18c2df0b589274ca534363c to your computer and use it in GitHub Desktop.
batocera-config-bootstrap
#!/usr/bin/env python3
#
# Tooling to allow configuration of Batocera settings from files located on the boot partition.
#
# This can be invoked manually, or ran during the postshare.sh process to start a new system with a preferred configuration.
# If the postshare.sh script is used, the configurations will be applied on each boot.
# This can make it such that your preferred wifi network will be reapplied on every boot,
# So if you change networks as you travel, it would always reset to your /boot/bootstrap.batocera.conf.<named> setting upon reboot.
#
# Recommended usage is to place files directly on the boot partition after flashing a new image and place bootstrap files in that directory.
#
# An example can be found here: https://gist.github.com/Skeeg/9b5a68f0d18c2df0b589274ca534363c#file-batocera-bootstrapper-sh
# With a postshare.sh example here: https://gist.github.com/Skeeg/9b5a68f0d18c2df0b589274ca534363c#file-postshare-sh
#
# @skeeg on Batocera Discord. https://github.com/Skeeg
#
# 20241103 - Initial revision
# 20241109 - Update to not sort and clobber the batocera.conf file.
# Values in the base file will be updated in place, and new values will be placed in the User-generated Configurations section.
#
import re
import os
import sys
import xml.etree.ElementTree as ET
import xml.dom.minidom as minidom
def backup_file(file_to_backup):
# Determine the next available backup file number
counter = 1
while os.path.exists(f"{file_to_backup}.bak{counter}"):
counter += 1
backup_destination_file = f"{file_to_backup}.bak{counter}"
if os.path.exists(file_to_backup):
with open(file_to_backup, 'rb') as src_file:
with open(backup_destination_file, 'wb') as dst_file:
dst_file.write(src_file.read())
print(f'Backed up {file_to_backup} to {backup_destination_file}.')
return backup_destination_file
def prettify_xml(elem):
"""Return a pretty-printed XML string for the Element."""
rough_string = ET.tostring(elem, 'utf-8')
reparsed = minidom.parseString(rough_string)
pretty_string = reparsed.toprettyxml(indent=" ")
# Remove unnecessary blank lines
lines = pretty_string.split('\n')
non_empty_lines = [line for line in lines if line.strip()]
return '\n'.join(non_empty_lines)
def update_xml(source_file, target_file):
# Parse the XML files
tree1 = ET.parse(source_file)
tree2 = ET.parse(target_file)
root1 = tree1.getroot()
root2 = tree2.getroot()
# Create a dictionary to store key-value pairs from the source file
config1 = {
(child.attrib['name'], child.tag): child.attrib['value'] for child in root1
}
# Drop any values from the target file that are defined in the source file.
for child in root2:
name = child.attrib['name']
child_type = child.tag # e.g., 'bool', 'int', 'string'
if (name, child_type) in config1:
child.attrib['value'] = config1[(name, child_type)]
config1.pop((name, child_type))# Remove the updated key from the dictionary
# Merge remaining entries from the source file into the target file
for (name, child_type), value in config1.items():
new_element = ET.Element(child_type)
new_element.attrib = {'name': name, 'value': value}
root2.append(new_element)
# Write the merged results to the target file as pretty-printed XML
pretty_xml_as_string = prettify_xml(root2)
with open(target_file, 'w', encoding='utf-8') as f:
f.write(pretty_xml_as_string)
print(f"Updated '{target_file}' with values from '{source_file}.")
def merge_keyvalues(source_file, target_file):
# Read the source file and target file
with open(source_file, 'r') as src_file:
source_lines = src_file.readlines()
filtered_lines = [line for line in source_lines if line.strip() and not line.strip().startswith('#')]
# Create empty map in case no target file exists
target_dict = {}
# Load existing target file data
if os.path.exists(target_file):
with open(target_file, 'r') as tgt_file:
for line in tgt_file:
if line.strip() and not line.strip().startswith('#'):
key, _, value = line.partition('=')
target_dict[key.strip()] = value.strip()
# Process the source file lines
for line in filtered_lines:
key, _, value = line.partition('=')
key = key.strip()
value = value.strip()
target_dict[key] = value
return target_dict
def update_keyvalue(source_file, target_file):
merged_keyvalues = merge_keyvalues(source_file, target_file)
# Read the target file lines
with open(target_file, 'r') as tgt:
target_lines = tgt.readlines()
# Dictionaries to track updated target lines
updated_lines = []
target_keys = {}
# First pass: Detect and prepare target data for updates, preserving comments
for line in target_lines:
#matching on pretty much everything but # and
match = re.match(r'^(#+)?([\w\s\.\_\-\+\[\]\(\)\{\}\\\/\,\"\'\!\@\#\$\%\^\&\*\<\>\?\:\;\|]+)=(.*)', line.strip())
if match:
comment, key, value = match.groups()
if key in merged_keyvalues:
# Update value and uncomment if necessary
new_value = merged_keyvalues[key]
updated_lines.append(f"{key}={new_value}\n")
target_keys[key] = True
else:
updated_lines.append(line)
target_keys[key] = False
else:
updated_lines.append(line)
# Append new entries from merged_keyvalues that were not in target_keys
for key, value in merged_keyvalues.items():
if key not in target_keys:
updated_lines.append(f"{key}={value}\n")
# Write updated target file
with open(target_file, 'w') as tgt:
tgt.writelines(updated_lines)
print(f"Updated '{target_file}' with values from '{source_file}.")
if __name__ == "__main__":
if len(sys.argv) != 5:
print("Usage: batocera-bootstrap <data_structure> <directory path with bootstrap.\{filename\}* files> <conf file to update> <[True|False] for backup>")
print("Example for keyvalue: batocera-bootstrap keyvalue /boot /userdata/system/batocera.conf True")
print("Example for xml: batocera-bootstrap xml /boot /userdata/system/configs/emulationstation/es_settings.cfg True")
print("The destination base filename is used as the filter, so all /boot/bootstrap.batocera.conf* files in this example will be read")
sys.exit(1)
data_structure = str.lower(sys.argv[1])
bootstrap_directory = sys.argv[2]
config_to_update = sys.argv[3]
config_backup = sys.argv[4]
if config_backup:
backup_file(config_to_update)
for file in sorted(os.listdir(bootstrap_directory)):
if file.startswith('bootstrap.' + os.path.basename(config_to_update)):
if data_structure == 'xml':
update_xml(os.path.join(bootstrap_directory, file), config_to_update)
elif data_structure == 'keyvalue':
update_keyvalue(os.path.join(bootstrap_directory, file), config_to_update)
print(f'Configuration bootstrapping complete.')
#!/usr/bin/env bash
if ! [[ -w "/boot" ]]; then
batocera-es-swissknife --remount
fi
REFRESH_GIST_FILES="true"
GIST_URL="https://gist.githubusercontent.com/Skeeg/9b5a68f0d18c2df0b589274ca534363c/raw"
for FILE in bootstrap.batocera.conf.cheats bootstrap.batocera.conf.common bootstrap.batocera.conf.h700 bootstrap.es_settings.cfg.common bootstrap.es_settings.cfg.h700 bootstrap.batocera.conf.compatibility.settings;
do
[[ "$REFRESH_GIST_FILES" == "true" ]] && [[ -f "/boot/$FILE" ]] && rm "/boot/$FILE" -v
[[ ! -f "/boot/$FILE" ]] && curl "$GIST_URL/$FILE" > "/boot/$FILE"
done
[[ "$REFRESH_GIST_FILES" == "true" ]] && curl "$GIST_URL/batocera-bootstrap" > /boot/tools/batocera-bootstrap
[[ "$REFRESH_GIST_FILES" == "true" ]] && curl "$GIST_URL/postshare.sh" > /boot/postshare.sh
chmod +x /boot/tools/batocera-bootstrap
chmod +x /boot/postshare.sh
resize2fs /dev/mmcblk0p4
cat << 'EO1' >> /boot/postshare.sh
# Set SSH authorized keys
cat << 'EOF' > /userdata/system/.ssh/authorized_keys
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINXsKyrZY/qerpVULDHc+51dJD/eqzrjS02e2quotkCG
EOF
# Disable battery saver. These are on the read only partition, so removing them is a temporary, per boot change without doing an overlay
mv /usr/bin/battery-saver.sh /usr/bin/battery-saver.sh.bak
mv /etc/init.d/S96battery-saver-daemon /tmp/S96battery-saver-daemon
EO1
cat << 'EOF' > /boot/bootstrap.batocera.conf.sensitive.personalized
global.netplay.nickname=UniqueNickname
global.retroachievements.password=YourPassword
global.retroachievements.username=YourUsername
wifi.key=YourPassword
wifi.ssid=YourSSID
system.hostname=KNULLI
EOF
cat << 'EOF' > /boot/bootstrap.es_settings.cfg.sensitive.personalized
<?xml version="1.0"?>
<config>
<string name="ScreenScraperPass" value="YourPassword" />
<string name="ScreenScraperUser" value="YourUsername" />
</config>
EOF
global.retroarch.apply_cheats_after_load=true
global.retroarch.apply_cheats_after_toggle=true
global.retroarch.cheat_database_path="/userdata/cheats/cht"
amstradcpc.retroarch.cheat_database_path="/userdata/cheats/cht/Amstrad - GX4000"
atari2600.retroarch.cheat_database_path="/userdata/cheats/cht/Atari - 2600"
atari800.retroarch.cheat_database_path="/userdata/cheats/cht/Atari - 400-800-1200XL"
atari5200.retroarch.cheat_database_path="/userdata/cheats/cht/Atari - 5200"
atari7800.retroarch.cheat_database_path="/userdata/cheats/cht/Atari - 7800"
jaguar.retroarch.cheat_database_path="/userdata/cheats/cht/Atari - Jaguar"
lynx.retroarch.cheat_database_path="/userdata/cheats/cht/Atari - Lynx"
colecovision.retroarch.cheat_database_path="/userdata/cheats/cht/Coleco - ColecoVision"
dos.retroarch.cheat_database_path="/userdata/cheats/cht/DOS
fbneo.retroarch.cheat_database_path="/userdata/cheats/cht/FBNeo - Arcade Games"
msx1.retroarch.cheat_database_path="/userdata/cheats/cht/Microsoft - MSX - MSX2 - MSX2P - MSX Turbo R"
msx2+.retroarch.cheat_database_path="/userdata/cheats/cht/Microsoft - MSX - MSX2 - MSX2P - MSX Turbo R"
msx2+.retroarch.cheat_database_path="/userdata/cheats/cht/Microsoft - MSX - MSX2 - MSX2P - MSX Turbo R"
msxturbor.retroarch.cheat_database_path="/userdata/cheats/cht/Microsoft - MSX - MSX2 - MSX2P - MSX Turbo R"
pcenginecd.retroarch.cheat_database_path="/userdata/cheats/cht/NEC - PC Engine CD - TurboGrafx-CD"
pcengine.retroarch.cheat_database_path="/userdata/cheats/cht/NEC - PC Engine SuperGrafx"
pcengine.retroarch.cheat_database_path="/userdata/cheats/cht/NEC - PC Engine - TurboGrafx 16"
gb.retroarch.cheat_database_path="/userdata/cheats/cht/Nintendo - Game Boy"
gba.retroarch.cheat_database_path="/userdata/cheats/cht/Nintendo - Game Boy Advance"
gbc.retroarch.cheat_database_path="/userdata/cheats/cht/Nintendo - Game Boy Color"
n64.retroarch.cheat_database_path="/userdata/cheats/cht/Nintendo - Nintendo 64"
nds.retroarch.cheat_database_path="/userdata/cheats/cht/Nintendo - Nintendo DS"
nes.retroarch.cheat_database_path="/userdata/cheats/cht/Nintendo - Nintendo Entertainment System"
snes.retroarch.cheat_database_path="/userdata/cheats/cht/Nintendo - Super Nintendo Entertainment System"
megadrive.retroarch.cheat_database_path="/userdata/cheats/cht/Sega - Mega Drive - Genesis"
saturn.retroarch.cheat_database_path="/userdata/cheats/cht/Sega - Saturn"
zxspectrum.retroarch.cheat_database_path="/userdata/cheats/cht/Sinclair - ZX Spectrum +3"
psx.retroarch.cheat_database_path="/userdata/cheats/cht/Sony - PlayStation"
thomson.retroarch.cheat_database_path="/userdata/cheats/cht/Thomson - MOTO"
tic80.retroarch.cheat_database_path="/userdata/cheats/cht/TIC-80
audio.bgmusic=0
global.incrementalsavestates=0
global.netplay_public_announce=1
global.netplay.port=55435
global.netplay=1
global.retroachievements.challenge_indicators=0
global.retroachievements.encore=0
global.retroachievements.hardcore=0
global.retroachievements.leaderboards=0
global.retroachievements.richpresence=0
global.retroachievements.screenshot=1
global.retroachievements.sound=zelda-secret
global.retroachievements.verbose=1
global.retroachievements=1
global.retroarch.block_sram_overwrite=true
global.retroarch.menu_show_online_updater=true
global.retroarch.rewind_buffer_size = "134217728"
global.retroarch.rewind_buffer_size_step = "16"
global.retroarch.rewind_granularity=2
global.retroarch.savefile_directory=/userdata/saves
global.retroarch.savestate_auto_index=true
global.retroarch.savestate_directory=/userdata/states
global.retroarch.savestate_file_compression=true
global.retroarch.savestate_max_keep=20
global.retroarch.savestate_thumbnail_enable=true
global.retroarch.savestates_in_content_dir=false
global.retroarch.sort_savefiles_by_content_enable=true
global.retroarch.sort_savestates_by_content_enable=true
global.retroarchcore.fceumm_nospritelimit=enabled
global.retroarchcore.fceumm_sndquality=Very High
global.retroarchcore.fceumm_turbo_enable=Both
global.retroarchcore.pcsx_rearmed_neon_enhancement_enable=enabled
global.retroarchcore.pcsx_rearmed_neon_enhancement_no_main=enabled
global.retroarchcore.pcsx_rearmed_neon_enhancement_tex_adj=enabled
global.savestates=1
global.toggle_fast_forward=1
psx.core=pcsx_rearmed
psx.emulator=libretro
ScrollLoadMedias=0
system.services=syncthing
system.timezone=America/Denver
wifi.enabled=1
snes["Contra III - The Alien Wars (USA) (MSU1).sfc"].rewind=0
snes["Donkey Kong Country 2 - Diddys Kong Quest (USA) (MSU1).sfc"].rewind=0
snes["DuckTales SNES (MSU1).sfc"].rewind=0
snes["Final Fantasy VI (USA) (MSU1).sfc"].rewind=0
snes["Legend of Zelda, The - A Link to the Past (USA) (MSU1).sfc"].rewind=0
snes["Mega Man 4 (USA) (MSU1).sfc"].rewind=0
snes["Mega Man 7 (USA) (MSU1).sfc"].rewind=0
snes["Mega Man X (USA) (Rev 1) (MSU1).sfc"].rewind=0
snes["Mega Man X2 (USA) (MSU1).sfc"].rewind=0
snes["Mega Man X3 (USA) (MSU1).sfc"].rewind=0
snes["Mother 2 - Giygas Strikes Back! (USA) (MSU1) [n].sfc"].rewind=0
snes["Rygar (USA) (Unl) (MSU1).sfc"].rewind=0
global.retroarch.menu_driver=rgui
global.rewind=1
n64.retroarch.rewind_enable=true
n64.retroarch.rewind_granularity=5
psx.retroarch.rewind_enable=true
psx.retroarch.rewind_granularity=3
<?xml version="1.0"?>
<config>
<bool name="InvertButtons" value="false" />
<bool name="CheevosCheckIndexesAtStart" value="true" />
<bool name="LocalArt" value="true" />
<bool name="FavoritesFirst" value="true" />
<bool name="NetPlayCheckIndexesAtStart" value="true" />
<bool name="ScrapeBezel" value="true" />
<bool name="ScrapeBoxBack" value="true" />
<bool name="ScrapeFanart" value="true" />
<bool name="ScrapeManual" value="true" />
<bool name="ScrapeMap" value="true" />
<bool name="ScrapeVideos" value="true" />
<bool name="ScreenSaverControls" value="true" />
<bool name="ScreenSaverMarquee" value="false" />
<bool name="ShowManualIcon" value="true" />
<bool name="ShowSaveStates" value="true" />
<bool name="atari800.ungroup" value="true"/>
<bool name="audio.bgmusic" value="false" />
<int name="recent.sort" value="7" />
<string name="CollectionSystemsAuto" value="all,favorites,recent" />
<string name="FolderViewMode" value="having multiple games"/>
<string name="HiddenSystems" value="imageviewer;odcommander;tools"/>
<string name="ScraperRegion" value="us" />
<string name="ScraperImageSrc" value="mixrbv2" />
<string name="ScrapperImageSrc" value="mixrbv2" />
<string name="ScreenSaverBehavior" value="random video" />
<string name="ScreenSaverDecorations" value="none" />
<string name="ScreenSaverGameInfo" value="start &amp; end" />
<string name="ShowFlags" value="auto" />
<string name="ThemeSet" value="es-theme-art-book-next" />
</config>
<?xml version="1.0"?>
<config>
<string name="FontScale" value="1.5" />
<string name="ForceSmallScreen" value="true" />
<string name="MenuFontScale" value="2.0" />
</config>
<?xml version="1.0"?>
<config>
<string name="FontScale" value="1.25" />
<string name="ForceSmallScreen" value="true" />
<string name="MenuFontScale" value="1.5" />
</config>
<?xml version="1.0" encoding="UTF-8"?>
<savestates>
<emulator name="libretro" autosave="true" incremental="true">
<directory>/userdata/states/{{system}}</directory>
<file>{{romfilename}}.state{{slot}}</file>
<image>{{romfilename}}.state{{slot}}.png</image>
<autosave_file>{{romfilename}}.state.auto</autosave_file>
<autosave_image>{{romfilename}}.state.auto.png</autosave_image>
<!-- cores management -->
<!--<defaultCoreDirectory>/userdata/states/{{system}}</defaultCoreDirectory>-->
<!-- Sample core management
<core name="mesen" enabled="false"/>
<core name="fceumm" system="nes" directory="/userdata/states/{{system}}"/>
-->
</emulator>
<emulator name="bigpemu" firstslot="001" lastslot="999" autosave="false" incremental="false">
<directory>/userdata/states/{{system}}/bigpemu</directory>
<file>{{romfilename}}_state{{slot2d}}.bigpstate</file>
<image>{{romfilename}}_state{{slot2d}}.png</image>
</emulator>
<emulator name="bizhawk" firstslot="0" lastslot="9" autosave="false" incremental="false">
<directory>/userdata/states/{{system}}/bizhawk/sstates/{{core}}</directory>
<file>{{romfilename}}.QuickSave{{slot0}}.State</file>
<image>{{romfilename}}.QuickSave{{slot0}}.png</image>
</emulator>
<emulator name="dolphin" firstslot="1" lastslot="10">
<directory>/userdata/states/{{system}}/dolphin</directory>
<file>{{romfilename}}.s{{slot2d}}</file>
<image>{{romfilename}}.s{{slot2d}}.png</image>
</emulator>
<emulator name="flycast" firstslot="1" lastslot="9">
<directory>/userdata/states/{{system}}/flycast/sstates</directory>
<file>{{romfilename}}_{{slot0}}.state</file>
<image>{{romfilename}}_{{slot0}}.png</image>
</emulator>
<emulator name="jgenesis" firstslot="0" lastslot="9" autosave="false" incremental="false">
<directory>/userdata/states/{{system}}/jgenesis/states</directory>
<file>{{romfilename}}_{{slot0}}.jst</file>
<image>{{romfilename}}_{{slot0}}.png</image>
</emulator>
<emulator name="pcsx2" firstslot="1" lastslot="10" autosave="true" incremental="true">
<directory>/userdata/states/{{system}}/pcsx2</directory>
<file>{{romfilename}}.{{slot2d}}.p2s</file>
<image>{{romfilename}}.{{slot2d}}.p2s.png</image>
<autosave_file>{{romfilename}}.resume.p2s</autosave_file>
<autosave_image>{{romfilename}}.resume.p2s.png</autosave_image>
</emulator>
<emulator name="duckstation" firstslot="1" lastslot="10" autosave="true" incremental="true">
<directory>/userdata/states/{{system}}/duckstation</directory>
<file>{{romfilename}}_{{slot2d}}.sav</file>
<image>{{romfilename}}_{{slot2d}}.png</image>
<autosave_file>{{romfilename}}_resume.sav</autosave_file>
<autosave_image>{{romfilename}}_resume.png</autosave_image>
</emulator>
<emulator name="openmsx" firstslot="0" lastslot="20" autosave="false" incremental="false">
<directory>/userdata/states/{{system}}/openmsx</directory>
<file>{{romfilename}}_{{slot0}}.oms</file>
<image>{{romfilename}}_{{slot0}}.png</image>
</emulator>
<emulator name="ppsspp" firstslot="0" lastslot="4">
<directory>/userdata/states/{{system}}/ppsspp</directory>
<file>{{romfilename}}_{{slot0}}.ppst</file>
<image>{{romfilename}}_{{slot0}}.jpg</image>
</emulator>
<emulator name="mupen64_notsupported" firstslot="0" lastslot="9">
<directory>/userdata/states/{{system}}/mupen64</directory>
<file>{{romfilename}}.st{{slot0}}</file>
<image>{{romfilename}}.st{{slot0}}.png</image>
</emulator>
</savestates>
#!/bin/bash
/boot/tools/batocera-bootstrap keyvalue /boot /userdata/system/batocera.conf True
/boot/tools/batocera-bootstrap xml /boot /userdata/system/configs/emulationstation/es_settings.cfg True
cp /boot/es_savestates.cfg /userdata/system/configs/emulationstation/es_savestates.cfg
# Set the initial state to 1 for power_led_hooks.sh to disable the LED during gameplay only. Knulli setting.
echo "1" > /var/run/led_state
function batocera-refresh-reload () {
/etc/init.d/S31emulationstation stop
sleep 2
/boot/postshare.sh
/etc/init.d/S31emulationstation start
}
function batocera-factory-reset () {
/etc/init.d/S31emulationstation stop
sleep 2
rm -rf /userdata/system
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment