diff --git a/services/wolf.sh b/services/wolf.sh index c273cb4..f6cf110 100644 --- a/services/wolf.sh +++ b/services/wolf.sh @@ -321,6 +321,8 @@ EOF echo " - Offer to auto-download standalone emulator AppImages into emulators/:" 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 " - 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 fi @@ -993,6 +995,17 @@ UDEV # 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 # 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 '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' import re, sys path = sys.argv[1] @@ -1001,13 +1014,14 @@ try: content = f.read() except FileNotFoundError: content = '\n\n' -content = re.sub(r'[ \t]*\s*ti994a.*?\s*\n?', '', content, flags=re.DOTALL) +content = re.sub(r'[ \t]*\s*ti994a.*?[ \t]*\n?', '', content, flags=re.DOTALL) block = ''' ti994a Texas Instruments TI-99/4A %ROMPATH%/ti994a .ctg .rpk .bin - %INJECT%=%BASENAME%.esprefix %STARTDIR%=%EMUDIR% %EMULATOR_TI99SIM% --joystick1=1 --fullscreen %ROM% + %INJECT%=%BASENAME%.esprefix %STARTDIR%=%EMUDIR% %EMULATOR_TI99SIM% --joystick1=1 --fullscreen %ROM% + %INJECT%=%BASENAME%.esprefix %STARTDIR%=%EMUDIR% %EMULATOR_TI99SIM_AM% --joystick1=1 --fullscreen %ROM% ti99 ti99 @@ -1038,12 +1052,18 @@ try: content = f.read() except FileNotFoundError: content = '\n\n' -content = re.sub(r'[ \t]*.*?\s*\n?', '', content, flags=re.DOTALL) +content = re.sub(r'[ \t]*.*?[ \t]*\n?', '', content, flags=re.DOTALL) +content = re.sub(r'[ \t]*.*?[ \t]*\n?', '', content, flags=re.DOTALL) block = ''' ~/Applications/ti99sim-sdl + + + ~/Applications/ti99sim-sdl-antimicrox + + ''' content = content.replace('', block + '') with open(path, 'w') as f: @@ -1138,6 +1158,182 @@ TI99RULESPY log_info "Then just drop .ctg/.rpk/.bin cartridge files into roms/ti994a/" 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)" + # 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 = '\n\n' +content = re.sub(r'[ \t]*.*?[ \t]*\n?', '', content, flags=re.DOTALL) +block = ''' + + ~/Applications/cemu-antimicrox + + +''' +content = content.replace('', block + '') +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 = '\n\n' +content = re.sub(r'[ \t]*\s*wiiu.*?[ \t]*\n?', '', content, flags=re.DOTALL) +block = ''' + wiiu + Nintendo Wii U + %ROMPATH%/wiiu + .elf .ELF .rpx .RPX .tmd .TMD .wua .WUA .wud .WUD .wuhb .WUHB .wux .WUX + %EMULATOR_CEMU% -g %ROM% + %INJECT%=%BASENAME%.esprefix %STARTDIR%=%EMUDIR% %EMULATOR_CEMU_AM% -g %ROM% + wiiu + wiiu + +''' +content = content.replace('', block + '') +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 ───────────────────────────────────────────────────────── echo "" echo "═══════════════════════════════════════════════════════" @@ -1637,6 +1833,19 @@ selected = set(sys.argv[4:]) STD_CAP = ['NET_RAW', 'MKNOD', 'NET_ADMIN'] STD_ENV = ['RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'] 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 = { 'steam': dict( @@ -1666,8 +1875,9 @@ CATALOG = { f'{games}/emulators:/mnt/games/emulators:rw', f'{games}/emulators:/home/retro/Applications: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, + devices=['/dev/uinput:/dev/uinput'], ), 'lutris': dict( name='WolfLutris', title='Lutris', @@ -1778,6 +1988,7 @@ def make_app_block(app): create_json = json.dumps({'HostConfig': host_cfg}, indent=2) mounts_str = str(app['mounts']).replace('"', "'") env_str = str(ensure_tz(app['env'])).replace('"', "'") + devices_str = str(app.get('devices', [])) return ( f"\n" f" [[profiles.apps]]\n" @@ -1788,7 +1999,7 @@ def make_app_block(app): f" [profiles.apps.runner]\n" f" base_create_json = '''{create_json}\n" f"'''\n" - f" devices = []\n" + f" devices = {devices_str}\n" f" env = {env_str}\n" f" image = '{app['image']}'\n" f" mounts = {mounts_str}\n" @@ -1884,10 +2095,20 @@ for key in list(CATALOG.keys()): wolf_name = app['name'] new_mounts = str(app['mounts']).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) if already: ok = update_field(lines, wolf_name, 'mounts', new_mounts) 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, 'icon_png_path', app['icon']) or 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_ENV = ['RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'] 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 = { 'steam': dict( @@ -3133,8 +3367,9 @@ CATALOG = { f'{games}/emulators:/mnt/games/emulators:rw', f'{games}/emulators:/home/retro/Applications: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, + devices=['/dev/uinput:/dev/uinput'], ), 'lutris': dict( name='WolfLutris', title='Lutris', @@ -3246,6 +3481,7 @@ def make_app_block(app): create_json = json.dumps({'HostConfig': host_cfg}, indent=2) mounts_str = str(app['mounts']).replace('"', "'") env_str = str(ensure_tz(app['env'])).replace('"', "'") + devices_str = str(app.get('devices', [])) return ( f"\n" f" [[profiles.apps]]\n" @@ -3256,7 +3492,7 @@ def make_app_block(app): f" [profiles.apps.runner]\n" f" base_create_json = '''{create_json}\n" f"'''\n" - f" devices = []\n" + f" devices = {devices_str}\n" f" env = {env_str}\n" f" image = '{app['image']}'\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) new_mounts = str(app['mounts']).replace('"', "'") new_env = str(ensure_tz(app['env'])).replace('"', "'") + new_devices = str(app.get('devices', [])) if already: ok = update_field(lines, wolf_name, 'mounts', new_mounts) 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, 'icon_png_path', app['icon']) or ok if ok: @@ -3824,6 +4070,78 @@ when the TI-99/4A prompt ran, the installer offered to copy it there for you (tab-completing path prompt); otherwise copy it there by hand. Then just drop \`.ctg\`/\`.rpk\`/\`.bin\` cartridge files into \`roms/ti994a/\`. +**Controls.** \`ti99sim-sdl\` maps a real gamepad's stick to the TI-99 +joystick directly, and any joystick button beyond the first (the fire +button) to the number keys 1-9 (confirmed against its own source — +\`( VIRTUAL_KEY_E ) ( VK_0 + std::clamp(( int ) event.jbutton.button, 1, 9 ))\` +in \`src/sdl/ti994a-sdl.cpp\`) — there's no source-level path from a +joystick button to 0, Enter, Q, or Esc, so those need a real keyboard +reaching the stream. **Esc exits the emulator** (confirmed against its +own bundled \`doc/README.html\`) and hands focus back to ES-DE once the +process closes. The TI-99/4A's own FCTN function-key row, for anything +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, +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 (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