Add opt-in AntiMicroX gamepad remapping, scoped to TI-99/4A and Wii U

AntiMicroX maps gamepad buttons to synthetic keyboard/mouse events -
useful here because ti99sim-sdl's own joystick handling only ever emits
digit keys 1-9 for a raw button (confirmed against its source), with no
path to 0/Enter/Q/Esc, and because it's a plausible angle on Cemu not
reliably telling apart two Wolf virtual pads that share an SDL GUID.

Scoped to just these two ES-DE systems via a second, separately-labeled
"(AntiMicroX)" <command> alongside each one's existing default - ES-DE's
own multi-command "alternative emulators" mechanism - rather than
touching every system. Each launches through a small wrapper script
that starts AntiMicroX hidden (--no-tray --hidden --eventgen uinput,
flags confirmed against AntiMicroX's own commandlineutility.cpp source)
against a profile the user builds themselves via AntiMicroX's own GUI in
the Desktop/XFCE app, execs the real emulator, and kills AntiMicroX on
exit.

AntiMicroX needs /dev/uinput to inject events under ES-DE's Sway/Wayland
session (its XTest backend needs Xwayland, not present here), so the
esde catalog entry now requests it via both GOW_REQUIRED_DEVICES and a
real device grant - and make_app_block/the reinstall-update path both
learn to read/refresh a per-app 'devices' field instead of always
emitting an empty array, so this actually reaches an existing install on
a rerun, not just a fresh one (verified live against a synthetic
pre-uinput-era config.toml).

Also fixes a latent whitespace-eating bug in every custom_systems
strip-and-reappend regex in this file (trailing \s*\n? reached into the
next sibling block's leading indentation once two custom systems
coexist in the same file) - cosmetic only, but it would have made every
rerun churn indentation between the ti994a and wiiu blocks. Caught and
verified via a standalone test harness exercising all four
strip-and-reappend blocks together across repeated passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
This commit is contained in:
Claude
2026-09-03 15:02:06 +00:00
parent 9714bfca2e
commit 0be27b15e9
+312 -7
View File
@@ -321,6 +321,8 @@ EOF
echo " - Offer to auto-download standalone emulator AppImages into emulators/:" echo " - Offer to auto-download standalone emulator AppImages into emulators/:"
echo " Azahar (3DS), PCSX2 (PS2), Dolphin (GameCube/Wii — unofficial community build," echo " Azahar (3DS), PCSX2 (PS2), Dolphin (GameCube/Wii — unofficial community build,"
echo " symlinked to Dolphin_Emulator.AppImage so ES-DE's own find-rules can see it)" echo " symlinked to Dolphin_Emulator.AppImage so ES-DE's own find-rules can see it)"
echo " - Offer AntiMicroX (gamepad -> keyboard/mouse remapping), scoped to just TI-99/4A"
echo " and Wii U (Cemu) in ES-DE via a second, opt-in 'Alternative emulators' command"
return 0 return 0
fi fi
@@ -993,6 +995,17 @@ UDEV
# ti99sim-sdl still needs its cwd to be its own install dir to find # ti99sim-sdl still needs its cwd to be its own install dir to find
# the console ROM (TI-994A.ctg) via a plain relative lookup, same as # the console ROM (TI-994A.ctg) via a plain relative lookup, same as
# RetroPie's own ti99sim.sh does with its "pushd $md_inst" launch. # RetroPie's own ti99sim.sh does with its "pushd $md_inst" launch.
# The strip regex's trailing whitespace-eater is [ \t]*\n? (one
# line's trailing spaces + one newline), not \s* — confirmed live
# (via a standalone test harness) that a bare \s* reaches PAST this
# block's own trailing newline into the NEXT <system>'s leading
# indentation once a second custom system exists in the same file
# (e.g. the wiiu block the AntiMicroX setup step adds below), and on
# every rerun the two blocks trade indentation back and forth —
# cosmetic only (still well-formed XML, ES-DE doesn't care), but
# needlessly messy across reruns. Every other strip-and-reappend
# block in this file (TI99SIM/TI99SIM_AM rules, wiiu system,
# CEMU_AM rule) uses the same fixed pattern for the same reason.
python3 - "$_TI99_XML" << 'TI99XMLPY' python3 - "$_TI99_XML" << 'TI99XMLPY'
import re, sys import re, sys
path = sys.argv[1] path = sys.argv[1]
@@ -1001,13 +1014,14 @@ try:
content = f.read() content = f.read()
except FileNotFoundError: except FileNotFoundError:
content = '<systemList>\n</systemList>\n' content = '<systemList>\n</systemList>\n'
content = re.sub(r'[ \t]*<system>\s*<name>ti994a</name>.*?</system>\s*\n?', '', content, flags=re.DOTALL) content = re.sub(r'[ \t]*<system>\s*<name>ti994a</name>.*?</system>[ \t]*\n?', '', content, flags=re.DOTALL)
block = ''' <system> block = ''' <system>
<name>ti994a</name> <name>ti994a</name>
<fullname>Texas Instruments TI-99/4A</fullname> <fullname>Texas Instruments TI-99/4A</fullname>
<path>%ROMPATH%/ti994a</path> <path>%ROMPATH%/ti994a</path>
<extension>.ctg .rpk .bin</extension> <extension>.ctg .rpk .bin</extension>
<command>%INJECT%=%BASENAME%.esprefix %STARTDIR%=%EMUDIR% %EMULATOR_TI99SIM% --joystick1=1 --fullscreen %ROM%</command> <command label="TI99SIM (Standalone)">%INJECT%=%BASENAME%.esprefix %STARTDIR%=%EMUDIR% %EMULATOR_TI99SIM% --joystick1=1 --fullscreen %ROM%</command>
<command label="TI99SIM (AntiMicroX)">%INJECT%=%BASENAME%.esprefix %STARTDIR%=%EMUDIR% %EMULATOR_TI99SIM_AM% --joystick1=1 --fullscreen %ROM%</command>
<platform>ti99</platform> <platform>ti99</platform>
<theme>ti99</theme> <theme>ti99</theme>
</system> </system>
@@ -1038,12 +1052,18 @@ try:
content = f.read() content = f.read()
except FileNotFoundError: except FileNotFoundError:
content = '<ruleList>\n</ruleList>\n' content = '<ruleList>\n</ruleList>\n'
content = re.sub(r'[ \t]*<emulator name="TI99SIM">.*?</emulator>\s*\n?', '', content, flags=re.DOTALL) content = re.sub(r'[ \t]*<emulator name="TI99SIM">.*?</emulator>[ \t]*\n?', '', content, flags=re.DOTALL)
content = re.sub(r'[ \t]*<emulator name="TI99SIM_AM">.*?</emulator>[ \t]*\n?', '', content, flags=re.DOTALL)
block = ''' <emulator name="TI99SIM"> block = ''' <emulator name="TI99SIM">
<rule type="staticpath"> <rule type="staticpath">
<entry>~/Applications/ti99sim-sdl</entry> <entry>~/Applications/ti99sim-sdl</entry>
</rule> </rule>
</emulator> </emulator>
<emulator name="TI99SIM_AM">
<rule type="staticpath">
<entry>~/Applications/ti99sim-sdl-antimicrox</entry>
</rule>
</emulator>
''' '''
content = content.replace('</ruleList>', block + '</ruleList>') content = content.replace('</ruleList>', block + '</ruleList>')
with open(path, 'w') as f: with open(path, 'w') as f:
@@ -1138,6 +1158,182 @@ TI99RULESPY
log_info "Then just drop .ctg/.rpk/.bin cartridge files into roms/ti994a/" log_info "Then just drop .ctg/.rpk/.bin cartridge files into roms/ti994a/"
fi fi
# ── Optional: AntiMicroX gamepad -> keyboard/mouse remapping ───────────────
# Scoped to just TI-99/4A and Wii U (Cemu) — the two ES-DE systems that
# actually need it, not every system. TI-99/4A: ti99sim-sdl's own
# joystick handling (see the generated README's TI-99/4A Controls note)
# only ever emits digit keys 1-9 for a raw joystick button — there's no
# source-level path to 0, Enter, Q, or Esc from a gamepad. Wii U: Cemu's
# own controller support doesn't reliably distinguish two Wolf virtual
# pads that report the identical SDL GUID — AntiMicroX re-emits whatever
# it reads as its own distinct virtual device, a plausible angle on that
# worth having available (not a confirmed fix — needs live testing).
#
# Each system gets a SECOND, separately-labeled "(AntiMicroX)" <command>
# entry — ES-DE's own multi-command "alternative emulators" mechanism,
# the same pattern its bundled es_systems.xml already uses for e.g. the
# wii system's Dolphin vs. PrimeHack choice — alongside the existing
# default command, so nothing about the default launch path changes for
# anyone who doesn't explicitly pick the alternative.
#
# AntiMicroX has to inject synthetic keyboard/mouse events into the SAME
# Sway/Wayland session the game runs in. Its XTest backend needs
# Xwayland (not present in this container); its uinput backend
# (--eventgen uinput, added upstream in 3.1.7) needs /dev/uinput itself,
# which is why the esde catalog entry above now requests it via both
# GOW_REQUIRED_DEVICES and a real device grant — confirmed against
# AntiMicroX's own commandlineutility.cpp source for the exact flags
# (--no-tray, --hidden, --profile, --eventgen), not guessed.
echo ""
local _GET_ANTIMICROX=""
prompt_yn "Set up AntiMicroX (gamepad -> keyboard/mouse remapping) for TI-99/4A and Wii U (Cemu) in ES-DE? (y/n):" "n" _GET_ANTIMICROX
if [[ "$_GET_ANTIMICROX" =~ ^[Yy]$ ]]; then
_wolf_download_emulator_appimage \
"AntiMicroX (gamepad remapper)" "AntiMicroX/antimicrox" "*[Aa]nti[Mm]icro[Xx]*.AppImage" "$_EMU_DIR"
mkdir -p "$_EMU_DIR/antimicrox-profiles"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_EMU_DIR/antimicrox-profiles"
# Both wrappers are intentionally near-identical: start AntiMicroX
# hidden/no-tray (neither container has a desktop panel to dock a
# tray icon into) against a profile named after the system if one
# exists yet (it won't on a fresh install — build one via
# AntiMicroX's own GUI in the Desktop app, which shares this same
# ~/Applications mount, then save it into antimicrox-profiles/), exec
# the real emulator, and kill AntiMicroX again once it exits. This is
# what actually scopes remapping to just these two systems: nothing
# else in ES-DE launches through a wrapper, so nothing else is ever
# affected regardless of whether a profile exists.
cat > "$_EMU_DIR/ti99sim-sdl-antimicrox" << 'WRAPEOF'
#!/bin/bash
# Wraps ti99sim-sdl with AntiMicroX, scoped to just this system's own
# ES-DE launch command — see wolf.sh's AntiMicroX setup step.
DIR="$(cd "$(dirname "$0")" && pwd)"
PROFILE="$DIR/antimicrox-profiles/ti994a.gamecontroller.amgp"
AM_BIN=$(ls "$DIR"/*[Aa]nti[Mm]icro[Xx]*.AppImage 2>/dev/null | head -1)
AM_PID=""
if [ -n "$AM_BIN" ]; then
if [ -f "$PROFILE" ]; then
"$AM_BIN" --no-tray --hidden --eventgen uinput --profile "$PROFILE" &
else
"$AM_BIN" --no-tray --hidden --eventgen uinput &
fi
AM_PID=$!
sleep 1
fi
cleanup() { [ -n "$AM_PID" ] && kill "$AM_PID" 2>/dev/null; }
trap cleanup EXIT
cd "$DIR" || exit 1
exec ./ti99sim-sdl "$@"
WRAPEOF
chmod +x "$_EMU_DIR/ti99sim-sdl-antimicrox"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_EMU_DIR/ti99sim-sdl-antimicrox"
cat > "$_EMU_DIR/cemu-antimicrox" << 'WRAPEOF'
#!/bin/bash
# Wraps Cemu with AntiMicroX, scoped to just the Wii U system's own ES-DE
# launch command — see wolf.sh's AntiMicroX setup step.
DIR="$(cd "$(dirname "$0")" && pwd)"
PROFILE="$DIR/antimicrox-profiles/wiiu.gamecontroller.amgp"
AM_BIN=$(ls "$DIR"/*[Aa]nti[Mm]icro[Xx]*.AppImage 2>/dev/null | head -1)
CEMU_BIN=$(ls "$DIR"/*[Cc]emu*.AppImage 2>/dev/null | head -1)
if [ -z "$CEMU_BIN" ]; then
echo "cemu-antimicrox: no Cemu*.AppImage found in $DIR" >&2
exit 1
fi
AM_PID=""
if [ -n "$AM_BIN" ]; then
if [ -f "$PROFILE" ]; then
"$AM_BIN" --no-tray --hidden --eventgen uinput --profile "$PROFILE" &
else
"$AM_BIN" --no-tray --hidden --eventgen uinput &
fi
AM_PID=$!
sleep 1
fi
cleanup() { [ -n "$AM_PID" ] && kill "$AM_PID" 2>/dev/null; }
trap cleanup EXIT
exec "$CEMU_BIN" "$@"
WRAPEOF
chmod +x "$_EMU_DIR/cemu-antimicrox"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_EMU_DIR/cemu-antimicrox"
log_success "AntiMicroX wrapper scripts written: $_EMU_DIR/{ti99sim-sdl-antimicrox,cemu-antimicrox}"
mkdir -p "$GAME_STORAGE_DIR/esde-custom-systems"
# es_find_rules.xml: register CEMU_AM here (TI99SIM_AM is registered
# by the TI-99/4A system-setup step above, since it only means
# anything once that system exists). Same strip-and-reappend
# idempotency as every other custom_systems write in this file.
local _AM_RULES="$GAME_STORAGE_DIR/esde-custom-systems/es_find_rules.xml"
backup_if_exists "$_AM_RULES"
python3 - "$_AM_RULES" << 'AMRULESPY'
import re, sys
path = sys.argv[1]
try:
with open(path) as f:
content = f.read()
except FileNotFoundError:
content = '<ruleList>\n</ruleList>\n'
content = re.sub(r'[ \t]*<emulator name="CEMU_AM">.*?</emulator>[ \t]*\n?', '', content, flags=re.DOTALL)
block = ''' <emulator name="CEMU_AM">
<rule type="staticpath">
<entry>~/Applications/cemu-antimicrox</entry>
</rule>
</emulator>
'''
content = content.replace('</ruleList>', block + '</ruleList>')
with open(path, 'w') as f:
f.write(content)
AMRULESPY
chown "$ACTUAL_USER:$ACTUAL_USER" "$_AM_RULES"
# es_systems.xml: wiiu has no existing custom_systems override (it's
# one of ES-DE's own built-in systems, confirmed against ES-DE's own
# bundled resources/systems/linux/es_systems.xml) — per ES-DE's own
# USERGUIDE.md, a custom_systems entry for a system name that
# already exists in the bundled config REPLACES that system's whole
# definition rather than merging into it, so this has to replicate
# every field, not just add a line. fullname/path/extension/
# platform/theme and the original "Cemu (Standalone)" command below
# are copied verbatim from ES-DE's real bundled es_systems.xml so
# the default launch path stays byte-for-byte identical to the
# built-in one; only the new AntiMicroX alternative command is
# actually new.
local _WIIU_XML="$GAME_STORAGE_DIR/esde-custom-systems/es_systems.xml"
backup_if_exists "$_WIIU_XML"
python3 - "$_WIIU_XML" << 'WIIUXMLPY'
import re, sys
path = sys.argv[1]
try:
with open(path) as f:
content = f.read()
except FileNotFoundError:
content = '<systemList>\n</systemList>\n'
content = re.sub(r'[ \t]*<system>\s*<name>wiiu</name>.*?</system>[ \t]*\n?', '', content, flags=re.DOTALL)
block = ''' <system>
<name>wiiu</name>
<fullname>Nintendo Wii U</fullname>
<path>%ROMPATH%/wiiu</path>
<extension>.elf .ELF .rpx .RPX .tmd .TMD .wua .WUA .wud .WUD .wuhb .WUHB .wux .WUX</extension>
<command label="Cemu (Standalone)">%EMULATOR_CEMU% -g %ROM%</command>
<command label="Cemu (AntiMicroX)">%INJECT%=%BASENAME%.esprefix %STARTDIR%=%EMUDIR% %EMULATOR_CEMU_AM% -g %ROM%</command>
<platform>wiiu</platform>
<theme>wiiu</theme>
</system>
'''
content = content.replace('</systemList>', block + '</systemList>')
with open(path, 'w') as f:
f.write(content)
WIIUXMLPY
chown "$ACTUAL_USER:$ACTUAL_USER" "$_WIIU_XML"
log_success "AntiMicroX alternate launch commands added for Wii U (and TI-99/4A, if set up above) in ES-DE"
log_info "No profiles exist yet — build them via AntiMicroX's own GUI in the Desktop app (it shares"
log_info "this same ~/Applications mount), save as antimicrox-profiles/ti994a.gamecontroller.amgp"
log_info "and/or wiiu.gamecontroller.amgp, then pick the '(AntiMicroX)' alternate command for that"
log_info "system in ES-DE (per-game or per-system, via its own 'Alternative emulators' option)."
fi
# ── App selection ───────────────────────────────────────────────────────── # ── App selection ─────────────────────────────────────────────────────────
echo "" echo ""
echo "═══════════════════════════════════════════════════════" echo "═══════════════════════════════════════════════════════"
@@ -1637,6 +1833,19 @@ selected = set(sys.argv[4:])
STD_CAP = ['NET_RAW', 'MKNOD', 'NET_ADMIN'] STD_CAP = ['NET_RAW', 'MKNOD', 'NET_ADMIN']
STD_ENV = ['RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'] STD_ENV = ['RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*']
STD_RULES = ['c 13:* rmw', 'c 244:* rmw'] STD_RULES = ['c 13:* rmw', 'c 244:* rmw']
# ES-DE gets its own env: same as STD_ENV but with /dev/uinput added to
# GOW_REQUIRED_DEVICES (a second, duplicate-keyed GOW_REQUIRED_DEVICES entry
# alongside STD_ENV's own would be ambiguous to whichever engine reads it, so
# this is a full replacement, not an addition to STD_ENV) — AntiMicroX (see
# the antimicrox setup step below) needs to open /dev/uinput itself to inject
# synthetic key/mouse events under Sway/Wayland (XTest, its other backend,
# needs Xwayland, which this container doesn't run). Paired with
# devices=['/dev/uinput:/dev/uinput'] on the esde catalog entry itself below
# — GOW_REQUIRED_DEVICES alone only gets the base image's own entrypoint
# script to bind-mount the node; the container also needs Wolf's own
# create-time device grant to actually open it (matches the real
# games-on-whales/wolf config.toml example for Steam's own uinput access).
ESDE_ENV = ['RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/uinput /dev/input/* /dev/dri/* /dev/nvidia*']
CATALOG = { CATALOG = {
'steam': dict( 'steam': dict(
@@ -1666,8 +1875,9 @@ CATALOG = {
f'{games}/emulators:/mnt/games/emulators:rw', f'{games}/emulators:/mnt/games/emulators:rw',
f'{games}/emulators:/home/retro/Applications:rw', f'{games}/emulators:/home/retro/Applications:rw',
f'{games}/esde-custom-systems:/home/retro/ES-DE/custom_systems:rw'], f'{games}/esde-custom-systems:/home/retro/ES-DE/custom_systems:rw'],
env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host', env=ESDE_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host',
ulimits=[], privileged=False, ulimits=[], privileged=False,
devices=['/dev/uinput:/dev/uinput'],
), ),
'lutris': dict( 'lutris': dict(
name='WolfLutris', title='Lutris', name='WolfLutris', title='Lutris',
@@ -1778,6 +1988,7 @@ def make_app_block(app):
create_json = json.dumps({'HostConfig': host_cfg}, indent=2) create_json = json.dumps({'HostConfig': host_cfg}, indent=2)
mounts_str = str(app['mounts']).replace('"', "'") mounts_str = str(app['mounts']).replace('"', "'")
env_str = str(ensure_tz(app['env'])).replace('"', "'") env_str = str(ensure_tz(app['env'])).replace('"', "'")
devices_str = str(app.get('devices', []))
return ( return (
f"\n" f"\n"
f" [[profiles.apps]]\n" f" [[profiles.apps]]\n"
@@ -1788,7 +1999,7 @@ def make_app_block(app):
f" [profiles.apps.runner]\n" f" [profiles.apps.runner]\n"
f" base_create_json = '''{create_json}\n" f" base_create_json = '''{create_json}\n"
f"'''\n" f"'''\n"
f" devices = []\n" f" devices = {devices_str}\n"
f" env = {env_str}\n" f" env = {env_str}\n"
f" image = '{app['image']}'\n" f" image = '{app['image']}'\n"
f" mounts = {mounts_str}\n" f" mounts = {mounts_str}\n"
@@ -1884,10 +2095,20 @@ for key in list(CATALOG.keys()):
wolf_name = app['name'] wolf_name = app['name']
new_mounts = str(app['mounts']).replace('"', "'") new_mounts = str(app['mounts']).replace('"', "'")
new_env = str(ensure_tz(app['env'])).replace('"', "'") new_env = str(ensure_tz(app['env'])).replace('"', "'")
new_devices = str(app.get('devices', []))
already = any(f"name = '{wolf_name}'" in l for l in first_block) already = any(f"name = '{wolf_name}'" in l for l in first_block)
if already: if already:
ok = update_field(lines, wolf_name, 'mounts', new_mounts) ok = update_field(lines, wolf_name, 'mounts', new_mounts)
ok = update_field(lines, wolf_name, 'env', new_env) or ok ok = update_field(lines, wolf_name, 'env', new_env) or ok
# 'devices' is an array like mounts/env (even though it's currently
# always rendered on one line) — Wolf's own config.toml rewrites
# reformat arrays as multi-line, so this needs update_field's
# continuation-scanning logic, not update_scalar_field's single-line
# one, or a devices change (e.g. the esde entry's new /dev/uinput
# grant, added for AntiMicroX) would silently never apply on an
# "already installed" rerun — the same class of bug fixed for
# image/icon_png_path below by giving those their own scalar path.
ok = update_field(lines, wolf_name, 'devices', new_devices) or ok
ok = update_scalar_field(lines, wolf_name, 'image', app['image']) or ok ok = update_scalar_field(lines, wolf_name, 'image', app['image']) or ok
ok = update_scalar_field(lines, wolf_name, 'icon_png_path', app['icon']) or ok ok = update_scalar_field(lines, wolf_name, 'icon_png_path', app['icon']) or ok
if ok: if ok:
@@ -3103,6 +3324,19 @@ selected = set(sys.argv[4:]) # app keys chosen by the user
STD_CAP = ['NET_RAW', 'MKNOD', 'NET_ADMIN'] STD_CAP = ['NET_RAW', 'MKNOD', 'NET_ADMIN']
STD_ENV = ['RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'] STD_ENV = ['RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*']
STD_RULES = ['c 13:* rmw', 'c 244:* rmw'] STD_RULES = ['c 13:* rmw', 'c 244:* rmw']
# ES-DE gets its own env: same as STD_ENV but with /dev/uinput added to
# GOW_REQUIRED_DEVICES (a second, duplicate-keyed GOW_REQUIRED_DEVICES entry
# alongside STD_ENV's own would be ambiguous to whichever engine reads it, so
# this is a full replacement, not an addition to STD_ENV) — AntiMicroX (see
# the antimicrox setup step below) needs to open /dev/uinput itself to inject
# synthetic key/mouse events under Sway/Wayland (XTest, its other backend,
# needs Xwayland, which this container doesn't run). Paired with
# devices=['/dev/uinput:/dev/uinput'] on the esde catalog entry itself below
# — GOW_REQUIRED_DEVICES alone only gets the base image's own entrypoint
# script to bind-mount the node; the container also needs Wolf's own
# create-time device grant to actually open it (matches the real
# games-on-whales/wolf config.toml example for Steam's own uinput access).
ESDE_ENV = ['RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/uinput /dev/input/* /dev/dri/* /dev/nvidia*']
CATALOG = { CATALOG = {
'steam': dict( 'steam': dict(
@@ -3133,8 +3367,9 @@ CATALOG = {
f'{games}/emulators:/mnt/games/emulators:rw', f'{games}/emulators:/mnt/games/emulators:rw',
f'{games}/emulators:/home/retro/Applications:rw', f'{games}/emulators:/home/retro/Applications:rw',
f'{games}/esde-custom-systems:/home/retro/ES-DE/custom_systems:rw'], f'{games}/esde-custom-systems:/home/retro/ES-DE/custom_systems:rw'],
env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host', env=ESDE_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host',
ulimits=[], privileged=False, ulimits=[], privileged=False,
devices=['/dev/uinput:/dev/uinput'],
), ),
'lutris': dict( 'lutris': dict(
name='WolfLutris', title='Lutris', name='WolfLutris', title='Lutris',
@@ -3246,6 +3481,7 @@ def make_app_block(app):
create_json = json.dumps({'HostConfig': host_cfg}, indent=2) create_json = json.dumps({'HostConfig': host_cfg}, indent=2)
mounts_str = str(app['mounts']).replace('"', "'") mounts_str = str(app['mounts']).replace('"', "'")
env_str = str(ensure_tz(app['env'])).replace('"', "'") env_str = str(ensure_tz(app['env'])).replace('"', "'")
devices_str = str(app.get('devices', []))
return ( return (
f"\n" f"\n"
f" [[profiles.apps]]\n" f" [[profiles.apps]]\n"
@@ -3256,7 +3492,7 @@ def make_app_block(app):
f" [profiles.apps.runner]\n" f" [profiles.apps.runner]\n"
f" base_create_json = '''{create_json}\n" f" base_create_json = '''{create_json}\n"
f"'''\n" f"'''\n"
f" devices = []\n" f" devices = {devices_str}\n"
f" env = {env_str}\n" f" env = {env_str}\n"
f" image = '{app['image']}'\n" f" image = '{app['image']}'\n"
f" mounts = {mounts_str}\n" f" mounts = {mounts_str}\n"
@@ -3356,9 +3592,19 @@ for key in list(CATALOG.keys()): # preserve display order
already = any(f"name = '{wolf_name}'" in l for l in first_block) already = any(f"name = '{wolf_name}'" in l for l in first_block)
new_mounts = str(app['mounts']).replace('"', "'") new_mounts = str(app['mounts']).replace('"', "'")
new_env = str(ensure_tz(app['env'])).replace('"', "'") new_env = str(ensure_tz(app['env'])).replace('"', "'")
new_devices = str(app.get('devices', []))
if already: if already:
ok = update_field(lines, wolf_name, 'mounts', new_mounts) ok = update_field(lines, wolf_name, 'mounts', new_mounts)
ok = update_field(lines, wolf_name, 'env', new_env) or ok ok = update_field(lines, wolf_name, 'env', new_env) or ok
# 'devices' is an array like mounts/env (even though it's currently
# always rendered on one line) — Wolf's own config.toml rewrites
# reformat arrays as multi-line, so this needs update_field's
# continuation-scanning logic, not update_scalar_field's single-line
# one, or a devices change (e.g. the esde entry's new /dev/uinput
# grant, added for AntiMicroX) would silently never apply on an
# "already installed" rerun — the same class of bug fixed for
# image/icon_png_path below by giving those their own scalar path.
ok = update_field(lines, wolf_name, 'devices', new_devices) or ok
ok = update_scalar_field(lines, wolf_name, 'image', app['image']) or ok ok = update_scalar_field(lines, wolf_name, 'image', app['image']) or ok
ok = update_scalar_field(lines, wolf_name, 'icon_png_path', app['icon']) or ok ok = update_scalar_field(lines, wolf_name, 'icon_png_path', app['icon']) or ok
if ok: if ok:
@@ -3837,6 +4083,65 @@ needing keys beyond what a joystick provides, is (also confirmed
against that same doc): Alt+1 DEL, Alt+2 INS, Alt+3 ERASE, Alt+4 CLEAR, against that same doc): Alt+1 DEL, Alt+2 INS, Alt+3 ERASE, Alt+4 CLEAR,
Alt+5 BEGIN, Alt+6 PROC'D, Alt+7 AID, Alt+8 REDO, Alt+9 BACK, Alt+= QUIT. Alt+5 BEGIN, Alt+6 PROC'D, Alt+7 AID, Alt+8 REDO, Alt+9 BACK, Alt+= QUIT.
## AntiMicroX: extra gamepad buttons for TI-99/4A and Wii U
If you said yes to the AntiMicroX prompt during install, both TI-99/4A and
Wii U (Cemu) get a second, separately-labeled launch command in ES-DE —
pick it via ES-DE's own **Alternative emulators** option (per-game or
per-system) instead of the default:
- TI-99/4A: **TI99SIM (AntiMicroX)**
- Wii U: **Cemu (AntiMicroX)**
The default command for both (**TI99SIM (Standalone)** / **Cemu
(Standalone)**) is completely unchanged — picking the AntiMicroX
alternative is opt-in per game/system, not automatic, and every other
ES-DE system is untouched regardless.
**What it's for.** \`ti99sim-sdl\`'s own joystick handling only ever
reaches digit keys 1-9 from a raw gamepad button (see the Controls note
above) — there's no way to send 0, Enter, Q, or Esc from a controller.
AntiMicroX sits between the gamepad and the emulator and remaps specific
buttons (or combos) to whatever keyboard key you want. For Wii U, it's a
different angle on the "second controller doesn't work" problem: Wolf's
virtual pads report identical SDL GUIDs, and AntiMicroX re-emits whatever
it reads as its own distinct virtual device — a plausible fix, not a
confirmed one, worth trying if Cemu still won't tell your controllers
apart.
**Building a profile.** AntiMicroX ships with no profile loaded by
default — build one yourself via its own GUI, the same way Cemu's own
controller/audio setup above uses Desktop instead of ES-DE:
1. Connect to **Desktop** in Moonlight (not ES-DE — AntiMicroX's GUI, like
Cemu's Settings dialogs, needs a real XFCE session to render reliably,
not ES-DE's Sway kiosk).
2. Open a terminal from Desktop's application menu and launch AntiMicroX
(check \`emulators/\` for the exact downloaded filename, e.g.
\`~/Applications/AntiMicroX-x86_64.AppImage\`).
3. Build your mapping (e.g. one gamepad button → Esc, another → Enter,
another → 0) using AntiMicroX's own button-capture UI.
4. Save the profile as exactly:
- \`emulators/antimicrox-profiles/ti994a.gamecontroller.amgp\` for TI-99/4A
- \`emulators/antimicrox-profiles/wiiu.gamecontroller.amgp\` for Wii U
The wrapper scripts look for these exact filenames — anything else is
ignored, and AntiMicroX just starts with no profile loaded instead
(not an error, just no remapping).
5. Quit AntiMicroX in Desktop afterward — it's only meant to run
automatically, scoped to the actual game session, via the wrapper.
**How the scoping works.** Only these two systems' AntiMicroX-labeled
commands launch through a wrapper script
(\`emulators/ti99sim-sdl-antimicrox\` / \`emulators/cemu-antimicrox\`) that
starts AntiMicroX hidden, loads the matching profile if one exists, runs
the real emulator, and kills AntiMicroX again once it exits.
**If remapped buttons don't do anything:** AntiMicroX needs
\`/dev/uinput\` to inject key events under ES-DE's Sway/Wayland session
(its other backend, XTest, needs Xwayland, which this container doesn't
run) — this installer grants that to the EmulationStation app
automatically, but this whole feature is new and hasn't been confirmed on
real hardware yet. Check the EmulationStation container's logs
(\`docker logs\`) if it doesn't behave as expected.
## MAME: sample packs showing up as games ## MAME: sample packs showing up as games
MAME sample packs (audio for a handful of older analog-sound games) use the MAME sample packs (audio for a handful of older analog-sound games) use the
exact same \`.zip\`-per-game naming as real ROMs, so if they're sitting in exact same \`.zip\`-per-game naming as real ROMs, so if they're sitting in