4.4.0 - #1663
Merged
Merged
4.4.0#1663
Conversation
Emu/ATOMISWAVE/config.json is active but no Roms/ATOMISWAVE folder existed, so the system could never list or scan games. Matches the NAOMI layout (Imgs/.gitkeep). Tracker: SPR-LOW-007.
Two RetroArch autoconfig files and the rg35xxsp key map carried CRLF endings; on-device parsers read the stray carriage returns as part of values. Tracker: SPR-LOW-007.
App/Credits declared led.png without shipping it and commented its label out as '#label', so themeless launches drew no icon and no name; Emu/ATOMISWAVE referenced atomiswave.png/_sel.png that did not exist. Copies of the SPRUCE theme's own icons now live beside the configs like every other app/emu, and the Credits label key is real again. Tracker: SPR-MED-029.
An empty or fully-excluded game pool re-ran the selection loop without end, pegging the CPU behind the please-wait screen. Thirty failed draws now end with a clear message. Tracker: SPR-LOW-001.
eval on the composed command string executed any shell metacharacters in a ROM file name; standard_launch.sh is now invoked directly with the path as a quoted argument (the cmd file keeps its shell-line format for the resume/kill consumers). get_rand_file also returned mid-loop with IFS still set to newline, leaking it into the caller. Tracker: SPR-MED-034.
If entering TMP_DIR failed, 'rm -r ./*' ran in whatever directory the script started from. Tracker: SPR-MED-033.
PyUI launches these directly. deleteMacFiles.sh called log_message without sourcing helperFunctions.sh and assumed python3 on PATH; fetchPPSSPPauth.sh invoked the auth helper by repo-relative path, which only worked from the payload root. Helpers are sourced, the device Python resolver is used, and the path is absolute. Tracker: SPR-MED-037.
command.txt comes from the GUI's stream selection; eval executed any shell metacharacters in a host or app name. The line is now field-split with globbing off and passed as arguments. Tracker: SPR-MED-035.
Inside the double-quoted preset heredoc-style strings, the bare "" after 'alias0 = ' closed and reopened the shell string, so the written .glslp carried an unquoted empty value instead of alias0 = "". Tracker: SPR-MED-013.
The description used 'ReARMed' while the task key uses 'ReARMED', so PyUI showed no description for that task. Tracker: SPR-MED-036.
Follow-up to the empty-pool escape: use a named constant and counter so the bound is self-describing. Tracker: SPR-LOW-001.
audio_cleanup went through _send_cmd, whose _ensure_worker spawned a brand-new worker - opening the audio device - purely so the cleanup command could kill it. With no live worker the state is now reset directly. Tracker: SPR-MED-004.
Two display_message_multiline lists were missing a comma, so adjacent f-strings concatenated into 'Scale factor is 0.75Patching main assets' on one line. Tracker: SPR-MED-032.
'[ -d ] || log && return 1' groups as '([ -d ] || log) && return 1': when the backup folder existed the guard still returned 1 immediately, so theme config restore never ran at all; when it was missing, only the log-and-return path worked by accident. Now an explicit if. Tracker: SPR-MED-005.
'7zr l | grep /mnt/SDCARD/' matched the archive's own pathname in the listing header, so every queued archive passed the layout gate and was extracted at / wherever its members pointed - a malicious or malformed theme archive could write anywhere on the filesystem. The gate now reads member paths from 7zr -slt and requires every entry to sit under mnt/SDCARD/ (ancestor directory entries allowed); staged e2e tests confirm wrong-layout archives are skipped and kept in the queue while correct themes still install. Tracker: SPR-HIGH-010.
restore_audio() lost its only caller when headphones_watchdog.sh was removed, but the function stayed behind: it suspends the rk817 sink and then backgrounds a script that no longer exists. Delete it. launch_startup_watchdogs also backgrounds theme_watchdog.sh and leds_manager.sh outside the common launcher, so they missed the stop_running_watchdog dedupe the other platforms' extras got. Run it for both before launching, the same no-op at a cold boot.
`(( ... ))` is a bash arithmetic command. Under a POSIX sh it parses as a nested subshell whose output is redirected to a file named "=", so the guard was always false: the brightness never changed and a stray "=" file appeared per call. `[ -ge ]`/`[ -le ]` say the same thing in every shell; the redirect keeps a non-numeric value from printing a test error.
`[[ ]]` and `&>` are bash extensions. dash parses `cmd &>f` as "background cmd, then truncate f" - the misparse upstream measured on the RGB30 for the PortMaster scripts - and `[[` is simply "command not found" on ash. The four remaining sites become `[ ]` and `>f 2>&1`, which mean the same under bash.
update_images.sh is exec'd directly from launch.sh yet had no interpreter line while using `[[ ]]`, so whichever shell the caller happened to be under parsed it. Declare bash, which every PortMaster device ships. PortMaster.txt's PortMaster.zip block kept a `continue` copied from the loops above it, outside any loop: bash only warns and falls through, and the test it guarded re-tested a variable the next line reassigns anyway. Remove it.
Both writers stage through a delete=False NamedTemporaryFile and only ever unlinked it by way of os.replace. A json.dump that raises, or a replace that fails, left the tmp* file beside the config it never replaced, one more per failed save. Record the name before dumping and remove it on any failure, then re-raise so the callers' handling is unchanged.
monitor_start_button forks getevent inside a backgrounded subshell and keeps its pid there, so monitor_sync_status's trap, which kills only the subshell, orphaned the reader on every cancelled, timed-out or stalled sync. During shutdown that is the one input reader left alive: save_poweroff.sh clears getevent before the sync check starts. Publish the pid to a file the trap can read and kill both.
AnbernicXXCommon.sh overrides launch_startup_watchdogs instead of calling the common launcher, and the common launcher is the only place low_power_warning.sh is ever started - so every XX device ran with no low-battery warning and no critical-battery shutdown. Start it the way the override starts its other watchdogs, and include it in the per-start dedupe list.
Batch fix automated
PR #1633 traded several working oddities for clean-looking breakage. Five of these are regressions from that batch; two are older bugs found while reviewing it. Regressions from #1633: RandomGame launched every game with no emulator. standard_launch.sh takes its identity from $0 - it strips up to "/Emu/" and keeps the next path component as EMU_NAME - so the Emu/<system>/../../ detour was load-bearing, not redundant. The canonical path contains no /Emu/, leaving EMU_NAME empty and EMU_JSON_PATH at /mnt/SDCARD/Emu//config.json. Three consumers broke: core selection, the game switcher's Emu/ sed in button_actions.sh, and save_poweroff.sh's close_non_emu_cmd_to_run, which classified every random launch as an App and deleted lastgame.lock - silently discarding autoresume. The eval removal from that commit is kept. Moonlight streams all failed. gui/main.lua writes shell syntax - quoted arguments and a literal $GAMEDIR - which only worked because launch.sh ran it through eval. Replacing eval with `set -- $COMMAND` does neither quote removal nor parameter expansion, so moonlight received the literal "Desktop" with its quotes and the literal string $GAMEDIR/keys, and a multi-word app name split into several arguments. Fixed at the producer instead: one argument per line, read back with `while IFS= read -r`. launch.sh contributes "stream" and -keydir because it is what knows GAMEDIR. Two guards go with it - a nil in a Lua table constructor is dropped rather than stored, so table.concat would quietly write a truncated argument list where the old .. raised. ROM lists crashed on any gamelist entry without <image>. image=None reaches CachedExists.exists -> os.path.normpath(None) -> TypeError, raised lazily while the grid draws and outside any handler. "" is not a safe placeholder either: normpath("") is ".", which exists. EBOOT.PBP disappeared from PSX. PyUI lowercases both sides so dropping the uppercase entry was a no-op there, but MiyooGamelist/generate.py and RandomGame/random.sh both match case-sensitively, and uppercase is the canonical form for these images. "Only when unplugged" could no longer sleep on unplug. The edge trigger latched the raw lid state even on the loop where the charging check rejected the close, so closing the lid while charging and unplugging later never slept until the lid was physically cycled. Latch whether this close was acted on, separately from the lid state, and clear it when the lid opens. Older bugs found while reviewing: A <game> carrying <path> but no <name> takes down the ROM list sort. name=None becomes display_name, and get_sort_key() calls .strip() on it. Fall back to the filename the way a missing entry already does. AnbernicXXCommon.cfg set LED_PATH="" where every other LED-less platform uses the "not applicable" sentinel. LED_PATH is concatenated, so an empty value resolves to /brightness and /trigger and the writes land at the filesystem root: on every boot from runtime.sh, every shutdown from save_poweroff.sh, and every 30s once low_power_warning.sh started running there. Claude-Session: https://claude.ai/code/session_01DERvo9DJPSLZKjmvFDTvPo
Fix five regressions from the automated batch (#1633), and two bugs it surfaced
Entering a password on one device should be enough. It wasn't, and the reason was that the two halves of spruce disagreed about where the file lives. helperFunctions.sh has always set WPA_SUPPLICANT_FILE to /mnt/SDCARD/Saves/spruce/wpa_supplicant.conf, and enable_wifi boots the supplicant against it. PyUI wrote somewhere else entirely: /userdata/cfg on the Flip and TrimUI, /appconfigs on the Mini, /config on the A30 - all on the handheld's own storage. So the password the user typed went to internal flash while the device booted from a file on the card that never received it. What hid this is the takeover path in enable_wifi: spruce notices a supplicant on the wrong config and scrapes its networks across before killing it. A recovery mechanism was carrying the feature, and only when some other supplicant happened to be running. get_wpa_supplicant_conf_path now has a single definition in DeviceCommon returning the card path, and the six per-device overrides are gone. Two of them (Anbernic XX, Miniloong) already pointed at the card, so the target state was proven on hardware rather than theoretical. Saves/spruce is untracked, which is why it survives an update - the same reasoning that put the timezone in Saves/spruce/shared-system.json rather than the tracked py-ui-config.json. Existing networks are adopted at boot. enable_wifi sweeps the old locations through import_wpa_networks_from, which already merges blocks, skips SSIDs we hold, and logs only counts because these files carry keys in the clear. Because it skips duplicates it is idempotent and runs every boot on purpose, so it keeps working for someone who joins a network through the stock UI. clearwifi.sh had to clear those locations too. It only truncated the card file, so the adoption sweep would have re-imported everything on the next boot and a "forget all networks" would have silently undone itself. The path list is one exported WPA_LEGACY_CONFS so the sweep and the clear cannot drift apart. App/adbd/launch.sh brought up its own supplicant against /appconfigs, which PyUI no longer writes, so a network added in the UI would never have reached adbd. It now uses the card file, matching what platform/miyoo_mini_startup.sh already does for the same job on the same device, and falls back for a card that predates the move. Also: _write_wpa_supplicant_block did open(path,"w") with no makedirs inside a broad except that only logs, so a missing directory discarded a password silently. And wifi_menu.write_wpa_supplicant_conf was a second, divergent copy of that same function with no callers anywhere - deleted before someone fixed a bug in the copy nothing runs. Untouched, all of which manage WiFi another way: the Pixel 2 (connman), the RGB30 (nmcli), and the gkd/muos/rocknix classes that return None. Not yet verified on a Flip: whether anything in its stock layer reads /userdata/cfg/wpa_supplicant.conf. TrimUI came back clean - nothing under /etc, /usr/bin, /usr/sbin, /bin or /sbin references it, and the Brick Pro has no /userdata at all. If the Flip does, the fix follows the timezone precedent: the card file stays the source of truth and the Flip additionally writes the stock copy. Claude-Session: https://claude.ai/code/session_01DERvo9DJPSLZKjmvFDTvPo
Firstboot showed "Sprucing up your device...\nExtracting files", and the same thing turned up in the Game Nursery welcome text. Every display_* call site in the tree writes a two-character \n where it wants a line break - 13 of them across 8 files. Before json_escape existed that sequence went straight into the JSON string and decoded to a real newline. json_escape correctly escapes backslashes, which doubles it to \\n, so the JSON now decodes to a literal backslash-n and PyUI draws it. Fold the sequence back to a real newline before escaping; the awk at the end already re-emits real newlines as a JSON \n. Fixing the one function restores the contract all 13 sites already follow, which beats converting them to real newlines - in POSIX sh that means literal line breaks inside quotes, where the indentation silently becomes part of the string. The replacement is a backslash-newline rather than \n because busybox sed does not read \n on the right-hand side. Checked on the wire: json_escape was emitting the bytes \ \ n, decoding to a literal backslash-n with no newline. After the change the firstboot and multi-line strings decode to real newlines, and the escaping still does its job - embedded double quotes survive, a real backslash in "C:\temp" stays single, tabs and real newlines encode correctly. Not just firstboot: this also hit the USB storage prompts, the RetroArch core-change save transfer prompt (four line breaks in one message), the DraStic and DSperate error messages, and the unpacker's progress text. A path containing a literal \n, like C:\newfile, folds to a newline. That was equally broken before json_escape existed, so this restores the old behaviour rather than introducing anything new. Claude-Session: https://claude.ai/code/session_01DERvo9DJPSLZKjmvFDTvPo
Two things, both found by fitting a motor to an RGB30 and asking whether the intensity setting was even reachable on it. The RGB30 tuning. The motor Powkiddy leaves pads for is a small button type and it is faint: Medium at 0xC000/150ms was barely perceptible in the game switcher, while 0xFFFF at 300ms is easily felt. Magnitude is a 16-bit value and Medium already sat at 75% of full scale, so there was almost no headroom there - each tier now carries a duration too, or Weak/Medium/Strong are indistinguishable at the top of the range. Confirmed on hardware as low/med/high against the old default, which read as "super low". A duration passed by a caller still wins, so low_power_warning's morse timing is untouched. "Off" is where it got interesting. It is one of the four options the setting offers, and no platform handled it: - SmartProS fell through the case, which only sets max_scale, and then ran "echo -n 65535 > /sys/class/motor/level" unconditionally. Off buzzed at full level. - Miniloong's catch-all gave Off a half-period duty cycle. - MiyooMini never read the setting at all, so Off buzzed like anything else. - AnbernicXXCommon and Pixel2 passed the literal string "Off" to the rumble binary, which rejects it - no rumble, but "intensity must be 0..65535" and exit 2 on every vibrate call. - SmartPro and the shared rumble_gpio logged "Invalid intensity: Off" the same way, on every call. - A30 was already correct; its else branch declines to rumble. All of them now return early on Off, after argument parsing so --intensity Off works as well as the setting. Flip and trimui_a133p are covered through rumble_gpio. Miniloong needed more than the guard. It reads .rumble_intensity from SYSTEM_JSON and nothing in the tree writes that key, so jq always fell back to "Medium" and the setting has never done anything on that device - Off included. It now falls back to the same spruce-config key every other platform reads, keeping SYSTEM_JSON first for anything that might set it later. Only the RGB30 was exercised on hardware. The other platforms are source-level only, and three of them had motor-driving code changed, SmartProS most of all - it now returns before the unconditional level write. Claude-Session: https://claude.ai/code/session_01DERvo9DJPSLZKjmvFDTvPo
Holding Home rumbled and then sat there for an awkward few seconds before the game switcher appeared. The log put the time in one place: prepare_game_switcher captures the screen before kill_emulator, and that capture was eating most of it. The grab itself is not the problem - loading ffmpeg is. Measured on hardware, three runs each: /usr/bin/ffmpeg 204 shared libs 2302ms to start 3.0-4.5s total /mnt/SDCARD/spruce/bin/ffmpeg static, 0 libs 76ms to start 1.0-1.7s total take_screenshot tried Debian's build first, so every hold-home paid two seconds of dynamic linking before anything was captured. Putting spruce's static build first takes the capture from 3256ms to 986ms on a straight A/B, 2270ms saved on every hold-home. Confirmed on the device by the user. The reason the slow one was first is a real trap, just a different binary from the one it was written about: spruce/bin64/ffmpeg has no kmsgrab demuxer and fails with "Unknown input format", and bin64 is what spruce puts first on PATH. spruce/bin/ffmpeg is a separate 31M binary that does have the demuxer - verified on hardware, producing an identical 720x720 frame. Debian's stays as the fallback. Also corrected the comment's cost estimate: it claimed about 2.3s, actual was 3.0-4.5s. The measurements are in the file now so the next person does not have to re-derive them. About a second of capture remains and cannot be moved - the frame has to be grabbed while the emulator still owns the screen. A dedicated DRM helper would cut most of that, but it is real work rather than a reorder. Claude-Session: https://claude.ai/code/session_01DERvo9DJPSLZKjmvFDTvPo
Rumble now has three distinguishable settings on the Mini, and the duration argument means what it does everywhere else. The GPIO is a bare on/off pin with no intensity control, so intensity comes from toggling it - the same trick rumble_gpio uses on the SmartPro and Flip. It is NOT amplitude control though, and the reason is worth recording: busybox sleep floors at 10ms on this hardware (measured: sleep 0.005 takes 10.1ms), so the toggle lands at 33-50Hz. That is inside the range you feel as individual pulses rather than above the motor's mechanical response, so the tiers read as pulse rate. Tester's words: Weak is "a slow rumble", Strong is "a powerful BRRRRRRRR". Real amplitude control needs sub-millisecond timing, which means a C helper like the freestanding rumble binary the evdev devices use, not shell. Strong pin held low, solid DC Medium 50Hz toggle, 50% duty Weak 33Hz toggle, 25% duty Measured on a Mini Plus: 420 / 430 / 370ms against a 400ms request, so the tiers differ in texture and not in length. Cycle counts are derived from what the sleeps actually cost rather than the nominal timing, or everything runs double. Separately, duration was in SECONDS here and milliseconds on every other platform - "sleep $duration" straight from the argument. low_power_warning.sh calls "vibrate 100", so a low battery meant a 100 SECOND buzz, fired repeatedly from the morse loop on a device that is already nearly flat. It now reads as milliseconds: vibrate 100 -> 110ms, 250 -> 250ms, 800 -> 820ms. rumble_gpio is not reused because it writes 1 to start the motor and the Mini needs 0. Threading a polarity flag through a function the SmartPro, Flip and trimui_a133p all share looked worse than keeping the Mini's own copy. Not fixed here: rumble_gpio has the same 2x inflation, its "timer + 6" assuming 6ms for two sleeps that actually cost about 20ms, so SmartPro and Flip rumbles likely run ~3x longer than asked. Claude-Session: https://claude.ai/code/session_01DERvo9DJPSLZKjmvFDTvPo
A, B and the C buttons came from gptokeyb2 keystrokes, but the mali SDL2 has no evdev keyboard path, so they never arrived. The pad tables are now all raw joystick bindings (C on the right stick where fitted, A/X/SELECT/R2 otherwise, like muOS) and gptokeyb2 is not started there. Stickless units get the d-pad as the stick for the run via the swap helper, now shared with ScummVM in helperFunctions.sh. Verified on a 35XXSP.
The overlay hardcoded X360 raw button numbers, which on this pad are the ESC/VOL- phantom keys: confirm was volume-down and nothing backed out. The rebuilt video plugins (mupen64plus-spruce b3cbeb9) read the numbers and labels from EMU_OVERLAY_BTNMAP/BTNLABELS, which the XX launcher exports.
The BaseOS BusyBox mount rejects the long form, so on the Anbernic XX the Flycast and PCSX BIOS mounts, the RA per-arch state dirs and the screenshot dirs were all silently skipped. Every mount takes -o bind.
Rebuilt from YabaSanshiro-spruce 49d2ded: retro_arena's main thread called SDL_GL_MakeCurrent(wnd,nullptr) after the render thread owned the context. The mali-fbdev SDL2 tracks the current context globally, so every swap on its blitter path re-bound a null context and one buffer was never drawn. Verified on a 35XXSP with the blitter on, which is the RG28XX's path too.
The mali-fbdev SDL2 already rotates that panel and presents a landscape desktop, so transform_screen 135 turned the picture a second time. Keep transform 0 on the whole XX line and size the stretch to the desktop.
…o that they only contain what specifically differs from the default.ini, to make them easier to read and compare
…is handled inside run_dsperate, so no bios check is performed on boot action.
# Conflicts: # spruce/scripts/emu/lib/ports_functions.sh
PR #337 merged upstream, so the bundle now ships PlatformSpruce, the spruce-first device_info and config.py's real paths. launch.sh's merged test fires on it: no staging, PortMaster.sh comes from spruce/.
Sundownersport
requested review from
XanXic,
chrisj951 and
ryanmsartor
as code owners
September 11, 2026 21:17
New device families and the PortMaster overhaul make this a minor bump, not a patch. Existing upgrade scripts keep their names: the runner matches filenames against .lastUpdate and never reads spruce/spruce.
hardwareservice and the trimui blobs reset the mixer when they finish initialising, and boot load moves when that happens. The single 1.5s restore from 38de31d lands before it on a busy boot, so the device ignores the saved level until a volume key is pressed.
hardware.py names the firmware from /usr/trimui and /usr/miyoo and has no spruce test, so harbour.py picked PlatformTrimUI or PlatformMiyoo and wrote their control.txt over ours. Patch it in place beside config.py. That exposed the A/B split: the Flip and TrimUI inherit SDL's positional map, the Anbernic and RGB30 were handed a label-named one, so no single WANT_XBOX_FIX suited both. Everything is positional now and the flag is on. Verified on a Smart Pro S, an RG28XX and an RGB30.
On a first boot PyUI has not written SYSTEM_JSON yet, so jq printed nothing and comparing that with -eq exited 2 - which the if read as false and took the enable branch. A fresh Flip install joined WiFi with the setting off, and the update check then skipped because the stored value said off.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release PR for 4.4.0. 549 commits since 4.3.4, across 39 merged PRs and ten contributors.
New hardware
ra64.h700/ra32.h700builds, the 32-bit stack inspruce/h700/lib32, mali-fbdev SDL2 inApp/PyUI/dll-mali.PortMaster
Upstream merged PortMaster-GUI #337, so
PlatformSpruceand the real spruce paths ship in the release rather than being patched in on the card. Bundle is 2026.09.11-0804, the first one carrying it, andlaunch.shstages nothing on that path.Roms/PORTSis renamed toRoms/portsand every PORTS/ports bind mount is gone.spruce/scripts/ports_migration.shdoes the rename once on the first boot after an install or update, and rewrites the paths held in recents, state, game-time, A30 port scripts and an installed harbourmaster config.Emulators
New NDS Emulator DSperate!
Two new systems: Jaguar via BigPEmu, Tandy CoCo via xroar.
Networking
Saved networks live on the card rather than per handheld. The clock is set from network location, so HTTPS works on a device with a dead RTC. USB WiFi dongles on the Flip and RG28XX. Downloads moved to curl, since BaseOS cannot do HTTPS with its busybox wget.
Power and shutdown
Lid sleep on the XX, clean unmounts on shutdown, the A30 sleep and audio path, USB storage mode exit, and the Flip power audit.
Release mechanics
spruce/spruceandautorun.infread 4.4.0. Upgrade scripts4.3.5.shand4.3.6.shkeep their names — the runner matches filenames against.lastUpdate, notspruce/spruce.4.3.7.shwas removed: XX users take the new defaults and reconfigure.Testing
Verified on hardware device by device as it landed: Flip, Brick, Brick Pro, Smart Pro, Smart Pro S, A30, CubeXX, 35XX-SP, RG28XX, RGB30.
Not yet verified:
Roms/PORTSlayout.