├── .github ├── FUNDING.yml └── workflows │ └── package-and-release.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── Scrappy.sh ├── assets ├── ChakraPetch-Regular.ttf ├── icons │ ├── caret-left-solid.png │ ├── caret-right-solid.png │ ├── circle-info-solid.png │ ├── compact-disc-solid.png │ ├── display-solid.png │ ├── download-solid.png │ ├── file-image-regular.png │ ├── file-import-solid.png │ ├── folder-open-regular.png │ ├── gamepad-solid.png │ ├── image-regular.png │ ├── magnifying-glass-solid.png │ ├── object-group-solid.png │ ├── rotate-right-solid.png │ ├── sd-card-solid.png │ ├── square-check-solid.png │ ├── square-regular.png │ ├── triangle-exclamation-solid.png │ └── wrench-solid.png ├── inputs │ ├── switch_button_a.png │ ├── switch_button_b.png │ ├── switch_button_sl.png │ ├── switch_button_sr.png │ ├── switch_button_x.png │ ├── switch_button_y.png │ └── switch_dpad_vertical_outline.png ├── loading.png ├── muos-logo.png ├── scrappy.png └── scrappy_logo.png ├── bin ├── Skyscraper.aarch64 ├── libs.aarch64 │ ├── libQt5Core.so.5 │ ├── libQt5Gui.so.5 │ ├── libQt5Network.so.5 │ ├── libQt5Sql.so.5 │ ├── libQt5Xml.so.5 │ ├── libcrypto.so.1.1 │ ├── libicudata.so.66 │ ├── libicui18n.so.66 │ ├── libicuuc.so.66 │ ├── liblove-11.5.so │ ├── libluajit-5.1.so.2 │ ├── libpcre.so.3 │ ├── libpcre2-16.so.0 │ ├── libpng16.so.16 │ └── libssl.so.1.1 ├── love └── plugins │ └── imageformats │ └── libqjpeg.so ├── conf.lua ├── config.ini.example ├── data ├── cache │ └── .gitkeep └── output │ └── .gitkeep ├── docker ├── Dockerfile └── package_skyscraper.sh ├── globals.lua ├── helpers ├── artwork.lua ├── config.lua ├── input.lua ├── muos.lua └── utils.lua ├── lib ├── backend │ ├── channels.lua │ ├── log_backend.lua │ ├── skyscraper_backend.lua │ ├── skyscraper_generate_backend.lua │ └── task_backend.lua ├── gui │ ├── badr.lua │ ├── button.lua │ ├── checkbox.lua │ ├── footer.lua │ ├── icon.lua │ ├── label.lua │ ├── listitem.lua │ ├── output_log.lua │ ├── popup.lua │ ├── progress.lua │ ├── scroll_container.lua │ └── select.lua ├── ini.lua ├── json.lua ├── loading.lua ├── log.lua ├── metadata.lua ├── nativefs.lua ├── parser.lua ├── pprint.lua ├── scenes.lua ├── skyscraper.lua ├── splash.lua └── timer.lua ├── logs └── .gitkeep ├── main.lua ├── mux_launch.sh ├── sample ├── covers │ └── screenscraper │ │ └── fake-rom ├── db.xml ├── fake-rom.zip ├── marquees │ └── screenscraper │ │ └── fake-rom ├── media │ ├── covers │ │ └── fake-rom.png │ └── marquees │ │ └── fake-rom.png ├── priorities.xml ├── quickid.xml ├── screenshots │ └── screenscraper │ │ └── fake-rom ├── textures │ └── screenscraper │ │ └── fake-rom └── wheels │ └── screenscraper │ └── fake-rom ├── scenes ├── main.lua ├── settings.lua ├── single_scrape.lua └── tools.lua ├── scripts └── update.sh ├── skyscraper_config.ini.example ├── static └── .skyscraper │ ├── import │ └── .gitkeep │ ├── peas.json │ ├── platforms_idmap.csv │ ├── tgdb_developers.json │ ├── tgdb_genres.json │ ├── tgdb_platforms.json │ └── tgdb_publishers.json ├── templates ├── box2d-splash-preview.xml ├── box2d.xml ├── jdcross_brush_torn.xml ├── md9000-split.xml ├── outlined-wheel-box-texture.xml ├── resources │ ├── boxfront.png │ ├── boxside.png │ ├── mask │ │ ├── 3px_dither.png │ │ ├── 3px_dither_720.png │ │ ├── BlackGradientShadow_bordered.png │ │ ├── brush_transparent_inverted.png │ │ └── md9000-split.png │ ├── scanlines1.png │ └── scanlines2.png ├── retro-dither-logo.xml ├── retro-dither-logo_720.xml ├── retro-dither.xml └── three-mix.xml └── theme.ini /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: gabrielfvale 2 | -------------------------------------------------------------------------------- /.github/workflows/package-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Package and Release (on tag) 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: write 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Create directory structure 22 | run: | 23 | mkdir -p ./Scrappy/.scrappy 24 | 25 | - name: Get the tag name 26 | id: get_tag 27 | run: echo "TAG_NAME=${{ github.ref_name }}" >> $GITHUB_ENV 28 | 29 | - name: Package full install and update zips 30 | run: | 31 | ZIP_NAME="Scrappy_${TAG_NAME}.muxzip" 32 | UPDATE_ZIP_NAME="Scrappy_${TAG_NAME}_update.muxapp" 33 | 34 | # Create the Scrappy directory structure 35 | mkdir -p ./Scrappy/.scrappy 36 | 37 | # Copy files for the update package 38 | cp mux_launch.sh ./Scrappy/ 39 | cp -r assets helpers lib scenes scripts templates ./Scrappy/.scrappy/ 40 | cp conf.lua globals.lua main.lua config.ini.example skyscraper_config.ini.example theme.ini ./Scrappy/.scrappy/ 41 | 42 | # Create the update zip 43 | zip -r $UPDATE_ZIP_NAME ./Scrappy 44 | 45 | # Copy files for the full install package 46 | cp -r bin data logs sample static ./Scrappy/.scrappy/ 47 | # Create the full install directory structure 48 | mkdir -p ./mnt/mmc/MUOS/application 49 | cp -r ./Scrappy ./mnt/mmc/MUOS/application/ 50 | 51 | # Include assets/scrappy.png in /opt/muos/default/MUOS/theme/active/glyph/muxapp/ 52 | mkdir -p ./opt/muos/default/MUOS/theme/active/glyph/muxapp/ 53 | cp assets/scrappy.png ./opt/muos/default/MUOS/theme/active/glyph/muxapp/ 54 | 55 | # Create the full install zip 56 | zip -r $ZIP_NAME ./mnt/mmc/MUOS/application/Scrappy ./opt/muos/default/MUOS/theme/active/glyph/muxapp/scrappy.png 57 | 58 | # List files to verify they were created 59 | ls -l 60 | 61 | - name: Get commit messages for the release 62 | id: get_commits 63 | run: | 64 | # Try to get the previous tag 65 | PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") 66 | 67 | # If no previous tag is found, use the initial commit as the fallback 68 | if [ -z "$PREVIOUS_TAG" ]; then 69 | PREVIOUS_TAG=$(git rev-list --max-parents=0 HEAD) 70 | fi 71 | 72 | # Get commits between the previous tag and the current commit (from PREVIOUS_TAG to HEAD) 73 | COMMITS=$(git log ${PREVIOUS_TAG}..HEAD --oneline --no-merges) 74 | 75 | # Separate commits by type 76 | CHANGED=$(echo "$COMMITS" | grep -iE "^[a-f0-9]+ (chore:|refact:|refactor:)" || echo "None") 77 | ADDED=$(echo "$COMMITS" | grep -iE "^[a-f0-9]+ (feat:|wip:)" || echo "None") 78 | FIXED=$(echo "$COMMITS" | grep -iE "^[a-f0-9]+ (fix:)" || echo "None") 79 | 80 | # Format the output with actual newlines 81 | RELEASE_NOTES="## Scrappy ${TAG_NAME} 82 | 83 | ### 🛠️ Changed 84 | ${CHANGED} 85 | 86 | ### ✨ Added 87 | ${ADDED} 88 | 89 | ### 🐛 Fixed 90 | ${FIXED}" 91 | 92 | # Save the release notes for later use 93 | echo "RELEASE_NOTES<> $GITHUB_ENV 94 | echo "$RELEASE_NOTES" >> $GITHUB_ENV 95 | echo "EOF" >> $GITHUB_ENV 96 | 97 | - name: Create GitHub release with commit notes 98 | uses: softprops/action-gh-release@v2 99 | if: startsWith(github.ref, 'refs/tags/') 100 | with: 101 | files: | 102 | Scrappy_${{ env.TAG_NAME }}.muxzip 103 | Scrappy_${{ env.TAG_NAME }}_update.muxapp 104 | body: ${{ env.RELEASE_NOTES }} 105 | env: 106 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 107 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.ini 2 | *.log 3 | gamelist.xml 4 | 5 | roms/ 6 | cache/ 7 | sample/media/* 8 | data/* 9 | 10 | docker/libs 11 | docker/out 12 | docker/Qt* 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2024] [Gabriel Vale] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![scrappy](https://github.com/user-attachments/assets/78e48f14-45a8-427d-99ba-80f20ba018dd) 2 | # Scrappy 3 | Scrappy is an art scraper for muOS, with the standout feature of incorporating a fully-fledged **Skyscraper** app under the hood. This integration enables near-complete support for artwork XML layouts, allowing Scrappy to scrape, cache assets, and generate artwork using XML mixes with ease. 4 | 5 | Please read the Wiki for more info on installation and configuration! 6 | * [Getting started](https://github.com/gabrielfvale/scrappy/wiki/Getting-Started) 7 | 8 | ## Features 9 | * Skyscraper backend (artwork XML, cached data, and many other features) 10 | * Auto-detection of storage preferences 11 | * Auto-detection of ROM folders (based on muOS core assignments) 12 | * Configurable app options 13 | * Simple UI & navigation 14 | * Support for user-created artworks (easily drop your XML in `templates/`) 15 | * Support for `box`, `preview` and `splash` outputs 16 | * Support for `arm64` devices with LOVE2d 17 | * OTA updates 18 | 19 | ![image](https://github.com/user-attachments/assets/3f22110f-9df0-4ee6-80f5-e83f42dd1052) 20 | 21 | ## Caveats 22 | * Screenscraper credentials need to be manually added to `skyscraper_config.ini` 23 | * First time scraping can be slow (this is expected, but worth noting) 24 | 25 | ## Resources 26 | 27 | - **Skyscraper** - Artwork scraper framework by Gemba [Skyscraper on GitHub](https://github.com/Gemba/skyscraper) 28 | - **ini_parser** - INI file parser by nobytesgiven [GitHub](https://github.com/nobytesgiven/ini_parser) 29 | - **nativefs** - Native filesystem interface by EngineerSmith [GitHub](https://github.com/EngineerSmith/nativefs) 30 | - **timer** - Lightweight timing library by vrld [GitHub](https://github.com/vrld/hump) 31 | - **boxart-buddy** - A curated box art retrieval library [GitHub](https://github.com/boxart-buddy/boxart-buddy) 32 | - **LÖVE** - framework for 2D games in Lua [Website](https://love2d.org/) 33 | - **LÖVE aarch64 binaries** - LOVE2D binary files for aarch64 [Arch Linux Arm](https://archlinuxarm.org/packages/aarch64/love) and [Cebion](https://github.com/Cebion/love2d_aarch64) 34 | 35 | ## Special thanks 36 | 37 | - **Snow (snowram)** - for the huge undertaking of compiling Qt5 and sharing with this project [Kofi](https://ko-fi.com/snowram) 38 | - **Portmaster and their devs** - for great documentation on porting games/software for Linux handhelds [Portmaster](https://portmaster.games/porting.html) 39 | - Testers and many other contributors 40 | 41 | ## Supporting the project 42 | Your greatest support comes through testing. If you'd like to help the project financially, consider donating through Ko-Fi! 43 | 44 | [![Ko-Fi](https://img.shields.io/badge/Ko--fi-F16061?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/gabrielfvale) 45 | 46 | ## Contributing 47 | 48 | Contributions to Scrappy are welcome! Please fork the repository, make your changes, and submit a pull request. 49 | 50 | ## License 51 | 52 | This project is licensed under the MIT License. See `LICENSE.md` for more details. 53 | -------------------------------------------------------------------------------- /Scrappy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # HELP: Scrappy 3 | # ICON: scrappy 4 | 5 | . /opt/muos/script/var/func.sh 6 | 7 | # Define global variables 8 | SCREEN_WIDTH=$(GET_VAR device mux/width) 9 | SCREEN_HEIGHT=$(GET_VAR device mux/height) 10 | SCREEN_RESOLUTION="${SCREEN_WIDTH}x${SCREEN_HEIGHT}" 11 | 12 | if pgrep -f "playbgm.sh" >/dev/null; then 13 | killall -q "playbgm.sh" "mpg123" 14 | fi 15 | 16 | echo app >/tmp/act_go 17 | 18 | # Define paths and commands 19 | LOVEDIR="$(GET_VAR "device" "storage/rom/mount")/MUOS/application/.scrappy" 20 | GPTOKEYB="$(GET_VAR "device" "storage/rom/mount")/MUOS/emulator/gptokeyb/gptokeyb2.armhf" 21 | STATICDIR="$LOVEDIR/static/" 22 | BINDIR="$LOVEDIR/bin" 23 | 24 | # Export environment variables 25 | export SDL_GAMECONTROLLERCONFIG_FILE="/usr/lib/gamecontrollerdb.txt" 26 | export XDG_DATA_HOME="$STATICDIR" 27 | export HOME="$STATICDIR" 28 | export LD_LIBRARY_PATH="$BINDIR/libs.aarch64:$LD_LIBRARY_PATH" 29 | export QT_PLUGIN_PATH="$BINDIR/plugins" 30 | 31 | # Create Skyscraper folders 32 | mkdir -p $HOME/.skyscraper/resources 33 | cp -r $LOVEDIR/templates/resources/* $HOME/.skyscraper/resources 34 | 35 | # Launcher 36 | cd "$LOVEDIR" || exit 37 | SET_VAR "system" "foreground_process" "love" 38 | 39 | # Run Application 40 | $GPTOKEYB "love" & 41 | ./bin/love . "${SCREEN_RESOLUTION}" 42 | 43 | kill -9 "$(pidof gptokeyb2.armhf)" 44 | -------------------------------------------------------------------------------- /assets/ChakraPetch-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/ChakraPetch-Regular.ttf -------------------------------------------------------------------------------- /assets/icons/caret-left-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/caret-left-solid.png -------------------------------------------------------------------------------- /assets/icons/caret-right-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/caret-right-solid.png -------------------------------------------------------------------------------- /assets/icons/circle-info-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/circle-info-solid.png -------------------------------------------------------------------------------- /assets/icons/compact-disc-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/compact-disc-solid.png -------------------------------------------------------------------------------- /assets/icons/display-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/display-solid.png -------------------------------------------------------------------------------- /assets/icons/download-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/download-solid.png -------------------------------------------------------------------------------- /assets/icons/file-image-regular.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/file-image-regular.png -------------------------------------------------------------------------------- /assets/icons/file-import-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/file-import-solid.png -------------------------------------------------------------------------------- /assets/icons/folder-open-regular.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/folder-open-regular.png -------------------------------------------------------------------------------- /assets/icons/gamepad-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/gamepad-solid.png -------------------------------------------------------------------------------- /assets/icons/image-regular.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/image-regular.png -------------------------------------------------------------------------------- /assets/icons/magnifying-glass-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/magnifying-glass-solid.png -------------------------------------------------------------------------------- /assets/icons/object-group-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/object-group-solid.png -------------------------------------------------------------------------------- /assets/icons/rotate-right-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/rotate-right-solid.png -------------------------------------------------------------------------------- /assets/icons/sd-card-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/sd-card-solid.png -------------------------------------------------------------------------------- /assets/icons/square-check-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/square-check-solid.png -------------------------------------------------------------------------------- /assets/icons/square-regular.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/square-regular.png -------------------------------------------------------------------------------- /assets/icons/triangle-exclamation-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/triangle-exclamation-solid.png -------------------------------------------------------------------------------- /assets/icons/wrench-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/icons/wrench-solid.png -------------------------------------------------------------------------------- /assets/inputs/switch_button_a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/inputs/switch_button_a.png -------------------------------------------------------------------------------- /assets/inputs/switch_button_b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/inputs/switch_button_b.png -------------------------------------------------------------------------------- /assets/inputs/switch_button_sl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/inputs/switch_button_sl.png -------------------------------------------------------------------------------- /assets/inputs/switch_button_sr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/inputs/switch_button_sr.png -------------------------------------------------------------------------------- /assets/inputs/switch_button_x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/inputs/switch_button_x.png -------------------------------------------------------------------------------- /assets/inputs/switch_button_y.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/inputs/switch_button_y.png -------------------------------------------------------------------------------- /assets/inputs/switch_dpad_vertical_outline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/inputs/switch_dpad_vertical_outline.png -------------------------------------------------------------------------------- /assets/loading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/loading.png -------------------------------------------------------------------------------- /assets/muos-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/muos-logo.png -------------------------------------------------------------------------------- /assets/scrappy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/scrappy.png -------------------------------------------------------------------------------- /assets/scrappy_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/assets/scrappy_logo.png -------------------------------------------------------------------------------- /bin/Skyscraper.aarch64: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/Skyscraper.aarch64 -------------------------------------------------------------------------------- /bin/libs.aarch64/libQt5Core.so.5: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/libs.aarch64/libQt5Core.so.5 -------------------------------------------------------------------------------- /bin/libs.aarch64/libQt5Gui.so.5: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/libs.aarch64/libQt5Gui.so.5 -------------------------------------------------------------------------------- /bin/libs.aarch64/libQt5Network.so.5: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/libs.aarch64/libQt5Network.so.5 -------------------------------------------------------------------------------- /bin/libs.aarch64/libQt5Sql.so.5: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/libs.aarch64/libQt5Sql.so.5 -------------------------------------------------------------------------------- /bin/libs.aarch64/libQt5Xml.so.5: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/libs.aarch64/libQt5Xml.so.5 -------------------------------------------------------------------------------- /bin/libs.aarch64/libcrypto.so.1.1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/libs.aarch64/libcrypto.so.1.1 -------------------------------------------------------------------------------- /bin/libs.aarch64/libicudata.so.66: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/libs.aarch64/libicudata.so.66 -------------------------------------------------------------------------------- /bin/libs.aarch64/libicui18n.so.66: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/libs.aarch64/libicui18n.so.66 -------------------------------------------------------------------------------- /bin/libs.aarch64/libicuuc.so.66: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/libs.aarch64/libicuuc.so.66 -------------------------------------------------------------------------------- /bin/libs.aarch64/liblove-11.5.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/libs.aarch64/liblove-11.5.so -------------------------------------------------------------------------------- /bin/libs.aarch64/libluajit-5.1.so.2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/libs.aarch64/libluajit-5.1.so.2 -------------------------------------------------------------------------------- /bin/libs.aarch64/libpcre.so.3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/libs.aarch64/libpcre.so.3 -------------------------------------------------------------------------------- /bin/libs.aarch64/libpcre2-16.so.0: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/libs.aarch64/libpcre2-16.so.0 -------------------------------------------------------------------------------- /bin/libs.aarch64/libpng16.so.16: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/libs.aarch64/libpng16.so.16 -------------------------------------------------------------------------------- /bin/libs.aarch64/libssl.so.1.1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/libs.aarch64/libssl.so.1.1 -------------------------------------------------------------------------------- /bin/love: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/love -------------------------------------------------------------------------------- /bin/plugins/imageformats/libqjpeg.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/bin/plugins/imageformats/libqjpeg.so -------------------------------------------------------------------------------- /conf.lua: -------------------------------------------------------------------------------- 1 | function love.conf(t) 2 | t.version = "11.4" 3 | t.console = false 4 | 5 | t.window.title = "Scrappy" 6 | t.window.icon = nil 7 | t.window.width = 640 8 | t.window.height = 480 9 | t.window.borderless = false 10 | t.window.resizable = false 11 | t.window.vsync = 1 12 | t.window.display = 1 13 | t.window.highdpi = false 14 | t.window.x = nil 15 | t.window.y = nil 16 | 17 | t.modules.thread = true 18 | t.modules.audio = false 19 | t.modules.mouse = false 20 | t.modules.physics = false 21 | t.modules.sound = false 22 | t.modules.touch = false 23 | t.modules.video = false 24 | end 25 | -------------------------------------------------------------------------------- /config.ini.example: -------------------------------------------------------------------------------- 1 | [main] 2 | filterTemplates=1 3 | parseCache=1 4 | sd=1 5 | [overrides] 6 | [platforms] 7 | [platformsSelected] 8 | -------------------------------------------------------------------------------- /data/cache/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/data/cache/.gitkeep -------------------------------------------------------------------------------- /data/output/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/data/output/.gitkeep -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the Ubuntu 20.04 base image for aarch64 2 | FROM arm64v8/ubuntu:20.04 3 | 4 | # Set environment variables to avoid interactive prompts during installation 5 | ENV DEBIAN_FRONTEND=noninteractive 6 | 7 | # Define the version as a build argument with a default value 8 | ARG VERSION=3.12.0 9 | 10 | # Update the package list and install essential packages including make, g++, gcc, and git 11 | RUN apt-get update && \ 12 | apt-get install -y \ 13 | build-essential \ 14 | pkg-config \ 15 | zlib1g-dev \ 16 | libpng-dev \ 17 | libsdl2-dev \ 18 | make \ 19 | g++ \ 20 | gcc \ 21 | git \ 22 | curl \ 23 | wget \ 24 | vim \ 25 | sudo \ 26 | p7zip-full \ 27 | software-properties-common \ 28 | libgles2-mesa-dev \ 29 | libgl1-mesa-dev && \ 30 | apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 31 | 32 | # Copy the Qt-5.15.15 folder to the desired Qt install location 33 | COPY Qt-5.15.15 /opt/Qt-5.15.15 34 | 35 | # Ensure all files in Qt-5.15.15 have the appropriate permissions 36 | RUN chmod -R +x /opt/Qt-5.15.15/bin 37 | 38 | # Set up environment variables for Qt 39 | ENV QT_HOME=/opt/Qt-5.15.15 40 | ENV PATH="$QT_HOME/bin:$PATH" 41 | 42 | # Copy the libs folder to the desired location 43 | COPY libs /usr/local/libs 44 | 45 | # Update the library cache to include /usr/local/libs 46 | # RUN echo "/usr/local/libs" > /etc/ld.so.conf.d/custom-libs.conf && \ 47 | # ldconfig 48 | 49 | # Create the skysource directory 50 | RUN mkdir /skysource 51 | 52 | # Download the script to the skysource directory 53 | RUN wget -q -O /skysource/update_skyscraper.sh https://raw.githubusercontent.com/Gemba/skyscraper/master/update_skyscraper.sh && \ 54 | chmod +x /skysource/update_skyscraper.sh 55 | 56 | # Replace the LATEST assignment in the update script with the specified version 57 | RUN sed -i "s|LATEST=.*|LATEST=\"${VERSION}\"|" /skysource/update_skyscraper.sh 58 | 59 | # Set the entrypoint to run the script on container startup 60 | # ENTRYPOINT ["/skysource/update_skyscraper.sh"] 61 | 62 | COPY package_skyscraper.sh /skysource/package_skyscraper.sh 63 | RUN chmod +x /skysource/package_skyscraper.sh 64 | ENTRYPOINT ["/skysource/package_skyscraper.sh"] 65 | -------------------------------------------------------------------------------- /docker/package_skyscraper.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Run the update script to build Skyscraper 4 | /skysource/update_skyscraper.sh 5 | 6 | # Check if the Skyscraper binary was built successfully 7 | if ! command -v Skyscraper &> /dev/null; then 8 | echo "Skyscraper binary not found. Build may have failed." 9 | exit 1 10 | fi 11 | 12 | Skyscraper -v 13 | 14 | # Create a directory to store the binary and libraries 15 | mkdir -p /skysource/output 16 | mkdir -p /output 17 | 18 | # Copy the Skyscraper binary 19 | cp $(which Skyscraper) /skysource/output/ 20 | 21 | # Copy shared libraries needed by Skyscraper 22 | ldd $(which Skyscraper) | grep "=>" | awk '{print $3}' | xargs -I '{}' cp '{}' /skysource/output/ 23 | cp /usr/lib/aarch64-linux-gnu/libssl.so.1.1 /skysource/output/ 24 | cp /usr/lib/aarch64-linux-gnu/libcrypto.so.1 /skysource/output/ 25 | 26 | # Package the binary and libraries into a .zip file 27 | cd /skysource/output 28 | 7z a /output/skyscraper_package.zip . 29 | 30 | echo "Packaging complete. Zip file located at /output/skyscraper_package.zip" 31 | -------------------------------------------------------------------------------- /globals.lua: -------------------------------------------------------------------------------- 1 | _G.nativefs = require("lib.nativefs") 2 | _G.timer = require("lib.timer") 3 | _G.WORK_DIR = nativefs.getWorkingDirectory() 4 | 5 | local sem_ver = { 6 | major = 2, 7 | minor = 2, 8 | patch = 1, 9 | extra = "" 10 | } 11 | 12 | _G.version = (function() 13 | local version = string.format("v%d.%d.%d", sem_ver.major, sem_ver.minor, sem_ver.patch) 14 | if sem_ver.extra ~= "" then 15 | version = version .. "-" .. sem_ver.extra 16 | end 17 | return version 18 | end)() 19 | 20 | _G.resolution = "640x480" 21 | 22 | _G.device_resolutions = { 23 | "640x480", 24 | "720x480", 25 | "720x720", 26 | "1024x768", 27 | "1280x720", 28 | } 29 | 30 | _G.SKYSCRAPER_ERRORS = { 31 | "doesn't exist or can't be accessed by current user. Please check path and permissions.", 32 | "ScreenScraper APIv2 returned invalid / empty Json.", 33 | "No such file or directory", 34 | "cannot execute binary file: Exec format error", 35 | "Couldn't read artwork xml file", 36 | "requested either on command line or with", 37 | "Couldn't create cache folders, please check folder permissions and try again...", 38 | "Please set a valid platform with", 39 | "No files to process in cache", 40 | "Skyscraper came to an untimely end." 41 | } 42 | -------------------------------------------------------------------------------- /helpers/artwork.lua: -------------------------------------------------------------------------------- 1 | local log = require("lib.log") 2 | local metadata = require("lib.metadata") 3 | local config = require("helpers.config") 4 | local utils = require("helpers.utils") 5 | local muos = require("helpers.muos") 6 | local pprint = require("lib.pprint") 7 | 8 | local artwork = { 9 | cached_game_ids = {}, 10 | } 11 | 12 | 13 | local output_types = { 14 | BOX = "box", 15 | PREVIEW = "preview", 16 | SPLASH = "splash", 17 | } 18 | 19 | artwork.output_map = { 20 | [output_types.BOX] = "covers", 21 | [output_types.PREVIEW] = "screenshots", 22 | [output_types.SPLASH] = "wheels", 23 | } 24 | 25 | local user_config, skyscraper_config = config.user_config, config.skyscraper_config 26 | 27 | function artwork.get_artwork_path() 28 | local artwork_xml = skyscraper_config:read("main", "artworkXml") 29 | if not artwork_xml or artwork_xml == "\"\"" then return nil end 30 | artwork_xml = artwork_xml:gsub('"', '') 31 | return artwork_xml 32 | end 33 | 34 | function artwork.get_artwork_name() 35 | local artwork_path = artwork.get_artwork_path() 36 | if not artwork_path then return nil end 37 | local artwork_name = artwork_path:match("([^/]+)%.xml$") 38 | return artwork_name 39 | end 40 | 41 | function artwork.get_template_resolution(xml_path) 42 | local xml_content = nativefs.read(xml_path) 43 | if not xml_content then 44 | return nil 45 | end 46 | 47 | local width, height = xml_content:match(']*width="(%d+)"[^>]*height="(%d+)"') 48 | 49 | if width and height then 50 | return width .. "x" .. height 51 | end 52 | return nil 53 | end 54 | 55 | function artwork.get_output_types(xml_path) 56 | local xml_content = nativefs.read(xml_path) 57 | local result = { 58 | box = false, 59 | preview = false, 60 | splash = false 61 | } 62 | 63 | if not xml_content then return result end 64 | 65 | if xml_content:find(']*type="cover"') then 66 | result.box = true 67 | end 68 | if xml_content:find(']*type="screenshot"') then 69 | result.preview = true 70 | end 71 | if xml_content:find(']*type="wheel"') then 72 | result.splash = true 73 | end 74 | 75 | return result 76 | end 77 | 78 | function artwork.copy_artwork_type(platform, game, media_path, copy_path, output_type) 79 | --[[ 80 | platform -> nes | gb | gba | ... 81 | game -> "Super Mario World" 82 | media_path -> "data/output/{platform}/media" 83 | copy_path -> "/mnt/mmc/MUOS/info/catalogue/Platform Title/{type}" 84 | output_type -> box | preview | splash 85 | --]] 86 | 87 | -- Find scraped artwork in output folder 88 | local scraped_art_path = string.format("%s/%s/%s.png", media_path, artwork.output_map[output_type], game) 89 | local scraped_art = nativefs.newFileData(scraped_art_path) 90 | if not scraped_art then 91 | log.write(string.format("Scraped artwork not found for output '%s'", artwork.output_map[output_type])) 92 | return 93 | end 94 | 95 | -- Copy to catalogue 96 | local _, err = nativefs.write(string.format("%s/%s/%s.png", copy_path, output_type, game), scraped_art) 97 | if err then 98 | log.write(err) 99 | end 100 | end 101 | 102 | function artwork.copy_to_catalogue(platform, game) 103 | log.write(string.format("Copying artwork for %s: %s", platform, game)) 104 | local _, output_path = skyscraper_config:get_paths() 105 | local _, catalogue_path = user_config:get_paths() 106 | if output_path == nil or catalogue_path == nil then 107 | log.write("Missing paths from config") 108 | return 109 | end 110 | output_path = utils.strip_quotes(output_path) 111 | local platform_str = muos.platforms[platform] 112 | if not platform_str then 113 | log.write("Catalogue destination folder not found") 114 | return 115 | end 116 | 117 | local media_path = string.format("%s/%s/media", output_path, platform) 118 | local copy_path = string.format("%s/%s", catalogue_path, platform_str) 119 | 120 | -- Copy box/cover artwork 121 | artwork.copy_artwork_type(platform, game, media_path, copy_path, output_types.BOX) 122 | -- Copy preview artwork 123 | artwork.copy_artwork_type(platform, game, media_path, copy_path, output_types.PREVIEW) 124 | -- Copy splash artwork 125 | artwork.copy_artwork_type(platform, game, media_path, copy_path, output_types.SPLASH) 126 | 127 | ----------------------------- 128 | -- Read Pegasus-formatted metadata 129 | ----------------------------- 130 | local file = nativefs.read(string.format("%s/%s/metadata.pegasus.txt", output_path, platform)) 131 | if file then 132 | local games = metadata.parse(file) 133 | if games then 134 | for _, entry in ipairs(games) do 135 | if entry.filename == game then 136 | print(string.format("Writing desc for %s", game)) 137 | local _, err = nativefs.write(string.format("%s/text/%s.txt", copy_path, game), 138 | string.format("%s\nGenre: %s", entry.description, entry.genre)) 139 | if err then log.write(err) end 140 | break 141 | end 142 | end 143 | end 144 | else 145 | log.write("Failed to load metadata.pegasus.txt for " .. platform) 146 | end 147 | end 148 | 149 | function artwork.process_cached_by_platform(platform, cache_folder) 150 | local quick_id_entries = {} 151 | local cached_games = {} 152 | 153 | if not cache_folder then 154 | cache_folder = skyscraper_config:read("main", "cacheFolder") 155 | if not cache_folder or cache_folder == "\"\"" then 156 | return 157 | end 158 | cache_folder = utils.strip_quotes(cache_folder) 159 | end 160 | 161 | -- Read quickid and db files 162 | local quickid = nativefs.read(string.format("%s/%s/quickid.xml", cache_folder, platform)) 163 | local db = nativefs.read(string.format("%s/%s/db.xml", cache_folder, platform)) 164 | 165 | if not quickid or not db then 166 | log.write("Missing quickid.xml or db.xml for " .. platform) 167 | return 168 | end 169 | 170 | -- Parse quickid for ROM identifiers 171 | local lines = utils.split(quickid, "\n") 172 | for _, line in ipairs(lines) do 173 | if line:find(" 0 then 117 | self:insert("main", "sd", 2) 118 | log.write("Found SD2") 119 | return 120 | end 121 | 122 | log.write("No SD2 found. Defaulting to SD1") 123 | self:insert("main", "sd", 1) 124 | end 125 | 126 | function user_config:get_paths() 127 | --[[ 128 | Get paths from config 129 | Args: 130 | None 131 | Returns: 132 | (user) 133 | rom_path: string 134 | catalogue_path: string 135 | --]] 136 | -- Check for overrides 137 | local rom_path_override = self:read("overrides", "romPath") 138 | local catalogue_path_override = self:read("overrides", "cataloguePath") 139 | if rom_path_override and catalogue_path_override then 140 | return rom_path_override, catalogue_path_override 141 | end 142 | 143 | -- Get paths 144 | local sd = self:read("main", "sd") 145 | local rom_path = sd == "1" and muos.SD1_PATH or muos.SD2_PATH 146 | for _, item in ipairs(nativefs.getDirectoryItems(rom_path) or {}) do 147 | if item:lower() == "roms" then 148 | rom_path = string.format("%s/%s", rom_path, item) 149 | break 150 | end 151 | end 152 | 153 | local catalogue_path = muos.CATALOGUE 154 | 155 | return rom_path_override or rom_path, catalogue_path_override or catalogue_path 156 | end 157 | 158 | function user_config:load_platforms() 159 | local rom_path, _ = self:get_paths() 160 | 161 | log.write(string.format("Loading platforms from %s", rom_path)) 162 | 163 | -- Function to parse core.cfg files 164 | local function parse_dir(cfg_file) 165 | local lines = {} 166 | for line in cfg_file:gmatch("[^\r\n]+") do 167 | table.insert(lines, line) 168 | end 169 | if #lines < 3 then 170 | return nil, "Error parsing cfg file" 171 | end 172 | return lines[2], nil 173 | end 174 | 175 | -- Recursive function to scan directories 176 | local function scan_directories(base_path, relative_path) 177 | local platforms = {} 178 | local contains_files = false 179 | local items = nativefs.getDirectoryItems(base_path) 180 | 181 | for _, item in ipairs(items) do 182 | local item_path = base_path .. "/" .. item 183 | local file_info = nativefs.getInfo(item_path) 184 | 185 | -- Ignore hidden folders and files 186 | if file_info and file_info.type == "directory" and item:sub(1, 1) ~= "." then 187 | -- Construct the relative path for the current directory 188 | local current_relative_path = relative_path and (relative_path .. "/" .. item) or item 189 | 190 | -- Recursively collect platforms from subdirectories 191 | local sub_platforms = scan_directories(item_path, current_relative_path) 192 | for _, sub_platform in ipairs(sub_platforms) do 193 | table.insert(platforms, sub_platform) 194 | end 195 | elseif file_info and file_info.type == "file" then 196 | contains_files = true 197 | end 198 | end 199 | 200 | -- Add current folder to platforms if it contains files 201 | if contains_files and relative_path then 202 | table.insert(platforms, relative_path) 203 | end 204 | 205 | return platforms, contains_files 206 | end 207 | 208 | -- Scan the main ROM path for platforms 209 | local platforms = scan_directories(rom_path, nil) 210 | if not platforms or next(platforms) == nil then 211 | log.write("No platforms found") 212 | return 213 | end 214 | 215 | ini.deleteSection(self.values, "platforms") 216 | ini.deleteSection(self.values, "platformsSelected") 217 | 218 | for _, item in ipairs(platforms) do 219 | -- Find muos core info 220 | local core_path = muos.CORE_DIR .. "/" .. item:lower() .. "/core.cfg" 221 | local muos_core_info = nativefs.getInfo(core_path) 222 | 223 | if muos_core_info then 224 | local file = nativefs.read(core_path) 225 | if file then 226 | local folder_name, err = parse_dir(file) 227 | if err then 228 | log.write(err) 229 | return 230 | end 231 | local assignment = muos.assignment[folder_name] 232 | if assignment then 233 | self:insert("platforms", item, assignment) 234 | self:insert("platformsSelected", item, 1) 235 | else 236 | log.write(string.format("Unable to find platform for %s", item)) 237 | self:insert("platforms", item, "unmapped") 238 | self:insert("platformsSelected", item, 0) 239 | end 240 | end 241 | else 242 | log.write(string.format("Unable to find platform for %s", item)) 243 | end 244 | end 245 | end 246 | 247 | function user_config:fill_selected_platforms() 248 | for platform in utils.orderedPairs(self:get().platforms or {}) do 249 | if not self:read("platformsSelected", platform) then 250 | self:insert("platformsSelected", platform, 0) 251 | end 252 | end 253 | end 254 | 255 | function user_config:has_platforms() 256 | local platforms = self:get().platforms 257 | 258 | if not platforms then return false end 259 | 260 | local count = 0 261 | for _ in pairs(platforms) do 262 | count = count + 1 263 | end 264 | 265 | return count > 0 266 | end 267 | 268 | -- Skyscraper-specific config 269 | local skyscraper_config = {} 270 | skyscraper_config.__index = skyscraper_config 271 | setmetatable(skyscraper_config, { __index = config }) 272 | 273 | function skyscraper_config.create(config_path) 274 | local self = config.new("skyscraper", config_path or "skyscraper_config.ini") 275 | setmetatable(self, skyscraper_config) 276 | self:init() 277 | return self 278 | end 279 | 280 | function skyscraper_config:start_fresh() 281 | if self:create_from("skyscraper_config.ini.example") then 282 | log.write("Created skyscraper config") 283 | self:reset() 284 | else 285 | log.write("Failed to create skyscraper config") 286 | end 287 | end 288 | 289 | function skyscraper_config:init() 290 | if self:load() then 291 | log.write("Loaded skyscraper config") 292 | local artwork_xml = self:read("main", "artworkXml") 293 | if not artwork_xml or artwork_xml == "\"\"" then 294 | self:insert("main", "artworkXml", string.format("\"%s/%s\"", WORK_DIR, "templates/box2d.xml")) 295 | end 296 | local cache_path = self:read("main", "cacheFolder") 297 | if not cache_path or cache_path == "\"\"" then 298 | self:insert("main", "cacheFolder", string.format("\"%s/%s\"", WORK_DIR, "data/cache")) 299 | end 300 | local output_path = self:read("main", "gameListFolder") 301 | if not output_path or output_path == "\"\"" then 302 | self:insert("main", "cacheFolder", string.format("\"%s/%s\"", WORK_DIR, "data/output")) 303 | end 304 | local region_prios = self:read("main", "regionPrios") 305 | if not region_prios or region_prios == "\"\"" then 306 | self:insert("main", "regionPrios", 307 | "\"us,wor,eu,jp,ss,uk,au,ame,de,cus,cn,kr,asi,br,sp,fr,gr,it,no,dk,nz,nl,pl,ru,se,tw,ca\"") 308 | end 309 | local subdirs = self:read("main", "subdirs") 310 | if not subdirs or subdirs == "\"\"" then 311 | self:insert("main", "subdirs", "\"false\"") 312 | end 313 | else 314 | self:start_fresh() 315 | end 316 | end 317 | 318 | function skyscraper_config:reset() 319 | self:insert("main", "cacheFolder", string.format("\"%s/%s\"", WORK_DIR, "data/cache")) 320 | self:insert("main", "gameListFolder", string.format("\"%s/%s\"", WORK_DIR, "data/output")) 321 | self:insert("main", "artworkXml", string.format("\"%s/%s\"", WORK_DIR, "templates/box2d.xml")) 322 | self:save() 323 | end 324 | 325 | function skyscraper_config:has_credentials() 326 | local creds = self:read("screenscraper", "userCreds") 327 | return creds and creds:find("USER:PASS") == nil 328 | end 329 | 330 | function skyscraper_config:get_paths() 331 | local cache_path = self:read("main", "cacheFolder") 332 | local output_path = self:read("main", "gameListFolder") 333 | return cache_path, output_path 334 | end 335 | 336 | -- Theme specific 337 | local theme = setmetatable({}, { __index = config }) 338 | theme.__index = theme 339 | 340 | function theme.create() 341 | local self = config.new("theme", "theme.ini") 342 | setmetatable(self, theme) 343 | self:init() 344 | return self 345 | end 346 | 347 | function theme:init() 348 | if self:load() then 349 | log.write("Loaded theme config") 350 | else 351 | log.write("Failed to load theme config") 352 | end 353 | end 354 | 355 | function theme:read_color(section, key, fallback) 356 | local color = self:read(section, key) 357 | if not color then return utils.hex(fallback) end 358 | return utils.hex_v(color) 359 | end 360 | 361 | function theme:read_number(section, key, fallback) 362 | local number = self:read(section, key) 363 | return number and tonumber(number) or fallback 364 | end 365 | 366 | -- Singleton instances 367 | local user_config_instance = user_config.create("config.ini") 368 | local skyscraper_config_instance = skyscraper_config.create("skyscraper_config.ini") 369 | local theme_instance = theme.create() 370 | 371 | return { 372 | user_config = user_config_instance, 373 | skyscraper_config = skyscraper_config_instance, 374 | theme = theme_instance 375 | } 376 | -------------------------------------------------------------------------------- /helpers/input.lua: -------------------------------------------------------------------------------- 1 | local input = {} 2 | 3 | local joystick 4 | local state = { 5 | last_event = nil, 6 | current_event = nil, 7 | trigger = false, 8 | } 9 | 10 | input.events = { 11 | LEFT = "left", 12 | RIGHT = "right", 13 | UP = "up", 14 | DOWN = "down", 15 | ESC = "escape", 16 | RETURN = "return", 17 | MENU = "lalt", 18 | PREV = "[", 19 | NEXT = "]", 20 | } 21 | 22 | input.joystick_mapping = { 23 | ["dpleft"] = input.events.LEFT, 24 | ["dpright"] = input.events.RIGHT, 25 | ["dpup"] = input.events.UP, 26 | ["dpdown"] = input.events.DOWN, 27 | ["a"] = input.events.RETURN, 28 | ["b"] = input.events.ESC, 29 | ["back"] = input.events.MENU, 30 | ["l1"] = input.events.PREV, 31 | ["r1"] = input.events.NEXT, 32 | } 33 | 34 | local cooldown_duration = 0.2 35 | local last_trigger_time = -cooldown_duration 36 | 37 | local function can_trigger_global(dt) 38 | local current_time = love.timer.getTime() 39 | if current_time - last_trigger_time >= cooldown_duration then 40 | last_trigger_time = current_time 41 | return true 42 | end 43 | return false 44 | end 45 | 46 | local function trigger(event) 47 | if can_trigger_global() then 48 | state.last_event = state.current_event 49 | state.current_event = event 50 | state.trigger = true 51 | -- print("Triggered: " .. event) -- Debug 52 | end 53 | end 54 | 55 | function input.load() 56 | -- Initialize joystick 57 | local joysticks = love.joystick.getJoysticks() 58 | if #joysticks > 0 then 59 | joystick = joysticks[1] 60 | end 61 | end 62 | 63 | function input.update(dt) 64 | if joystick then 65 | for button, event in ipairs(input.joystick_mapping) do 66 | if joystick:isGamepadDown(button) then 67 | trigger(event) 68 | end 69 | end 70 | end 71 | end 72 | 73 | function input.onEvent(callback) 74 | if state.trigger then 75 | state.trigger = false 76 | callback(state.current_event) 77 | end 78 | end 79 | 80 | function love.keypressed(key) 81 | for _, k in pairs(input.events) do 82 | if key == k then 83 | trigger(key) 84 | end 85 | end 86 | end 87 | 88 | return input 89 | -------------------------------------------------------------------------------- /helpers/muos.lua: -------------------------------------------------------------------------------- 1 | local muos = { 2 | SD1_PATH = "/mnt/mmc", 3 | SD2_PATH = "/mnt/sdcard", 4 | CATALOGUE = "/run/muos/storage/info/catalogue", 5 | ASSIGN_JSON = "/mnt/mmc/MUOS/info/assign.json", 6 | ASSIGN_DIR = "/mnt/mmc/MUOS/info/assign", 7 | CORE_DIR = "/run/muos/storage/info/core", 8 | platforms = { 9 | ["amstradcpc"] = "Amstrad", 10 | ["arcade"] = "Arcade", 11 | ["arduboy"] = "Arduboy", 12 | ["atari2600"] = "Atari 2600", 13 | ["atari5200"] = "Atari 5200", 14 | ["atari7800"] = "Atari 7800", 15 | ["atarijaguar"] = "Atari Jaguar", 16 | ["atarilynx"] = "Atari Lynx", 17 | ["atarist"] = "Atari ST-STE-TT-Falcon", 18 | ["wonderswan"] = "Bandai WonderSwan-Color", 19 | ["wonderswancolor"] = "Bandai WonderSwan-Color", 20 | -- "CHIP-8", 21 | -- "Cannonball", 22 | -- "Cave Story", 23 | -- "ChaiLove", 24 | ["coleco"] = "ColecoVision", 25 | ["amiga"] = "Commodore Amiga", 26 | ["c128"] = "Commodore C128", 27 | ["c64"] = "Commodore C64", 28 | -- "Commodore CBM-II", 29 | -- "Commodore PET", 30 | ["vic20"] = "Commodore VIC-20", 31 | ["pc"] = "DOS", 32 | -- "Dinothawr", 33 | -- "Doom", 34 | ["PORTS"] = "External - Ports", 35 | ["channelf"] = "Fairchild ChannelF", 36 | -- "Flashback", 37 | ["vectrex"] = "GCE-Vectrex", 38 | ["gameandwatch"] = "Handheld Electronic - Game and Watch", 39 | ["j2me"] = "Java J2ME", 40 | ["lowresnx"] = "Lowres NX", 41 | -- "Lua Engine", 42 | ["coleco_"] = "MSX-SVI-ColecoVision-SG1000", 43 | ["megaduck"] = "Mega Duck - Cougar Boy", 44 | ["intellivision"] = "Mattel - Intellivision", 45 | ["msx"] = "Microsoft - MSX", 46 | -- "Mr. Boom", 47 | ["pcengine"] = "NEC PC Engine", 48 | ["pcenginecd"] = "NEC PC Engine CD", 49 | ["pcengine_"] = "NEC PC Engine SuperGrafx", 50 | ["pcfx"] = "NEC PC-FX", 51 | ["pc88"] = "NEC PC-8000 - PC-8800 series", 52 | ["pc98"] = "NEC PC98", 53 | ["nds"] = "Nintendo DS", 54 | ["gb"] = "Nintendo Game Boy", 55 | ["gba"] = "Nintendo Game Boy Advance", 56 | ["gbc"] = "Nintendo Game Boy Color", 57 | ["n64"] = "Nintendo N64", 58 | ["nes"] = "Nintendo NES-Famicom", 59 | ["fds"] = "Nintendo FDS", 60 | ["snes"] = "Nintendo SNES-SFC", 61 | ["pokemini"] = "Nintendo Pokemon Mini", 62 | ["virtualboy"] = "Nintendo Virtual Boy", 63 | ["openbor"] = "OpenBOR", 64 | ["pico8"] = "PICO-8", 65 | ["palm"] = "Palm OS", 66 | ["cdi"] = "Philips CDi", 67 | -- "Quake", 68 | ["easyrpg"] = "RPG Maker 2000 - 2003", 69 | -- "Rick Dangerous", 70 | -- "Root", 71 | ["neogeo"] = "SNK Neo Geo", 72 | ["neogeocd"] = "SNK Neo Geo CD", 73 | ["ngp"] = "SNK Neo Geo Pocket - Color", 74 | ["ngpc"] = "SNK Neo Geo Pocket - Color", 75 | ["scummvm"] = "ScummVM", 76 | ["sega32x"] = "Sega 32X", 77 | ["naomi"] = "Sega Atomiswave Naomi", 78 | ["atomiswave"] = "Sega Atomiswave Naomi", 79 | ["dreamcast"] = "Sega Dreamcast", 80 | ["gamegear"] = "Sega Game Gear", 81 | ["mastersystem"] = "Sega Master System", 82 | ["segacd"] = "Sega Mega CD - Sega CD", 83 | ["megadrive"] = "Sega Mega Drive - Genesis", 84 | ["mastersystem_"] = "Sega SG-1000", 85 | ["saturn"] = "Sega Saturn", 86 | ["x1"] = "Sharp X1", 87 | ["x68000"] = "Sharp X68000", 88 | ["zx81"] = "Sinclair ZX 81", 89 | ["zxspectrum"] = "Sinclair ZX Spectrum", 90 | ["psx"] = "Sony PlayStation", 91 | ["psp"] = "Sony Playstation Portable", 92 | ["tic80"] = "TIC-80", 93 | -- "Texas Instruments TI-83", 94 | ["3do"] = "The 3DO Company - 3DO", 95 | ["uzebox"] = "Uzebox", 96 | -- "VeMUlator", 97 | ["vircon32"] = "Vircon32", 98 | ["wasm4"] = "WASM-4", 99 | ["supervision"] = "Watara Supervision", 100 | -- "Wolfenstein 3D", 101 | }, 102 | assignment = { 103 | ["Amstrad"] = "amstradcpc", 104 | ["Arcade"] = "arcade", 105 | ["Arduboy"] = "arduboy", 106 | ["Atari 2600"] = "atari2600", 107 | ["Atari 5200"] = "atari5200", 108 | ["Atari 7800"] = "atari7800", 109 | ["Atari Jaguar"] = "atarijaguar", 110 | ["Atari Lynx"] = "atarilynx", 111 | ["Atari ST-STE-TT-Falcon"] = "atarist", 112 | ["Bandai WonderSwan-Color"] = "wonderswancolor", 113 | ["ColecoVision"] = "coleco", 114 | ["Commodore Amiga"] = "amiga", 115 | ["Commodore C128"] = "c128", 116 | ["Commodore C64"] = "c64", 117 | ["Commodore VIC-20"] = "vic20", 118 | ["DOS"] = "pc", 119 | ["External - Ports"] = "PORTS", 120 | ["Fairchild ChannelF"] = "channelf", 121 | ["GCE-Vectrex"] = "vectrex", 122 | ["Handheld Electronic - Game and Watch"] = "gameandwatch", 123 | ["Java J2ME"] = "j2me", 124 | ["Lowres NX"] = "lowresnx", 125 | ["MSX-SVI-ColecoVision-SG1000"] = "coleco", 126 | ["Mattel - Intellivision"] = "intellivision", 127 | ["Mega Duck - Cougar Boy"] = "megaduck", 128 | ["Microsoft - MSX"] = "msx", 129 | ["NEC PC Engine"] = "pcengine", 130 | ["NEC PC Engine CD"] = "pcenginecd", 131 | ["NEC PC Engine SuperGrafx"] = "pcengine", 132 | ["NEC PC-FX"] = "pcfx", 133 | ["NEC PC98"] = "pc98", 134 | ["Nintendo DS"] = "nds", 135 | ["Nintendo Game Boy"] = "gb", 136 | ["Nintendo Game Boy Advance"] = "gba", 137 | ["Nintendo Game Boy Color"] = "gbc", 138 | ["Nintendo N64"] = "n64", 139 | ["Nintendo NES-Famicom"] = "nes", 140 | ["Nintendo Pokemon Mini"] = "pokemini", 141 | ["Nintendo SNES-SFC"] = "snes", 142 | ["Nintendo Virtual Boy"] = "virtualboy", 143 | ["OpenBOR"] = "openbor", 144 | ["PICO-8"] = "pico8", 145 | ["Palm OS"] = "palm", 146 | ["Philips CDi"] = "cdi", 147 | ["Sinclair ZX Spectrum"] = "zxspectrum", 148 | ["SNK Neo Geo"] = "neogeo", 149 | ["SNK Neo Geo CD"] = "neogeocd", 150 | ["SNK Neo Geo Pocket - Color"] = "ngpc", 151 | ["ScummVM"] = "scummvm", 152 | ["Sega 32X"] = "sega32x", 153 | ["Sega Atomiswave Naomi"] = "atomiswave", 154 | ["Sega Dreamcast"] = "dreamcast", 155 | ["Sega Game Gear"] = "gamegear", 156 | ["Sega Master System"] = "mastersystem", 157 | ["Sega Mega CD - Sega CD"] = "segacd", 158 | ["Sega Mega Drive - Genesis"] = "megadrive", 159 | ["Sega SG-1000"] = "mastersystem", 160 | ["Sega Saturn"] = "saturn", 161 | ["Sharp X1"] = "x1", 162 | ["Sharp X68000"] = "x68000", 163 | ["Sinclair ZX 81"] = "zx81", 164 | ["Sinclair ZX Spectrum"] = "zxspectrum", 165 | ["Sony PlayStation"] = "psx", 166 | ["Sony Playstation Portable"] = "psp", 167 | ["TIC-80"] = "tic80", 168 | ["The 3DO Company - 3DO"] = "3do", 169 | ["Uzebox"] = "uzebox", 170 | ["Vircon32"] = "vircon32", 171 | ["WASM-4"] = "wasm4", 172 | ["Watara Supervision"] = "supervision", 173 | }, 174 | } 175 | 176 | return muos 177 | -------------------------------------------------------------------------------- /helpers/utils.lua: -------------------------------------------------------------------------------- 1 | local utils = {} 2 | 3 | function utils.split(inputstr, sep) 4 | if sep == nil then 5 | sep = "%s" 6 | end 7 | local t = {} 8 | for str in string.gmatch(inputstr, "([^" .. sep .. "]+)") do 9 | table.insert(t, str) 10 | end 11 | return t 12 | end 13 | 14 | function utils.strip_ansi_colors(str) 15 | return str:gsub("\27%[%d*;*%d*m", "") 16 | end 17 | 18 | function utils.strip_quotes(str) 19 | return str:gsub('"', '') 20 | end 21 | 22 | function utils.append_quotes(str) 23 | return '"' .. str .. '"' 24 | end 25 | 26 | function utils.get_extension(str) 27 | return str:match('.+%.(%w+)$') 28 | end 29 | 30 | function utils.get_filename(str) 31 | if not str then return nil end 32 | return str:gsub("%.%w+$", "") 33 | end 34 | 35 | function utils.match_extension(str, ext) 36 | return str:match('.+' .. ext .. '$') 37 | end 38 | 39 | function utils.get_filename_from_path(str) 40 | if not str then return nil end 41 | return str:match("([^/]+)%.%w+$") 42 | end 43 | 44 | function utils.escape_html(input) 45 | local entities = { 46 | ["&"] = "&", 47 | ["<"] = "<", 48 | [">"] = ">", 49 | ["\""] = """, 50 | ["'"] = "'" 51 | } 52 | 53 | local escapedString = input:gsub("[&<>'\"]", function(c) 54 | return entities[c] or c 55 | end) 56 | 57 | return escapedString 58 | end 59 | 60 | function utils.unescape_html(input) 61 | local entities = { 62 | ["&"] = "&", 63 | ["<"] = "<", 64 | [">"] = ">", 65 | ["""] = "\"", 66 | ["'"] = "'" 67 | } 68 | 69 | local unescapedString = input:gsub("(&[%w#]+;)", function(entity) 70 | return entities[entity] or entity 71 | end) 72 | 73 | return unescapedString 74 | end 75 | 76 | -- https://github.com/s-walrus/hex2color/blob/master/hex2color.lua 77 | function utils.hex(hex, value) 78 | return { 79 | tonumber(string.sub(hex, 2, 3), 16) / 256, 80 | tonumber(string.sub(hex, 4, 5), 16) / 256, 81 | tonumber(string.sub(hex, 6, 7), 16) / 256, 82 | value or 1 } 83 | end 84 | 85 | -- https://github.com/s-walrus/hex2color/blob/master/hex2color.lua 86 | function utils.hex_v(hex, value) 87 | if not hex then return { 0, 0, 0, 1 } end 88 | return { 89 | tonumber(string.sub(hex, 1, 2), 16) / 256, 90 | tonumber(string.sub(hex, 3, 4), 16) / 256, 91 | tonumber(string.sub(hex, 5, 6), 16) / 256, 92 | value or 1 } 93 | end 94 | 95 | -- http://lua-users.org/wiki/SortedIteration 96 | --[[ 97 | Ordered table iterator, allow to iterate on the natural order of the keys of a 98 | table. 99 | ]] 100 | 101 | local function __genOrderedIndex(t) 102 | local orderedIndex = {} 103 | for key in pairs(t) do 104 | table.insert(orderedIndex, key) 105 | end 106 | table.sort(orderedIndex) 107 | return orderedIndex 108 | end 109 | 110 | local function orderedNext(t, state) 111 | -- Equivalent of the next function, but returns the keys in the alphabetic 112 | -- order. We use a temporary ordered key table that is stored in the 113 | -- table being iterated. 114 | 115 | local key = nil 116 | if state == nil then 117 | -- the first time, generate the index 118 | t.__orderedIndex = __genOrderedIndex(t) 119 | key = t.__orderedIndex[1] 120 | else 121 | -- fetch the next value 122 | for i = 1, #t.__orderedIndex do 123 | if t.__orderedIndex[i] == state then 124 | key = t.__orderedIndex[i + 1] 125 | end 126 | end 127 | end 128 | 129 | if key then 130 | return key, t[key] 131 | end 132 | 133 | -- no more value to return, cleanup 134 | t.__orderedIndex = nil 135 | return 136 | end 137 | 138 | function utils.orderedPairs(t) 139 | -- Equivalent of the pairs() function on tables. Allows to iterate 140 | -- in order 141 | return orderedNext, t, nil 142 | end 143 | 144 | function utils.tableMerge(...) 145 | local result = {} 146 | for _, t in ipairs({ ... }) do 147 | for k, v in pairs(t) do 148 | result[k] = v 149 | end 150 | end 151 | return result 152 | end 153 | 154 | function utils.load_image(path) 155 | local file_data = nativefs.newFileData(path) 156 | if not file_data then return nil end 157 | 158 | -- Use pcall to handle any errors that might occur when loading image data 159 | local success, image_data = pcall(function() 160 | return love.image.newImageData(file_data) 161 | end) 162 | 163 | if not success then return nil end 164 | 165 | return love.graphics.newImage(image_data) 166 | end 167 | 168 | return utils 169 | -------------------------------------------------------------------------------- /lib/backend/channels.lua: -------------------------------------------------------------------------------- 1 | local channels = { 2 | LOG_INPUT = love.thread.getChannel("log"), 3 | SKYSCRAPER_INPUT = love.thread.getChannel("skyscraper_input"), 4 | SKYSCRAPER_OUTPUT = love.thread.getChannel("skyscraper_output"), 5 | SKYSCRAPER_GEN_INPUT = love.thread.getChannel("skyscraper_generate_input"), 6 | SKYSCRAPER_GAME_QUEUE = love.thread.getChannel("skyscraper_midleware"), 7 | SKYSCRAPER_GEN_OUTPUT = love.thread.getChannel("skyscraper_generate_output"), 8 | TASK_OUTPUT = love.thread.getChannel("task_output"), 9 | } 10 | 11 | return channels 12 | -------------------------------------------------------------------------------- /lib/backend/log_backend.lua: -------------------------------------------------------------------------------- 1 | local start_time = ... 2 | 3 | local nativefs = require("lib.nativefs") 4 | local log_channel = require("lib.backend.channels").LOG_INPUT 5 | 6 | local function log_filename() 7 | return string.format("logs/scrappy-%s.log", os.date("%Y-%m-%d-%H-%M", start_time)) 8 | end 9 | 10 | while true do 11 | local input = log_channel:demand() 12 | 13 | if input == "close" then 14 | break 15 | elseif input == "start" then 16 | nativefs.write(log_filename(), "") 17 | else 18 | nativefs.append(log_filename(), input .. "\n") 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/backend/skyscraper_backend.lua: -------------------------------------------------------------------------------- 1 | require("globals") 2 | local parser = require("lib.parser") 3 | local log = require("lib.log") 4 | local channels = require("lib.backend.channels") 5 | local pprint = require("lib.pprint") 6 | local utils = require("helpers.utils") 7 | 8 | local function log_version(output) 9 | if not output then 10 | log.write("Failed to run Skyscraper") 11 | return 12 | end 13 | 14 | for _, line in ipairs(output) do 15 | -- Attempt to parse errors 16 | local _, err = parser.parse(line) 17 | if err then 18 | log.write("Failed to start Skyscraper: " .. err, "skyscraper") 19 | break 20 | end 21 | 22 | -- Check for version pattern in the line 23 | local version = line:match("(%d+%.%d+%.%d+)") 24 | if version then 25 | log.write(string.format("Skyscraper version: %s\n", version)) 26 | break 27 | end 28 | end 29 | end 30 | 31 | local function emit_ready(game, platform, input_folder, skipped) 32 | channels.SKYSCRAPER_GAME_QUEUE:push({ game = game, platform = platform, input_folder = input_folder, skipped = skipped }) 33 | end 34 | 35 | while true do 36 | ::continue:: 37 | -- Demand a table with command, platform, type, and game from SKYSCRAPER_INPUT 38 | local input_data = channels.SKYSCRAPER_INPUT:demand() 39 | 40 | -- Extract the command, platform, type, and game 41 | local command = input_data.command 42 | local current_platform = input_data.platform 43 | local input_folder = input_data.input_folder 44 | local op = input_data.op 45 | 46 | log.write("Starting Skyscraper, please wait...") 47 | 48 | if current_platform then 49 | channels.SKYSCRAPER_OUTPUT:push({ 50 | log = "[fetch] Starting Skyscraper for \"" .. 51 | current_platform .. "\", please wait..." 52 | }) 53 | end 54 | 55 | local stderr_to_stdout = " 2>&1" 56 | local output = io.popen(command .. stderr_to_stdout) 57 | 58 | log.write(string.format("Running command: %s", command)) 59 | log.write(string.format("Platform: %s | Game: %s\n", current_platform or "none", "none")) 60 | 61 | if not output then 62 | log.write("Failed to run Skyscraper") 63 | channels.SKYSCRAPER_OUTPUT:push({ data = {}, error = "Failed to run Skyscraper", loading = false }) 64 | goto continue 65 | end 66 | 67 | -- if game and current_platform then 68 | -- channels.SKYSCRAPER_OUTPUT:push({ data = { title = game, platform = current_platform }, error = nil }) 69 | -- end 70 | 71 | if input_data.version then -- Special command. Log version only 72 | local result = output:read("*a") 73 | output:close() 74 | local lines = utils.split(result, "\n") 75 | log_version(lines) 76 | goto continue 77 | end 78 | 79 | local parsed = false 80 | for line in output:lines() do 81 | -- if game ~= "fake-rom" then log.write(line, "skyscraper") end 82 | line = utils.strip_ansi_colors(line) 83 | -- RUNNING TASK; PUSH OUTPUT 84 | if op == "update" or op == "import" then 85 | channels.TASK_OUTPUT:push({ output = line, error = nil }) 86 | end 87 | local res, error, skipped, rtype = parser.parse(line) 88 | if res ~= nil or error then parsed = true end 89 | if res ~= nil then 90 | log.write(string.format("[fetch] %s", line), "skyscraper") 91 | channels.SKYSCRAPER_OUTPUT:push({ log = string.format("[fetch] %s", line) }) 92 | if rtype == "game" then 93 | emit_ready(res, current_platform, input_folder, skipped) 94 | end 95 | end 96 | 97 | if error ~= nil and error ~= "" then 98 | log.write("ERROR: " .. error, "skyscraper") 99 | channels.SKYSCRAPER_OUTPUT:push({ data = {}, error = error, loading = false }) 100 | break 101 | end 102 | end 103 | end 104 | 105 | function love.threaderror(thread, errorstr) 106 | print(errorstr) 107 | log.write(errorstr) 108 | end 109 | -------------------------------------------------------------------------------- /lib/backend/skyscraper_generate_backend.lua: -------------------------------------------------------------------------------- 1 | require("globals") 2 | local socket = require("socket") 3 | local parser = require("lib.parser") 4 | local log = require("lib.log") 5 | local channels = require("lib.backend.channels") 6 | local utils = require("helpers.utils") 7 | local pprint = require("lib.pprint") 8 | 9 | -- local input_data = ... 10 | -- local running = true 11 | 12 | while true do 13 | ::continue:: 14 | -- Demand a table with command, platform, type, and game from SKYSCRAPER_INPUT 15 | local input_data = channels.SKYSCRAPER_GEN_INPUT:demand() 16 | 17 | print("\nSkyscraper received input data:") 18 | pprint(input_data) 19 | print("\n") 20 | 21 | -- Extract the command, platform, type, and game 22 | local command = input_data.command 23 | local current_platform = input_data.platform 24 | local game = utils.get_filename(input_data.game) 25 | 26 | channels.SKYSCRAPER_OUTPUT:push({ log = string.format("[gen] Queued \"%s\"", game) }) 27 | 28 | local stderr_to_stdout = " 2>&1" 29 | local output = io.popen(command .. stderr_to_stdout) 30 | 31 | log.write(string.format("Running generate command: %s", command)) 32 | log.write(string.format("Platform: %s | Game: %s\n", current_platform or "none", game or "none")) 33 | 34 | print(string.format("Running generate command: %s", command)) 35 | -- print(string.format("Platform: %s | Game: %s\n", current_platform or "none", game or "none")) 36 | 37 | if not output then 38 | log.write("Failed to run Skyscraper") 39 | channels.SKYSCRAPER_OUTPUT:push({ error = "Failed to run Skyscraper" }) 40 | goto continue 41 | end 42 | 43 | -- if game and current_platform then 44 | -- channels.SKYSCRAPER_OUTPUT:push({ data = { title = game, platform = current_platform }, error = nil }) 45 | -- end 46 | 47 | local parsed = false 48 | for line in output:lines() do 49 | line = utils.strip_ansi_colors(line) 50 | if game ~= "fake-rom" then log.write(line, "skyscraper") end 51 | local res, error, skipped, rtype = parser.parse(line) 52 | if res ~= nil or error then parsed = true end 53 | if res ~= nil and rtype == "game" then 54 | pprint({ 55 | title = res, 56 | platform = current_platform, 57 | success = not skipped, 58 | error = error, 59 | }) 60 | channels.SKYSCRAPER_OUTPUT:push({ 61 | title = res, 62 | platform = current_platform, 63 | success = not skipped, 64 | error = error, 65 | }) 66 | end 67 | 68 | if error ~= nil and error ~= "" then 69 | log.write("ERROR: " .. error, "skyscraper") 70 | channels.SKYSCRAPER_OUTPUT:push({ error = error }) 71 | break 72 | end 73 | end 74 | 75 | if not parsed then 76 | log.write(string.format("Failed to parse Skyscraper output for %s", game)) 77 | channels.SKYSCRAPER_OUTPUT:push({ 78 | title = game, 79 | platform = current_platform, 80 | error = "Failed to parse Skyscraper output", 81 | success = false 82 | }) 83 | end 84 | 85 | -- channels.SKYSCRAPER_OUTPUT:push({ command_finished = true }) 86 | 87 | channels.SKYSCRAPER_OUTPUT:push({ log = string.format("[gen] Finished \"%s\"", game) }) 88 | channels.SKYSCRAPER_GEN_OUTPUT:push({ finished = true }) 89 | end 90 | 91 | function love.threaderror(thread, errorstr) 92 | print(errorstr) 93 | channels.SKYSCRAPER_OUTPUT:push({ error = errorstr }) 94 | log.write(errorstr) 95 | end 96 | -------------------------------------------------------------------------------- /lib/backend/task_backend.lua: -------------------------------------------------------------------------------- 1 | require("globals") 2 | -- local pprint = require("lib.pprint") 3 | local log = require("lib.log") 4 | local channels = require("lib.backend.channels") 5 | 6 | local task = ... 7 | local running = true 8 | 9 | local function base_task_command(id, command) 10 | local stderr_to_stdout = " 2>&1" 11 | -- local stdout_null = " > /dev/null 2>&1" 12 | -- local read_output = "; echo $?" -- 'echo $?' returns 0 if successful 13 | local handle = io.popen(command .. stderr_to_stdout) 14 | 15 | if not handle then 16 | log.write(string.format("Failed to run %s - '%s'", id, command)) 17 | channels.TASK_OUTPUT:push({ output = "Command failed", error = string.format("Failed to run %s", id) }) 18 | return 19 | end 20 | 21 | for line in handle:lines() do 22 | channels.TASK_OUTPUT:push({ output = line, error = nil }) 23 | end 24 | 25 | channels.TASK_OUTPUT:push({ command_finished = true, command = id }) 26 | log.write(string.format("Finished command %s", id, command)) 27 | end 28 | 29 | local function migrate_cache() 30 | log.write("Migrating cache to SD2") 31 | base_task_command( 32 | "migrate", 33 | "cp -r /mnt/mmc/MUOS/application/Scrappy/.scrappy/data/cache/ /mnt/sdcard/scrappy_cache/" 34 | ) 35 | end 36 | 37 | local function backup_cache() 38 | log.write("Starting Zip to compress and move cache folder") 39 | base_task_command( 40 | "backup", 41 | 'zip -r /mnt/sdcard/ARCHIVE/scrappy_cache-$(date +"%Y-%m-%d-%H-%M-%S").zip /mnt/mmc/MUOS/application/Scrappy/.scrappy/data/cache/' 42 | ) 43 | end 44 | 45 | local function update_app() 46 | log.write("Updating app") 47 | base_task_command( 48 | "update_app", 49 | "sh scripts/update.sh" 50 | ) 51 | end 52 | 53 | while running do 54 | if task == "backup" then 55 | backup_cache() 56 | end 57 | 58 | if task == "migrate" then 59 | migrate_cache() 60 | end 61 | 62 | if task == "update_app" then 63 | update_app() 64 | end 65 | 66 | running = false 67 | end 68 | -------------------------------------------------------------------------------- /lib/gui/badr.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- Badr 3 | -- 4 | -- Copyright (c) 2024 Nabeel20 5 | -- 6 | -- This library is free software; you can redistribute it and/or modify it 7 | -- under the terms of the MIT license. See LICENSE for details. 8 | -- 9 | local badr = {} 10 | badr.__index = badr 11 | 12 | function badr:new(t) 13 | t = t or {} 14 | local _default = { 15 | x = 0, 16 | y = 0, 17 | height = 0, 18 | width = 0, 19 | parent = t.parent or nil, 20 | id = tostring(love.timer.getTime()), 21 | visible = true, 22 | children = {}, 23 | focusable = false, 24 | focused = false 25 | } 26 | for key, value in pairs(t) do 27 | _default[key] = value 28 | end 29 | 30 | local instance = setmetatable(_default, badr) 31 | if instance.focusable and not (instance.root and instance.root.focusedElement) then 32 | instance.root = self:getRoot() -- Ensure the root reference is set 33 | -- if not instance.root.focusedElement then 34 | -- instance.root:setFocus(instance) 35 | -- end 36 | end 37 | return instance 38 | end 39 | 40 | function badr:createRoot(t) 41 | t = t or {} 42 | t.focusedElement = nil -- Initialize focusedElement specifically for root nodes 43 | return self:new(t) -- Use `self:new` to create the root instance 44 | end 45 | 46 | function badr.__add(self, component) 47 | if type(component) ~= "table" or component == nil then return end 48 | 49 | component.parent = self 50 | component.x = self.x + component.x 51 | component.y = self.y + component.y 52 | 53 | local childrenSize = { width = 0, height = 0 } 54 | for _, child in ipairs(self.children) do 55 | childrenSize.width = childrenSize.width + child.width 56 | childrenSize.height = childrenSize.height + child.height 57 | end 58 | 59 | local gap = self.gap or 0 60 | local lastChild = self.children[#self.children] or {} 61 | 62 | if self.column then 63 | component.y = (lastChild.height or 0) + (lastChild.y or self.y) 64 | if #self.children > 0 then 65 | component.y = component.y + gap 66 | end 67 | self.height = math.max(self.height, childrenSize.height + component.height + gap * #self.children) 68 | self.width = math.max(self.width, component.width) 69 | end 70 | if self.row then 71 | component.x = (lastChild.width or 0) + (lastChild.x or self.x) 72 | if #self.children > 0 then 73 | component.x = component.x + gap 74 | end 75 | self.width = math.max(self.width, childrenSize.width + component.width + gap * #self.children) 76 | self.height = math.max(self.height, component.height) 77 | end 78 | 79 | if #component.children > 0 then 80 | for _, child in ipairs(component.children) do 81 | child:updatePosition(component.x, component.y) 82 | end 83 | end 84 | table.insert(self.children, component) 85 | return self 86 | end 87 | 88 | -- Remove child 89 | function badr.__sub(self, component) 90 | if self % component.id then 91 | for index, child in ipairs(self.children) do 92 | if child.id == component.id then 93 | table.remove(self.children, index) 94 | end 95 | end 96 | end 97 | return self 98 | end 99 | 100 | -- Returns child with specific id 101 | function badr.__mod(self, id) 102 | assert(type(id) == "string", 'Badar; Provided id must be a string.') 103 | for _, child in ipairs(self.children) do 104 | if child.id == id then 105 | return child 106 | end 107 | end 108 | end 109 | 110 | -- Return any depth child with id 111 | function badr.__pow(self, id) 112 | assert(type(id) == "string", 'Badr: Provided id must be a string.') 113 | -- Helper function to perform recursive search 114 | local function search(children) 115 | for _, child in ipairs(children) do 116 | if child.id == id then 117 | return child 118 | end 119 | -- Recursive call to search in the child’s children 120 | local found = search(child.children or {}) 121 | if found then 122 | return found 123 | end 124 | end 125 | end 126 | 127 | -- Start the search from the current instance’s children 128 | return search(self.children) 129 | end 130 | 131 | function badr:isMouseInside() 132 | local mouseX, mouseY = love.mouse.getPosition() 133 | return mouseX >= self.x and mouseX <= self.x + self.width and 134 | mouseY >= self.y and 135 | mouseY <= self.y + self.height 136 | end 137 | 138 | function badr:draw() 139 | if not self.visible then return end; 140 | if #self.children > 0 then 141 | for _, child in ipairs(self.children) do 142 | child:draw() 143 | end 144 | end 145 | end 146 | 147 | function badr:updatePosition(x, y) 148 | self.x = self.x + x 149 | self.y = self.y + y 150 | for _, child in ipairs(self.children) do 151 | child:updatePosition(x, y) 152 | end 153 | end 154 | 155 | function badr:animate(props) 156 | props(self) 157 | for _, child in ipairs(self.children) do 158 | child:animate(props) 159 | end 160 | end 161 | 162 | function badr:update(dt) 163 | if self.onUpdate then 164 | self:onUpdate(dt) 165 | end 166 | for _, child in ipairs(self.children) do 167 | child:update(dt) 168 | end 169 | end 170 | 171 | -- Focus-related methods 172 | -- Added by gabrielfvale 173 | 174 | function badr:getRoot() 175 | local node = self 176 | while node.parent do 177 | node = node.parent -- Traverse up to find the root 178 | end 179 | return node 180 | end 181 | 182 | -- Set focus on a specific element 183 | function badr:setFocus(element) 184 | local root = self:getRoot() -- Get the root node of the element 185 | if element.focusable then 186 | if root.focusedElement then 187 | root.focusedElement.focused = false -- Unfocus the current element 188 | end 189 | element.focused = true -- Set the new element as focused 190 | root.focusedElement = element -- Update the root's focused element 191 | end 192 | end 193 | 194 | local function gatherFocusableComponents(root) 195 | local focusableComponents = {} 196 | 197 | local function gather(component) 198 | if component.focusable and not component.disabled then 199 | table.insert(focusableComponents, component) 200 | end 201 | for _, child in ipairs(component.children or {}) do 202 | gather(child) -- Recursively gather focusable components from all children 203 | end 204 | end 205 | 206 | gather(root) 207 | return focusableComponents 208 | end 209 | 210 | function badr:getNextFocusable(direction) 211 | local root = self:getRoot() -- Focus within the current root context 212 | local focusableComponents = gatherFocusableComponents(root) 213 | local currentIndex = nil 214 | 215 | for i, component in ipairs(focusableComponents) do 216 | if component == root.focusedElement then 217 | currentIndex = i 218 | break 219 | end 220 | end 221 | 222 | if not currentIndex then return nil end 223 | 224 | local nextIndex 225 | if direction == "previous" then 226 | nextIndex = currentIndex > 1 and currentIndex - 1 or #focusableComponents 227 | elseif direction == "next" then 228 | nextIndex = currentIndex < #focusableComponents and currentIndex + 1 or 1 229 | end 230 | 231 | return focusableComponents[nextIndex] 232 | end 233 | 234 | function badr:getNthFocusable(direction, n) 235 | local root = self:getRoot() -- Focus within the current root context 236 | local focusableComponents = gatherFocusableComponents(root) 237 | local currentIndex = nil 238 | 239 | for i, component in ipairs(focusableComponents) do 240 | if component == root.focusedElement then 241 | currentIndex = i 242 | break 243 | end 244 | end 245 | 246 | if not currentIndex then return nil end 247 | 248 | local nextIndex 249 | local total = #focusableComponents 250 | 251 | if direction == "previous" then 252 | nextIndex = math.max(1, currentIndex - n) -- go to -n or the first element 253 | elseif direction == "next" then 254 | nextIndex = math.min(total, currentIndex + n) -- go to +n or the last element 255 | end 256 | 257 | nextIndex = math.max(1, math.min(total, nextIndex)) 258 | 259 | return focusableComponents[nextIndex] 260 | end 261 | 262 | function badr:focusFirstElement() 263 | local root = self:getRoot() -- Get the root context of this element 264 | for _, child in ipairs(gatherFocusableComponents(root)) do 265 | root:setFocus(child) -- Set focus to the first focusable element 266 | break 267 | end 268 | end 269 | 270 | -- Handles keyboard navigation 271 | function badr:keypressed(key) 272 | local root = self:getRoot() 273 | if not root.focusedElement then return end 274 | 275 | if root.focusedElement.onKeyPress then 276 | root.focusedElement:onKeyPress(key) 277 | end 278 | 279 | local nextElement 280 | if (self.column and key == "up") or (self.row and key == "left") then 281 | nextElement = root.focusedElement:getNextFocusable("previous") 282 | elseif (self.column and key == "down") or (self.row and key == "right") then 283 | nextElement = root.focusedElement:getNextFocusable("next") 284 | elseif key == "[" then 285 | nextElement = root.focusedElement:getNthFocusable("previous", 4) 286 | elseif key == "]" then 287 | nextElement = root.focusedElement:getNthFocusable("next", 4) 288 | end 289 | 290 | if nextElement then 291 | root:setFocus(nextElement) 292 | end 293 | end 294 | 295 | return setmetatable({ root = badr.createRoot, new = badr.new }, { 296 | __call = function(t, ...) 297 | return badr:new(...) 298 | end, 299 | }) 300 | -------------------------------------------------------------------------------- /lib/gui/button.lua: -------------------------------------------------------------------------------- 1 | local component = require("lib.gui.badr") 2 | local icon = require("lib.gui.icon") 3 | local theme = require("helpers.config").theme 4 | 5 | return function(props) 6 | local font = props.font or love.graphics.getFont() 7 | local padding = { 8 | horizontal = (props.leftPadding or 4) + (props.rightPadding or 4), 9 | vertical = (props.topPadding or 4) + (props.bottomPadding or 4) 10 | } 11 | -- local width = math.max(props.width or 0, font:getWidth(props.text) + padding.horizontal) 12 | local width = props.width or 0 13 | local height = math.max(props.height or 0, font:getHeight() + padding.vertical) 14 | 15 | local iconSize = props.iconSize or 16 16 | 17 | -- Scroll-related variables 18 | local scrollOffset = 0 19 | local scrollSpeed = 50 -- Pixels per second 20 | local spacer = " • " -- Spacer between wrapped text 21 | local spacerWidth = font:getWidth(spacer) -- Width of the spacer 22 | 23 | return component { 24 | text = props.text, 25 | icon = props.icon or nil, 26 | -- 27 | id = props.id or tostring(love.timer.getTime()), 28 | x = props.x or 0, 29 | y = props.y or 0, 30 | width = width, 31 | height = height, 32 | font = font, 33 | focusable = props.focusable or true, 34 | -- styles 35 | backgroundColor = theme:read_color("button", "BUTTON_BACKGROUND", "#2d3436"), 36 | focusColor = theme:read_color("button", "BUTTON_FOCUS", "#636e72"), 37 | textColor = theme:read_color("button", "BUTTON_TEXT", "#dfe6e9"), 38 | leftPadding = props.leftPadding or 8, 39 | rightPadding = props.rightPadding or 4, 40 | topPadding = props.topPadding or 4, 41 | bottomPadding = props.bottomPadding or 4, 42 | -- logic 43 | onClick = props.onClick, 44 | disabled = props.disabled or false, 45 | onKeyPress = function(self, key) 46 | if key == "return" and self.focused and props.onClick then 47 | props.onClick() 48 | end 49 | end, 50 | onUpdate = function(self, dt) 51 | -- Update scroll offset if text is wider than the button 52 | local textWidth = font:getWidth(self.text) 53 | -- Only scroll if the button is focused and the text is longer than the button width 54 | if self.focused and textWidth > self.width - padding.horizontal then 55 | scrollOffset = scrollOffset + scrollSpeed * dt 56 | -- Wrap the scroll offset when it exceeds the text width 57 | if scrollOffset > textWidth + spacerWidth then 58 | scrollOffset = 0 -- Reset to the beginning 59 | end 60 | else 61 | scrollOffset = 0 -- Reset scroll offset when not focused 62 | end 63 | end, 64 | -- 65 | draw = function(self) 66 | if not self.visible then return end 67 | love.graphics.push() 68 | love.graphics.setFont(font) 69 | 70 | -- Set color based on focus 71 | if self.focused then 72 | love.graphics.setColor(self.focusColor) 73 | else 74 | love.graphics.setColor(self.backgroundColor) 75 | end 76 | 77 | -- Draw button background 78 | love.graphics.rectangle('fill', self.x, self.y, self.width, self.height, self.cornerRadius) 79 | 80 | if self.icon then 81 | local leftIcon = icon { 82 | name = self.icon, 83 | x = self.x + self.leftPadding, 84 | y = self.y + (self.height - iconSize) * 0.5, 85 | size = iconSize 86 | } 87 | leftIcon:draw() 88 | end 89 | 90 | -- Draw button text 91 | love.graphics.setColor(self.textColor) 92 | love.graphics.setScissor(self.x, self.y, self.width, self.height) -- Clip text to button bounds 93 | 94 | local textWidth = font:getWidth(self.text) 95 | 96 | if textWidth <= self.width - padding.horizontal then 97 | -- Center the text if it fits within the button 98 | love.graphics.printf(self.text, self.x, self.y + self.topPadding, self.width, 'center') 99 | else 100 | -- Scroll the text if it's longer than the button width 101 | local textX = self.x + self.leftPadding - scrollOffset 102 | love.graphics.print(self.text, textX, self.y + self.topPadding) 103 | 104 | -- Draw the wrapped text with a spacer to the right of the first text 105 | if scrollOffset > textWidth - (self.width - padding.horizontal) then 106 | love.graphics.print(spacer .. self.text, textX + textWidth, self.y + self.topPadding) 107 | end 108 | end 109 | 110 | love.graphics.setScissor() -- Reset scissor 111 | love.graphics.pop() 112 | end 113 | } 114 | end 115 | -------------------------------------------------------------------------------- /lib/gui/checkbox.lua: -------------------------------------------------------------------------------- 1 | local component = require('lib.gui.badr') 2 | local icon = require('lib.gui.icon') 3 | local theme = require('helpers.config').theme 4 | 5 | return function(props) 6 | local font = props.font or love.graphics.getFont() 7 | local padding = { 8 | horizontal = (props.leftPadding or 8) + (props.rightPadding or 8), 9 | vertical = (props.topPadding or 8) + (props.bottomPadding or 8) 10 | } 11 | local text = props.text or "" 12 | local t = love.graphics.newText(font, text) 13 | local labelWidth, labelHeight = t:getWidth(), t:getHeight() 14 | 15 | local checkboxSize = props.checkboxSize or 16 -- Size of the checkbox square 16 | local width = math.max(props.width or 0, checkboxSize + padding.horizontal + labelWidth) 17 | local height = math.max(props.height or 0, checkboxSize + padding.vertical) 18 | 19 | return component { 20 | text = text, 21 | checked = props.checked or false, 22 | id = props.id, 23 | -- Positioning and layout properties 24 | x = props.x or 0, 25 | y = props.y or 0, 26 | width = width, 27 | height = height, 28 | focusable = props.focusable or true, 29 | -- Colors and styles 30 | backgroundColor = theme:read_color("checkbox", "CHECKBOX_BACKGROUND", "#000000"), 31 | focusColor = theme:read_color("checkbox", "CHECKBOX_FOCUS", "#2d3436"), 32 | checkColor = theme:read_color("checkbox", "CHECKBOX_INDICATOR", "#dfe6e9"), 33 | checkBg = theme:read_color("checkbox", "CHECKBOX_INDICATOR_BG", "#636e72"), 34 | textColor = theme:read_color("checkbox", "CHECKBOX_TEXT", "#dfe6e9"), 35 | borderWidth = props.borderWidth or 2, 36 | -- Events 37 | onToggle = props.onToggle, 38 | -- Key press handling for toggling checkbox with Enter/Return key 39 | onKeyPress = function(self, key) 40 | if key == "return" and self.focused then 41 | self.checked = not self.checked 42 | if self.onToggle then self:onToggle(self.checked) end 43 | end 44 | end, 45 | draw = function(self) 46 | if not self.visible then return end 47 | love.graphics.push() 48 | love.graphics.setFont(font) 49 | 50 | -- Background and focus styling 51 | if self.focused then 52 | love.graphics.setColor(self.focused and self.focusColor or self.backgroundColor) 53 | love.graphics.rectangle("fill", self.x, self.y, self.parent.width or self.width, self.height) 54 | end 55 | 56 | local bgIcon = icon { 57 | name = "square", 58 | x = self.x + padding.horizontal / 2, 59 | y = self.y + padding.vertical / 2, 60 | size = checkboxSize 61 | } 62 | 63 | local fgIcon = icon { 64 | name = "square_check", 65 | x = self.x + padding.horizontal / 2, 66 | y = self.y + padding.vertical / 2, 67 | size = checkboxSize 68 | } 69 | 70 | -- Inner box for the checkbox background 71 | love.graphics.setColor(self.checkBg) 72 | bgIcon:draw() 73 | 74 | -- Checkbox mark if checked 75 | if self.checked then 76 | love.graphics.setColor(self.checkColor) 77 | fgIcon:draw() 78 | end 79 | 80 | -- Draw the label next to the checkbox 81 | love.graphics.setColor(self.textColor) 82 | love.graphics.draw(t, self.x + checkboxSize + padding.horizontal, self.y + height / 2 - labelHeight / 2) 83 | 84 | love.graphics.pop() 85 | end 86 | } 87 | end 88 | -------------------------------------------------------------------------------- /lib/gui/footer.lua: -------------------------------------------------------------------------------- 1 | local component = require 'lib.gui.badr' 2 | local label = require 'lib.gui.label' 3 | 4 | local function footer() 5 | return component { row = true, gap = 40 } 6 | + label { text = "Select", icon = "button_a" } 7 | + label { text = "Back/Quit", icon = "button_b" } 8 | + label { text = "Navigate", icon = "dpad" } 9 | + label { text = "Settings", icon = "select" } 10 | end 11 | 12 | return footer 13 | -------------------------------------------------------------------------------- /lib/gui/icon.lua: -------------------------------------------------------------------------------- 1 | local component = require 'lib.gui.badr' 2 | 3 | -- Icons table 4 | local icons = { 5 | caret_left = love.graphics.newImage("assets/icons/caret-left-solid.png"), 6 | caret_right = love.graphics.newImage("assets/icons/caret-right-solid.png"), 7 | folder = love.graphics.newImage("assets/icons/folder-open-regular.png"), 8 | display = love.graphics.newImage("assets/icons/display-solid.png"), 9 | canvas = love.graphics.newImage("assets/icons/object-group-solid.png"), 10 | image = love.graphics.newImage("assets/icons/image-regular.png"), 11 | controller = love.graphics.newImage("assets/icons/gamepad-solid.png"), 12 | warn = love.graphics.newImage("assets/icons/triangle-exclamation-solid.png"), 13 | info = love.graphics.newImage("assets/icons/circle-info-solid.png"), 14 | cd = love.graphics.newImage("assets/icons/compact-disc-solid.png"), 15 | square = love.graphics.newImage("assets/icons/square-regular.png"), 16 | square_check = love.graphics.newImage("assets/icons/square-check-solid.png"), 17 | sd_card = love.graphics.newImage("assets/icons/sd-card-solid.png"), 18 | file_import = love.graphics.newImage("assets/icons/file-import-solid.png"), 19 | refresh = love.graphics.newImage("assets/icons/rotate-right-solid.png"), 20 | download = love.graphics.newImage("assets/icons/download-solid.png"), 21 | wrench = love.graphics.newImage("assets/icons/wrench-solid.png"), 22 | mag_glass = love.graphics.newImage("assets/icons/magnifying-glass-solid.png"), 23 | button_a = love.graphics.newImage("assets/inputs/switch_button_a.png"), 24 | button_b = love.graphics.newImage("assets/inputs/switch_button_b.png"), 25 | button_x = love.graphics.newImage("assets/inputs/switch_button_x.png"), 26 | button_y = love.graphics.newImage("assets/inputs/switch_button_y.png"), 27 | dpad = love.graphics.newImage("assets/inputs/switch_dpad_vertical_outline.png"), 28 | select = love.graphics.newImage("assets/inputs/switch_button_sl.png"), 29 | } 30 | 31 | return function(props) 32 | local name = props.name 33 | local icon = icons[name] 34 | 35 | if not icon then 36 | icon = icons["warn"] 37 | end 38 | 39 | local boxSize = props.size or 24 40 | local iconWidth, iconHeight = icon:getWidth(), icon:getHeight() 41 | local sx, sy = boxSize / iconWidth, boxSize / iconHeight 42 | 43 | -- Calculate the position to center the icon within the box 44 | local offsetX = (boxSize - iconWidth * sx) / 2 45 | local offsetY = (boxSize - iconHeight * sy) / 2 46 | 47 | return component { 48 | id = props.id or tostring(love.timer.getTime()), 49 | x = props.x or 0, 50 | y = props.y or 0, 51 | width = boxSize, 52 | height = boxSize, 53 | focusable = false, 54 | draw = function(self) 55 | love.graphics.push() 56 | 57 | -- Draw transparent box as the icon background 58 | love.graphics.setColor(1, 1, 1, 0) -- Fully transparent background 59 | love.graphics.rectangle("fill", self.x, self.y, self.width, self.height) 60 | 61 | -- Draw the icon centered within the box 62 | love.graphics.setColor(1, 1, 1, 1) -- Reset color to opaque for the icon 63 | love.graphics.draw(icon, self.x + offsetX, self.y + offsetY, 0, sx, sy) 64 | 65 | love.graphics.pop() 66 | end, 67 | } 68 | end 69 | -------------------------------------------------------------------------------- /lib/gui/label.lua: -------------------------------------------------------------------------------- 1 | local component = require("lib.gui.badr") 2 | local icon = require("lib.gui.icon") 3 | local theme = require("helpers.config").theme 4 | 5 | local function label(props) 6 | local _font = props.font or love.graphics.getFont() 7 | local color = props.color or theme:read_color("label", "LABEL_TEXT") 8 | local iconSize = 20 9 | local padding = props.iconPadding or 4 10 | 11 | local textWidth = _font:getWidth(props.text or props) 12 | local textHeight = _font:getHeight() 13 | local totalWidth = textWidth 14 | if props.iconName then 15 | totalWidth = totalWidth + iconSize + padding 16 | end 17 | 18 | return component { 19 | text = props.text or props, 20 | visible = props.visible, 21 | id = props.id, 22 | x = props.x or 0, 23 | y = props.y or 0, 24 | width = totalWidth, 25 | height = textHeight, 26 | font = _font, 27 | icon = props.icon, 28 | draw = function(self) 29 | if not self.visible then return end 30 | 31 | love.graphics.push() 32 | love.graphics.setFont(self.font) 33 | 34 | -- Draw the icon on the left if icon is provided 35 | if self.icon then 36 | local leftIcon = icon { 37 | name = self.icon, 38 | x = self.x, 39 | y = self.y + (self.height - iconSize) / 2, 40 | size = iconSize 41 | } 42 | leftIcon:draw() 43 | end 44 | 45 | -- Calculate the position of the text based on the presence of an icon 46 | local textX = self.x 47 | if self.icon then 48 | textX = textX + iconSize + padding 49 | end 50 | 51 | -- Draw the label text 52 | love.graphics.setColor(color) 53 | love.graphics.print(self.text or "", textX, self.y) 54 | love.graphics.setColor({ 1, 1, 1 }) -- Reset color to white 55 | love.graphics.pop() 56 | end, 57 | } 58 | end 59 | 60 | return label 61 | -------------------------------------------------------------------------------- /lib/gui/listitem.lua: -------------------------------------------------------------------------------- 1 | local component = require('lib.gui.badr') 2 | local icon = require("lib.gui.icon") 3 | local theme = require('helpers.config').theme 4 | 5 | return function(props) 6 | local font = props.font or love.graphics.getFont() 7 | local padding = { 8 | horizontal = (props.leftPadding or 4) + (props.rightPadding or 4), 9 | vertical = (props.topPadding or 8) + (props.bottomPadding or 8) 10 | } 11 | local iconSize = props.icon and 16 or 0 12 | local text = props.text or "" 13 | 14 | local itemHeight = theme:read_number("listitem", "ITEM_HEIGHT", 16) 15 | local height = math.max(props.height or 0, itemHeight + padding.vertical) 16 | 17 | -- Scroll-related variables 18 | local scrollOffset = 0 19 | local scrollSpeed = 50 -- Pixels per second 20 | local spacer = " • " -- Spacer between wrapped text 21 | local spacerWidth = font:getWidth(spacer) -- Width of the spacer 22 | 23 | local indicators = { 24 | theme:read_color("listitem", "ITEM_INDICATOR_DEFAULT", "#dfe6e9"), 25 | theme:read_color("listitem", "ITEM_INDICATOR_SUCCESS", "#2ecc71"), 26 | theme:read_color("listitem", "ITEM_INDICATOR_ERROR", "#e74c3c"), 27 | } 28 | 29 | return component { 30 | text = text, 31 | checked = props.checked or false, 32 | id = props.id, 33 | -- Positioning and layout properties 34 | x = props.x or 0, 35 | y = props.y or 0, 36 | width = props.width or 0, 37 | height = height, 38 | focusable = props.focusable or true, 39 | disabled = props.disabled or false, 40 | active = props.active or false, 41 | icon = props.icon or nil, 42 | -- Colors and styles 43 | backgroundColor = theme:read_color("listitem", "ITEM_BACKGROUND", "#000000"), 44 | focusColor = theme:read_color("listitem", "ITEM_FOCUS", "#2d3436"), 45 | indicatorColor = indicators[props.indicator or 1], 46 | textColor = theme:read_color("listitem", "ITEM_TEXT", "#dfe6e9"), 47 | -- Focus state 48 | last_focused = false, 49 | -- Events 50 | onFocus = props.onFocus, 51 | onClick = props.onClick, 52 | -- Key press handling for toggling checkbox with Enter/Return key 53 | onKeyPress = function(self, key) 54 | if key == "return" and self.focused and not self.disabled then 55 | if self.onClick then self:onClick() end 56 | end 57 | end, 58 | onUpdate = function(self, dt) 59 | -- Update width if necessary 60 | if self.width == 0 then 61 | self.width = self.parent.width 62 | end 63 | -- Update focus state 64 | if self.focused and not self.last_focused then 65 | if self.onFocus then self:onFocus() end 66 | end 67 | self.last_focused = self.focused 68 | 69 | local contentWidth = self.width - iconSize - padding.horizontal 70 | 71 | -- Update scroll offset if text is wider than the button 72 | local textWidth = font:getWidth(self.text or "") 73 | -- Only scroll if the button is focused and the text is longer than the button width 74 | if self.focused and textWidth > contentWidth then 75 | scrollOffset = scrollOffset + scrollSpeed * dt 76 | -- Wrap the scroll offset when it exceeds the text width 77 | if scrollOffset > textWidth + spacerWidth then 78 | scrollOffset = 0 -- Reset to the beginning 79 | end 80 | else 81 | scrollOffset = 0 -- Reset scroll offset when not focused 82 | end 83 | end, 84 | draw = function(self) 85 | if not self.visible then return end 86 | love.graphics.push() 87 | love.graphics.setFont(font) 88 | 89 | local labelHeight = font:getHeight(self.text) 90 | local topPadding = self.height * 0.5 - labelHeight * 0.5 91 | local leftPadding = (props.leftPadding or 4) 92 | 93 | -- Background and focus styling 94 | if self.focused then 95 | love.graphics.setColor(self.focusColor) 96 | love.graphics.rectangle("fill", self.x, self.y, self.width, self.height) 97 | end 98 | 99 | if self.active then 100 | love.graphics.setColor(self.indicatorColor) 101 | love.graphics.rectangle("fill", self.x, self.y + self.height * 0.25, 4, self.height * 0.5) 102 | end 103 | 104 | if self.icon then 105 | local leftIcon = icon { 106 | name = self.icon, 107 | x = self.x + leftPadding, 108 | y = self.y + (self.height - iconSize) * 0.5, 109 | size = iconSize 110 | } 111 | leftIcon:draw() 112 | end 113 | 114 | -- Stencil needed for framebuffer issues 115 | love.graphics.stencil( 116 | function() 117 | love.graphics.rectangle("fill", self.x + padding.horizontal, self.y, self.width - padding.horizontal, 118 | self.height) 119 | end, 120 | "replace", 1 121 | ) 122 | love.graphics.setStencilTest("greater", 0) 123 | love.graphics.setColor(self.textColor) 124 | 125 | local textX = self.x + 2 * leftPadding + iconSize 126 | local textWidth = font:getWidth(self.text) 127 | 128 | if textWidth <= self.width - padding.horizontal then 129 | -- Center the text if it fits within the button 130 | love.graphics.printf(self.text, textX, self.y + topPadding, self.width, 'left') 131 | else 132 | -- Scroll the text if it's longer than the button width 133 | textX = textX - scrollOffset 134 | love.graphics.print(self.text, textX, self.y + topPadding) 135 | 136 | -- Draw the wrapped text with a spacer to the right of the first text 137 | if scrollOffset > textWidth - (self.width - padding.horizontal) then 138 | love.graphics.print(spacer .. self.text, textX + textWidth, self.y + topPadding) 139 | end 140 | end 141 | 142 | love.graphics.setStencilTest() 143 | love.graphics.pop() 144 | end 145 | } 146 | end 147 | -------------------------------------------------------------------------------- /lib/gui/output_log.lua: -------------------------------------------------------------------------------- 1 | local component = require("lib.gui.badr") 2 | 3 | return function(props) 4 | local font = props.font or love.graphics.getFont() 5 | local padding = props.padding or 10 6 | 7 | local width = props.width or 0 8 | local height = props.height or 0 9 | 10 | return component { 11 | id = props.id or tostring(love.timer.getTime()), 12 | width = width - 4 * padding, 13 | height = height, 14 | font = font, 15 | text = "", 16 | draw = function(self) 17 | love.graphics.push() 18 | love.graphics.setColor(0, 0, 0, 0.5) 19 | love.graphics.rectangle("fill", self.x, self.y, self.width, self.height) 20 | love.graphics.setColor(1, 1, 1) 21 | love.graphics.stencil(function() 22 | love.graphics.rectangle("fill", self.x, self.y, self.width, self.height) 23 | end, "replace", 1) 24 | love.graphics.setStencilTest("greater", 0) 25 | 26 | -- Split the text into lines 27 | local lines = {} 28 | for s in self.text:gmatch("[^\r\n]+") do 29 | table.insert(lines, s) 30 | end 31 | 32 | -- Calculate the total height of all the lines 33 | local totalTextHeight = #lines * self.font:getHeight() 34 | 35 | -- Draw text from bottom-up 36 | local offset = self.height - totalTextHeight 37 | for i = 1, #lines do 38 | love.graphics.print(lines[i], self.x + 10, self.y + offset) 39 | offset = offset + self.font:getHeight() 40 | end 41 | 42 | love.graphics.setStencilTest() 43 | love.graphics.pop() 44 | end 45 | } 46 | end 47 | -------------------------------------------------------------------------------- /lib/gui/popup.lua: -------------------------------------------------------------------------------- 1 | local component = require("lib.gui.badr") 2 | local label = require("lib.gui.label") 3 | local theme = require("helpers.config").theme 4 | 5 | local function popup(props) 6 | local backgroundColor = theme:read_color("popup", "POPUP_BACKGROUND", "#000000") 7 | local opacity = theme:read_number("popup", "POPUP_OPACITY", 0.75) 8 | local boxColor = theme:read_color("popup", "POPUP_BOX", "#2d3436") 9 | backgroundColor[4] = opacity 10 | 11 | local screenWidth = love.graphics.getWidth() 12 | local screenHeight = love.graphics.getHeight() 13 | return component { 14 | title = props.title or "Info", 15 | content = props.content or "Info content", 16 | visible = props.visible, 17 | id = props.id, 18 | x = props.x or 0, 19 | y = props.y or 0, 20 | width = screenWidth, 21 | height = screenHeight, 22 | padding = props.padding or 10, 23 | _font = props.font or love.graphics.getFont(), 24 | draw = function(self) 25 | if not self.visible then return end 26 | 27 | local content_width, content_height, wrappedText 28 | 29 | if #self.children > 0 then 30 | content_width = math.min(self.children[1].width + self.padding * 2, self.width) 31 | content_height = math.min(self.children[1].height + self.padding * 2, self.width) 32 | else 33 | content_width = self.width - 150 34 | _, wrappedText = self._font:getWrap(self.content, content_width) 35 | content_height = self._font:getHeight() * #wrappedText 36 | end 37 | 38 | local center_width, center_height = screenWidth * 0.5 - content_width * 0.5, 39 | screenHeight * 0.5 - content_height * 0.5 40 | 41 | local overlayLabel = label { 42 | text = self.title, 43 | icon = props.icon or "info", 44 | font = self._font, 45 | x = self.x + center_width, 46 | y = self.y + center_height - 30, 47 | } 48 | 49 | local infoTextComponent = component { 50 | text = self.content, 51 | font = props.font or love.graphics.getFont(), 52 | width = content_width, 53 | height = 30, 54 | draw = function(self) 55 | local _, wrappedtext = self.font:getWrap(self.text, self.width - 10) 56 | love.graphics.push() 57 | love.graphics.translate(center_width, center_height) 58 | love.graphics.setColor(boxColor) 59 | love.graphics.rectangle("fill", self.x, self.y, self.width, #wrappedtext * self.height + 10) 60 | love.graphics.setColor(1, 1, 1) 61 | love.graphics.printf(wrappedtext, self.x + 10, self.y + 5, self.width - 10, "left") 62 | love.graphics.pop() 63 | end 64 | } 65 | 66 | -- Background 67 | love.graphics.push() 68 | love.graphics.setColor(backgroundColor) 69 | love.graphics.rectangle("fill", 0, 0, screenWidth, screenHeight) 70 | love.graphics.pop() 71 | 72 | overlayLabel:draw() 73 | -- If the popup has a child, draw it 74 | if #self.children > 0 then 75 | local child = self.children[1] 76 | 77 | love.graphics.push() 78 | love.graphics.translate(center_width, center_height) 79 | love.graphics.setColor(boxColor) 80 | love.graphics.rectangle("fill", self.x, self.y, content_width, content_height) 81 | love.graphics.setColor(1, 1, 1) 82 | love.graphics.translate(self.padding, self.padding) 83 | child:draw() 84 | love.graphics.pop() 85 | else 86 | infoTextComponent:draw() 87 | end 88 | end, 89 | } 90 | end 91 | 92 | return popup 93 | -------------------------------------------------------------------------------- /lib/gui/progress.lua: -------------------------------------------------------------------------------- 1 | local component = require("lib.gui.badr") 2 | local theme = require("helpers.config").theme 3 | 4 | return function(props) 5 | local width = props.width or 100 6 | local height = props.height or 20 7 | local progress = math.max(0, math.min(props.progress or 0, 1)) -- Clamp progress between 0 and 1 8 | 9 | return component { 10 | id = props.id or tostring(love.timer.getTime()), 11 | x = props.x or 0, 12 | y = props.y or 0, 13 | width = width, 14 | height = height, 15 | progress = progress, 16 | -- colors 17 | backgroundColor = theme:read_color("progress", "BAR_BACKGROUND", "#2d3436"), 18 | barColor = theme:read_color("progress", "BAR_COLOR", "#ffffff"), 19 | borderColor = theme:read_color("progress", "BAR_BORDER", "#636e72"), 20 | borderWidth = props.borderWidth or 2, 21 | -- draw function 22 | draw = function(self) 23 | if not self.visible then return end 24 | love.graphics.push() 25 | 26 | -- Draw background 27 | love.graphics.setColor(self.backgroundColor) 28 | love.graphics.rectangle('fill', self.x, self.y, self.width, self.height) 29 | 30 | -- Draw progress bar 31 | love.graphics.setColor(self.barColor) 32 | love.graphics.rectangle('fill', self.x, self.y, self.width * self.progress, self.height) 33 | 34 | -- Draw border if specified 35 | if self.borderWidth > 0 then 36 | love.graphics.setColor(self.borderColor) 37 | love.graphics.setLineWidth(self.borderWidth) 38 | love.graphics.rectangle('line', self.x, self.y, self.width, self.height) 39 | end 40 | 41 | love.graphics.pop() 42 | end, 43 | -- update function 44 | onUpdate = function(self, dt) 45 | -- Update progress, clamping between 0 and 1 46 | self.progress = math.max(0, math.min(self.progress, 1)) 47 | end, 48 | -- Set progress 49 | setProgress = function(self, newProgress) 50 | timer.tween(0.2, self, { progress = newProgress }, 'linear') 51 | -- self.progress = math.max(0, math.min(newProgress, 1)) 52 | end 53 | } 54 | end 55 | -------------------------------------------------------------------------------- /lib/gui/scroll_container.lua: -------------------------------------------------------------------------------- 1 | local component = require('lib.gui.badr') 2 | local theme = require('helpers.config').theme 3 | 4 | return function(props) 5 | local height = props.height or 6 | 200 -- Height of the scroll container viewport 7 | local width = props.width or 200 8 | local scrollY = 0 -- Initialize scroll position 9 | local scrollbarWidth = theme:read_number("scroll", "SCROLLBAR_WIDTH", 6) -- Width of the scroll bar 10 | 11 | return component { 12 | x = props.x or 0, 13 | y = props.y or 0, 14 | width = width, 15 | height = height, 16 | children = props.children or {}, 17 | focusable = false, 18 | 19 | barColor = theme:read_color("scroll", "SCROLLBAR_COLOR", "#636e72"), 20 | 21 | -- Scroll control methods 22 | scrollTo = function(self, position) 23 | -- Clamp the scroll position to be within the content bounds 24 | scrollY = math.max(0, math.min(position, self:getContentHeight() - height)) 25 | end, 26 | 27 | scrollToFocused = function(self) 28 | local focusedChild = self:getRoot().focusedElement 29 | if not focusedChild then return end 30 | 31 | -- Check if the focused element is within the scope of this scroll container 32 | local function isDescendantOf(component, parent) 33 | while component do 34 | if component == parent then return true end 35 | component = component.parent 36 | end 37 | return false 38 | end 39 | 40 | if not isDescendantOf(focusedChild, self) then return end 41 | 42 | -- Determine the relative position of the focused child within the container 43 | local childY = focusedChild.y - self.y - scrollY -- Relative Y position accounting for scroll offset 44 | if childY < 0 then 45 | -- Scroll up to bring the child into view 46 | self:scrollTo(scrollY + childY) 47 | elseif childY + focusedChild.height > height then 48 | -- Scroll down to bring the child into view 49 | self:scrollTo(scrollY + childY + focusedChild.height - height) 50 | end 51 | end, 52 | 53 | getContentHeight = function(self) 54 | -- Calculate the combined height of all children to determine content bounds 55 | local totalHeight = 0 56 | for _, child in ipairs(self.children) do 57 | totalHeight = totalHeight + child.height 58 | end 59 | return totalHeight 60 | end, 61 | 62 | drawScrollbar = function(self) 63 | -- Calculate scroll bar height and position based on the scroll position 64 | local contentHeight = self:getContentHeight() 65 | if contentHeight <= self.height then return end -- No scrollbar if content fits 66 | 67 | local scrollbarHeight = (self.height / contentHeight) * self.height 68 | local scrollbarY = (scrollY / contentHeight) * self.height 69 | 70 | -- Draw the scroll bar on the left of the container 71 | love.graphics.setColor(self.barColor) -- Set the scrollbar color (light gray) 72 | love.graphics.rectangle("fill", self.x - scrollbarWidth - 2, self.y + scrollbarY, scrollbarWidth, scrollbarHeight) 73 | end, 74 | 75 | draw = function(self) 76 | -- Apply clipping for the scroll container viewport 77 | love.graphics.setScissor(self.x, self.y, self.width, self.height) 78 | 79 | -- Draw each child with adjusted position for scrolling 80 | for _, child in ipairs(self.children) do 81 | love.graphics.push() 82 | love.graphics.translate(0, -scrollY) -- Adjust for scroll position 83 | child:draw() 84 | love.graphics.pop() 85 | end 86 | 87 | -- Remove clipping after drawing the children 88 | love.graphics.setScissor() 89 | 90 | -- Draw the scroll bar 91 | love.graphics.push() 92 | self:drawScrollbar() 93 | love.graphics.pop() 94 | end, 95 | 96 | update = function(self, dt) 97 | -- Update children with the current scroll offset 98 | for _, child in ipairs(self.children) do 99 | child:update(dt) 100 | end 101 | self:scrollToFocused() -- Ensure focused element is within view 102 | end, 103 | } 104 | end 105 | -------------------------------------------------------------------------------- /lib/gui/select.lua: -------------------------------------------------------------------------------- 1 | local component = require("lib.gui.badr") 2 | local icon = require("lib.gui.icon") 3 | local theme = require("helpers.config").theme 4 | 5 | return function(props) 6 | local font = props.font or love.graphics.getFont() 7 | local padding = { 8 | horizontal = (props.leftPadding or 4) + (props.rightPadding or 4), 9 | vertical = (props.topPadding or 4) + (props.bottomPadding or 4) 10 | } 11 | local options = props.options or {} 12 | local currentIndex = props.startIndex or 1 13 | 14 | -- Calculate width and height based on the longest option text 15 | local maxTextWidth = 0 16 | for _, option in ipairs(options) do 17 | maxTextWidth = math.max(maxTextWidth, font:getWidth(option)) 18 | end 19 | 20 | local iconSize = 16 -- Define the size of the icons 21 | local width = props.width or 0 22 | local height = math.max(props.height or 0, font:getHeight() + padding.vertical) 23 | 24 | -- Scroll-related variables 25 | local contentWidth = width - 2 * iconSize - padding.horizontal 26 | local scrollOffset = 0 27 | local scrollSpeed = 50 -- Pixels per second 28 | local spacer = " • " -- Spacer between wrapped text 29 | local spacerWidth = font:getWidth(spacer) -- Width of the spacer 30 | 31 | return component { 32 | options = options, 33 | currentIndex = currentIndex, 34 | x = props.x or 0, 35 | y = props.y or 0, 36 | width = width, 37 | height = height, 38 | font = font, 39 | focusable = props.focusable or true, 40 | -- styles 41 | backgroundColor = theme:read_color("select", "SELECT_BACKGROUND", "#2d3436"), 42 | focusColor = theme:read_color("select", "SELECT_FOCUS", "#636e72"), 43 | textColor = theme:read_color("select", "SELECT_TEXT", "#dfe6e9"), 44 | leftPadding = props.leftPadding or 4, 45 | rightPadding = props.rightPadding or 4, 46 | topPadding = props.topPadding or 4, 47 | bottomPadding = props.bottomPadding or 4, 48 | -- logic 49 | onKeyPress = function(self, key) 50 | if key == "left" then 51 | self.currentIndex = self.currentIndex > 1 and self.currentIndex - 1 or #self.options 52 | if props.onChange then props.onChange(key, self.currentIndex) end 53 | elseif key == "right" then 54 | self.currentIndex = self.currentIndex < #self.options and self.currentIndex + 1 or 1 55 | if props.onChange then props.onChange(key, self.currentIndex) end 56 | end 57 | end, 58 | onUpdate = function(self, dt) 59 | -- Update scroll offset if text is wider than the button 60 | local textWidth = font:getWidth(self.options[self.currentIndex] or "") 61 | -- Only scroll if the button is focused and the text is longer than the button width 62 | if self.focused and textWidth > contentWidth then 63 | scrollOffset = scrollOffset + scrollSpeed * dt 64 | -- Wrap the scroll offset when it exceeds the text width 65 | if scrollOffset > textWidth + spacerWidth then 66 | scrollOffset = 0 -- Reset to the beginning 67 | end 68 | else 69 | scrollOffset = 0 -- Reset scroll offset when not focused 70 | end 71 | end, 72 | draw = function(self) 73 | if not self.visible then return end 74 | love.graphics.push() 75 | love.graphics.setFont(font) 76 | 77 | -- Set color based on focus 78 | if self.focused then 79 | love.graphics.setColor(self.focusColor) 80 | else 81 | love.graphics.setColor(self.backgroundColor) 82 | end 83 | 84 | -- Draw background rectangle 85 | love.graphics.rectangle('fill', self.x, self.y, self.width, self.height) 86 | 87 | -- Draw caret icons using the icon component 88 | local leftIcon = icon { 89 | name = "caret_left", 90 | x = self.x + self.leftPadding, 91 | y = self.y + (self.height - iconSize) * 0.5, 92 | size = iconSize 93 | } 94 | local rightIcon = icon { 95 | name = "caret_right", 96 | x = self.x + self.width - iconSize - self.rightPadding, 97 | y = self.y + (self.height - iconSize) * 0.5, 98 | size = iconSize 99 | } 100 | 101 | leftIcon:draw() -- Draw the left caret 102 | rightIcon:draw() -- Draw the right caret 103 | 104 | 105 | local contentX = self.x + iconSize + self.leftPadding * 0.5 106 | love.graphics.setScissor(contentX, self.y, contentWidth, self.height) -- Clip text to button bounds 107 | 108 | -- Draw the current option text, centered between the icons 109 | local currentOption = self.options[self.currentIndex] or "" 110 | local textY = self.y + self.height * 0.5 - font:getHeight() * 0.5 111 | 112 | love.graphics.setColor(self.textColor) 113 | 114 | local textWidth = font:getWidth(currentOption) 115 | 116 | if textWidth <= contentWidth then 117 | -- Center the text if it fits within the button 118 | love.graphics.printf(currentOption, contentX, textY, contentWidth, 'center') 119 | else 120 | -- Scroll the text if it's longer than the button width 121 | local textX = contentX - scrollOffset 122 | love.graphics.print(currentOption, textX, self.y + self.topPadding) 123 | 124 | -- Draw the wrapped text with a spacer to the right of the first text 125 | if scrollOffset > textWidth - (contentWidth) then 126 | love.graphics.print(spacer .. currentOption, textX + textWidth, self.y + self.topPadding) 127 | end 128 | end 129 | 130 | love.graphics.setScissor() 131 | love.graphics.pop() 132 | end 133 | } 134 | end 135 | -------------------------------------------------------------------------------- /lib/ini.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | LICENSE: CC0 1.0 Universal (https://creativecommons.org/publicdomain/zero/1.0/legalcode). 3 | Attribution is not required but always appreciated. 4 | https://github.com/nobytesgiven/ini_parser 5 | 6 | modified to work with nativefs by gabrielfvale 7 | ]] -- 8 | 9 | local nativefs = require("lib.nativefs") 10 | local utils = require("helpers.utils") 11 | 12 | local ini = {} 13 | 14 | function ini.load(filePath) 15 | local fileInfo = nativefs.getInfo(filePath) 16 | if fileInfo and fileInfo.type == "file" then 17 | local iniTable = {} 18 | local currentSection = "default" 19 | for line in nativefs.lines(filePath) do 20 | -- Returns a string if the line is a comment 21 | local isComment = string.match(line, "^%s*;.*$") 22 | if line ~= "" and isComment == nil then 23 | -- Get section name (if section) 24 | local section = string.match(line, "%[%s*(.*)%s*%]") 25 | if section ~= nil then 26 | currentSection = section 27 | iniTable[section] = {} 28 | else 29 | -- Get variable name and value 30 | local variableName, variableValue = string.match(line, "^%s*(.*[^%s])%s*=%s*(.*[^%s])%s*$") 31 | if variableName and variableValue then 32 | iniTable[currentSection][variableName] = variableValue 33 | end 34 | end 35 | end 36 | end 37 | return iniTable 38 | end 39 | return nil 40 | end 41 | 42 | function ini.save(iniTable, file) 43 | if iniTable then 44 | local writeString = "" 45 | for sectionName, sectionValue in pairs(iniTable) do 46 | writeString = writeString .. "[" .. sectionName .. "]" .. "\r\n" 47 | for variableName, variableValue in pairs(sectionValue) do 48 | writeString = writeString .. variableName .. " = " .. variableValue .. "\r\n" 49 | end 50 | end 51 | local success, message = nativefs.write(file, writeString) 52 | if not success then 53 | return message 54 | end 55 | return 1 56 | end 57 | return nil 58 | end 59 | 60 | function ini.save_ordered(iniTable, file) 61 | if iniTable then 62 | local writeString = "" 63 | for sectionName, sectionValue in utils.orderedPairs(iniTable) do 64 | writeString = writeString .. "[" .. sectionName .. "]" .. "\r\n" 65 | for variableName, variableValue in utils.orderedPairs(sectionValue) do 66 | writeString = writeString .. variableName .. " = " .. variableValue .. "\r\n" 67 | end 68 | end 69 | local success, message = nativefs.write(file, writeString) 70 | if not success then 71 | return message 72 | end 73 | return 1 74 | end 75 | return nil 76 | end 77 | 78 | function ini.readKey(iniTable, sectionName, keyName) 79 | return iniTable[sectionName][keyName] 80 | end 81 | 82 | function ini.addSection(iniTable, newSectionName) 83 | iniTable[newSectionName] = {} 84 | return 1 85 | end 86 | 87 | function ini.addKey(iniTable, sectionName, keyName, keyValue) 88 | iniTable[sectionName][keyName] = keyValue 89 | return 1 90 | end 91 | 92 | function ini.sectionExists(iniTable, sectionName) 93 | if iniTable[sectionName] then 94 | return 1 95 | end 96 | return 0 97 | end 98 | 99 | function ini.keyExists(iniTable, sectionName, keyName) 100 | if iniTable[sectionName][keyName] then 101 | return 1 102 | end 103 | return 0 104 | end 105 | 106 | function ini.deleteSection(iniTable, sectionName) 107 | iniTable[sectionName] = nil 108 | return 1 109 | end 110 | 111 | function ini.deleteKey(iniTable, sectionName, keyName) 112 | iniTable[sectionName][keyName] = nil 113 | return 1 114 | end 115 | 116 | return ini 117 | -------------------------------------------------------------------------------- /lib/json.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- json.lua 3 | -- 4 | -- Copyright (c) 2020 rxi 5 | -- 6 | -- Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | -- this software and associated documentation files (the "Software"), to deal in 8 | -- the Software without restriction, including without limitation the rights to 9 | -- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 10 | -- of the Software, and to permit persons to whom the Software is furnished to do 11 | -- so, subject to the following conditions: 12 | -- 13 | -- The above copyright notice and this permission notice shall be included in all 14 | -- copies or substantial portions of the Software. 15 | -- 16 | -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | -- SOFTWARE. 23 | -- 24 | 25 | local json = { _version = "0.1.2" } 26 | 27 | ------------------------------------------------------------------------------- 28 | -- Encode 29 | ------------------------------------------------------------------------------- 30 | 31 | local encode 32 | 33 | local escape_char_map = { 34 | ["\\"] = "\\", 35 | ["\""] = "\"", 36 | ["\b"] = "b", 37 | ["\f"] = "f", 38 | ["\n"] = "n", 39 | ["\r"] = "r", 40 | ["\t"] = "t", 41 | } 42 | 43 | local escape_char_map_inv = { ["/"] = "/" } 44 | for k, v in pairs(escape_char_map) do 45 | escape_char_map_inv[v] = k 46 | end 47 | 48 | 49 | local function escape_char(c) 50 | return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte())) 51 | end 52 | 53 | 54 | local function encode_nil(val) 55 | return "null" 56 | end 57 | 58 | 59 | local function encode_table(val, stack) 60 | local res = {} 61 | stack = stack or {} 62 | 63 | -- Circular reference? 64 | if stack[val] then error("circular reference") end 65 | 66 | stack[val] = true 67 | 68 | if rawget(val, 1) ~= nil or next(val) == nil then 69 | -- Treat as array -- check keys are valid and it is not sparse 70 | local n = 0 71 | for k in pairs(val) do 72 | if type(k) ~= "number" then 73 | error("invalid table: mixed or invalid key types") 74 | end 75 | n = n + 1 76 | end 77 | if n ~= #val then 78 | error("invalid table: sparse array") 79 | end 80 | -- Encode 81 | for i, v in ipairs(val) do 82 | table.insert(res, encode(v, stack)) 83 | end 84 | stack[val] = nil 85 | return "[" .. table.concat(res, ",") .. "]" 86 | else 87 | -- Treat as an object 88 | for k, v in pairs(val) do 89 | if type(k) ~= "string" then 90 | error("invalid table: mixed or invalid key types") 91 | end 92 | table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) 93 | end 94 | stack[val] = nil 95 | return "{" .. table.concat(res, ",") .. "}" 96 | end 97 | end 98 | 99 | 100 | local function encode_string(val) 101 | return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' 102 | end 103 | 104 | 105 | local function encode_number(val) 106 | -- Check for NaN, -inf and inf 107 | if val ~= val or val <= -math.huge or val >= math.huge then 108 | error("unexpected number value '" .. tostring(val) .. "'") 109 | end 110 | return string.format("%.14g", val) 111 | end 112 | 113 | 114 | local type_func_map = { 115 | ["nil"] = encode_nil, 116 | ["table"] = encode_table, 117 | ["string"] = encode_string, 118 | ["number"] = encode_number, 119 | ["boolean"] = tostring, 120 | } 121 | 122 | 123 | encode = function(val, stack) 124 | local t = type(val) 125 | local f = type_func_map[t] 126 | if f then 127 | return f(val, stack) 128 | end 129 | error("unexpected type '" .. t .. "'") 130 | end 131 | 132 | 133 | function json.encode(val) 134 | return (encode(val)) 135 | end 136 | 137 | ------------------------------------------------------------------------------- 138 | -- Decode 139 | ------------------------------------------------------------------------------- 140 | 141 | local parse 142 | 143 | local function create_set(...) 144 | local res = {} 145 | for i = 1, select("#", ...) do 146 | res[select(i, ...)] = true 147 | end 148 | return res 149 | end 150 | 151 | local space_chars = create_set(" ", "\t", "\r", "\n") 152 | local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") 153 | local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") 154 | local literals = create_set("true", "false", "null") 155 | 156 | local literal_map = { 157 | ["true"] = true, 158 | ["false"] = false, 159 | ["null"] = nil, 160 | } 161 | 162 | 163 | local function next_char(str, idx, set, negate) 164 | for i = idx, #str do 165 | if set[str:sub(i, i)] ~= negate then 166 | return i 167 | end 168 | end 169 | return #str + 1 170 | end 171 | 172 | 173 | local function decode_error(str, idx, msg) 174 | local line_count = 1 175 | local col_count = 1 176 | for i = 1, idx - 1 do 177 | col_count = col_count + 1 178 | if str:sub(i, i) == "\n" then 179 | line_count = line_count + 1 180 | col_count = 1 181 | end 182 | end 183 | error(string.format("%s at line %d col %d", msg, line_count, col_count)) 184 | end 185 | 186 | 187 | local function codepoint_to_utf8(n) 188 | -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa 189 | local f = math.floor 190 | if n <= 0x7f then 191 | return string.char(n) 192 | elseif n <= 0x7ff then 193 | return string.char(f(n / 64) + 192, n % 64 + 128) 194 | elseif n <= 0xffff then 195 | return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) 196 | elseif n <= 0x10ffff then 197 | return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, 198 | f(n % 4096 / 64) + 128, n % 64 + 128) 199 | end 200 | error(string.format("invalid unicode codepoint '%x'", n)) 201 | end 202 | 203 | 204 | local function parse_unicode_escape(s) 205 | local n1 = tonumber(s:sub(1, 4), 16) 206 | local n2 = tonumber(s:sub(7, 10), 16) 207 | -- Surrogate pair? 208 | if n2 then 209 | return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) 210 | else 211 | return codepoint_to_utf8(n1) 212 | end 213 | end 214 | 215 | 216 | local function parse_string(str, i) 217 | local res = "" 218 | local j = i + 1 219 | local k = j 220 | 221 | while j <= #str do 222 | local x = str:byte(j) 223 | 224 | if x < 32 then 225 | decode_error(str, j, "control character in string") 226 | elseif x == 92 then -- `\`: Escape 227 | res = res .. str:sub(k, j - 1) 228 | j = j + 1 229 | local c = str:sub(j, j) 230 | if c == "u" then 231 | local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1) 232 | or str:match("^%x%x%x%x", j + 1) 233 | or decode_error(str, j - 1, "invalid unicode escape in string") 234 | res = res .. parse_unicode_escape(hex) 235 | j = j + #hex 236 | else 237 | if not escape_chars[c] then 238 | decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string") 239 | end 240 | res = res .. escape_char_map_inv[c] 241 | end 242 | k = j + 1 243 | elseif x == 34 then -- `"`: End of string 244 | res = res .. str:sub(k, j - 1) 245 | return res, j + 1 246 | end 247 | 248 | j = j + 1 249 | end 250 | 251 | decode_error(str, i, "expected closing quote for string") 252 | end 253 | 254 | 255 | local function parse_number(str, i) 256 | local x = next_char(str, i, delim_chars) 257 | local s = str:sub(i, x - 1) 258 | local n = tonumber(s) 259 | if not n then 260 | decode_error(str, i, "invalid number '" .. s .. "'") 261 | end 262 | return n, x 263 | end 264 | 265 | 266 | local function parse_literal(str, i) 267 | local x = next_char(str, i, delim_chars) 268 | local word = str:sub(i, x - 1) 269 | if not literals[word] then 270 | decode_error(str, i, "invalid literal '" .. word .. "'") 271 | end 272 | return literal_map[word], x 273 | end 274 | 275 | 276 | local function parse_array(str, i) 277 | local res = {} 278 | local n = 1 279 | i = i + 1 280 | while 1 do 281 | local x 282 | i = next_char(str, i, space_chars, true) 283 | -- Empty / end of array? 284 | if str:sub(i, i) == "]" then 285 | i = i + 1 286 | break 287 | end 288 | -- Read token 289 | x, i = parse(str, i) 290 | res[n] = x 291 | n = n + 1 292 | -- Next token 293 | i = next_char(str, i, space_chars, true) 294 | local chr = str:sub(i, i) 295 | i = i + 1 296 | if chr == "]" then break end 297 | if chr ~= "," then decode_error(str, i, "expected ']' or ','") end 298 | end 299 | return res, i 300 | end 301 | 302 | 303 | local function parse_object(str, i) 304 | local res = {} 305 | i = i + 1 306 | while 1 do 307 | local key, val 308 | i = next_char(str, i, space_chars, true) 309 | -- Empty / end of object? 310 | if str:sub(i, i) == "}" then 311 | i = i + 1 312 | break 313 | end 314 | -- Read key 315 | if str:sub(i, i) ~= '"' then 316 | decode_error(str, i, "expected string for key") 317 | end 318 | key, i = parse(str, i) 319 | -- Read ':' delimiter 320 | i = next_char(str, i, space_chars, true) 321 | if str:sub(i, i) ~= ":" then 322 | decode_error(str, i, "expected ':' after key") 323 | end 324 | i = next_char(str, i + 1, space_chars, true) 325 | -- Read value 326 | val, i = parse(str, i) 327 | -- Set 328 | res[key] = val 329 | -- Next token 330 | i = next_char(str, i, space_chars, true) 331 | local chr = str:sub(i, i) 332 | i = i + 1 333 | if chr == "}" then break end 334 | if chr ~= "," then decode_error(str, i, "expected '}' or ','") end 335 | end 336 | return res, i 337 | end 338 | 339 | 340 | local char_func_map = { 341 | ['"'] = parse_string, 342 | ["0"] = parse_number, 343 | ["1"] = parse_number, 344 | ["2"] = parse_number, 345 | ["3"] = parse_number, 346 | ["4"] = parse_number, 347 | ["5"] = parse_number, 348 | ["6"] = parse_number, 349 | ["7"] = parse_number, 350 | ["8"] = parse_number, 351 | ["9"] = parse_number, 352 | ["-"] = parse_number, 353 | ["t"] = parse_literal, 354 | ["f"] = parse_literal, 355 | ["n"] = parse_literal, 356 | ["["] = parse_array, 357 | ["{"] = parse_object, 358 | } 359 | 360 | 361 | parse = function(str, idx) 362 | local chr = str:sub(idx, idx) 363 | local f = char_func_map[chr] 364 | if f then 365 | return f(str, idx) 366 | end 367 | decode_error(str, idx, "unexpected character '" .. chr .. "'") 368 | end 369 | 370 | 371 | function json.decode(str) 372 | if type(str) ~= "string" then 373 | error("expected argument of type string, got " .. type(str)) 374 | end 375 | local res, idx = parse(str, next_char(str, 1, space_chars, true)) 376 | idx = next_char(str, idx, space_chars, true) 377 | if idx <= #str then 378 | decode_error(str, idx, "trailing garbage") 379 | end 380 | return res 381 | end 382 | 383 | return json 384 | -------------------------------------------------------------------------------- /lib/loading.lua: -------------------------------------------------------------------------------- 1 | require("globals") 2 | 3 | local loading = {} 4 | loading.__index = loading 5 | 6 | local logo = love.graphics.newImage("assets/scrappy_logo.png") 7 | local spin, flash_start, flash_end, highlight 8 | 9 | local rotation = { value = 0 } 10 | local opacity = { value = 1 } 11 | 12 | local highlight_position = { x = -logo:getWidth(), y = -logo:getHeight() } -- Start above and to the left of the logo 13 | 14 | function loading.new(type, update_duration) 15 | return setmetatable({ type = type or "spinner", update_duration = update_duration or 0.5 }, loading) 16 | end 17 | 18 | function loading:load() 19 | spin = function() 20 | timer.tween(self.update_duration, rotation, { value = rotation.value + 1 }, 'linear', spin) 21 | end 22 | flash_start = function() 23 | timer.tween(self.update_duration * 0.5, opacity, { value = 0.3 }, 'in-out-quad', flash_end) 24 | end 25 | flash_end = function() 26 | timer.tween(self.update_duration * 0.5, opacity, { value = 1 }, 'in-out-quad', flash_start) 27 | end 28 | highlight = function() 29 | timer.tween( 30 | self.update_duration, 31 | highlight_position, 32 | { x = logo:getWidth(), y = logo:getHeight() }, 33 | 'linear', 34 | function() 35 | highlight_position.x = -logo:getWidth() 36 | highlight_position.y = -logo:getHeight() 37 | highlight() 38 | end 39 | ) 40 | end 41 | 42 | if self.type == "spinner" then 43 | spin() 44 | elseif self.type == "flash" then 45 | flash_start() 46 | elseif self.type == "highlight" then 47 | highlight() 48 | else 49 | print("Unknown loading type: " .. self.type) 50 | end 51 | end 52 | 53 | local function moving_stencil() 54 | local w, h = 10, logo:getHeight() * 2 55 | love.graphics.push() 56 | love.graphics.translate(highlight_position.x, highlight_position.y) 57 | love.graphics.rotate(45 * math.pi / 180) 58 | love.graphics.rectangle("fill", -w * 0.5, -h * 0.5, w, h) 59 | love.graphics.pop() 60 | end 61 | 62 | function loading:draw(x, y, scale) 63 | if self.type == "spinner" then 64 | love.graphics.push() 65 | love.graphics.setColor(1, 1, 1) 66 | love.graphics.translate(x, y) 67 | love.graphics.rotate(rotation.value) 68 | love.graphics.draw(logo, 0, 0, rotation.value * math.pi, scale, scale, logo:getWidth() / 2, 69 | logo:getHeight() / 2) 70 | love.graphics.pop() 71 | end 72 | 73 | if self.type == "flash" then 74 | love.graphics.push() 75 | love.graphics.translate(x, y) 76 | love.graphics.setColor(1, 1, 1, opacity.value) 77 | love.graphics.draw(logo, 0, 0, 0, scale, scale, logo:getWidth() * 0.5, logo:getHeight() * 0.5) 78 | love.graphics.pop() 79 | end 80 | 81 | if self.type == "highlight" then 82 | love.graphics.push() 83 | love.graphics.translate(x, y) 84 | 85 | love.graphics.setColor(1, 1, 1, 0.3) 86 | love.graphics.draw(logo, 0, 0, 0, scale, scale, logo:getWidth() * 0.5, logo:getHeight() * 0.5) 87 | 88 | love.graphics.stencil(moving_stencil, "replace", 1) 89 | love.graphics.setStencilTest("equal", 1) 90 | 91 | love.graphics.setColor(1, 1, 1, 1) 92 | love.graphics.draw(logo, 0, 0, 0, scale, scale, logo:getWidth() * 0.5, logo:getHeight() * 0.5) 93 | 94 | love.graphics.setStencilTest() 95 | 96 | love.graphics.pop() 97 | end 98 | end 99 | 100 | function loading:reset() 101 | rotation.value = 0 102 | end 103 | 104 | return loading 105 | -------------------------------------------------------------------------------- /lib/log.lua: -------------------------------------------------------------------------------- 1 | local log = {} 2 | 3 | local socket = require("socket") 4 | local nativefs = require("lib.nativefs") 5 | local log_channel = require("lib.backend.channels").LOG_INPUT 6 | 7 | local log_backend = love.thread.newThread("lib/backend/log_backend.lua") 8 | local max_logs = 10 9 | 10 | local start_time 11 | 12 | local function cleanup_logs() 13 | local files = nativefs.getDirectoryItems("logs") 14 | if #files > max_logs then 15 | for i = 1, #files - max_logs do 16 | if files[i]:sub(-4) == ".log" then 17 | nativefs.remove("logs/" .. files[i]) 18 | end 19 | end 20 | end 21 | end 22 | 23 | function log.start() 24 | start_time = socket.gettime() 25 | cleanup_logs() 26 | log_backend:start(start_time) 27 | log_channel:push("start") 28 | end 29 | 30 | function log.close() 31 | log_channel:push("close") 32 | log_backend:wait() 33 | log_backend:release() 34 | end 35 | 36 | function log.write(msg, ...) 37 | local origin = select(1, ...) or "scrappy" 38 | local now = socket.gettime() 39 | 40 | local time_date = os.date("%H:%M:%S:", now) 41 | local msg = string.format("[%s%03d] (%s) %s", time_date, (now * 1000) % 1000, origin, msg) 42 | 43 | log_channel:push(msg) 44 | return msg 45 | end 46 | 47 | return log 48 | -------------------------------------------------------------------------------- /lib/metadata.lua: -------------------------------------------------------------------------------- 1 | local utils = require("helpers.utils") 2 | 3 | local metadata_parser = {} 4 | 5 | -- Parses the Pegasus frontend metadata file 6 | function metadata_parser.parse(content) 7 | local games = {} 8 | local current_game = {} 9 | 10 | for line in content:gmatch("[^\r\n]+") do 11 | -- Trim leading and trailing whitespace 12 | line = line:match("^%s*(.-)%s*$") 13 | 14 | -- Check if the line starts a new game block 15 | if line:match("^game:") then 16 | -- If there's a current game, add it to the games table 17 | if next(current_game) ~= nil then 18 | table.insert(games, current_game) 19 | end 20 | -- Start a new game block 21 | current_game = {} 22 | current_game.title = line:match("^game:%s*(.+)") 23 | elseif line:match("^file:") then 24 | -- Extract the full path 25 | local full_path = line:match("^file:%s*(.+)") 26 | -- Extract the filename from the full path 27 | current_game.filename = utils.get_filename(full_path:match("([^/]+)$")) 28 | elseif line:match("^description:") then 29 | current_game.description = line:match("^description:%s*(.+)") 30 | elseif line:match("^publisher:") then 31 | current_game.publisher = line:match("^publisher:%s*(.+)") 32 | elseif line:match("^genre:") then 33 | current_game.genre = line:match("^genre:%s*(.+)") 34 | end 35 | end 36 | 37 | -- Add the last game to the games table 38 | if next(current_game) ~= nil then 39 | table.insert(games, current_game) 40 | end 41 | 42 | return games 43 | end 44 | 45 | return metadata_parser 46 | -------------------------------------------------------------------------------- /lib/parser.lua: -------------------------------------------------------------------------------- 1 | require("globals") 2 | 3 | local parser = {} 4 | local line_patterns = { 5 | "found!", 6 | "Skipping game", 7 | "not found", 8 | "match too low", 9 | "No entries to scrape..." 10 | } 11 | local game_title_patterns = { 12 | FOUND = "Game '(.-)' found!", 13 | NOT_FOUND = "Game '(.-)' not found", 14 | MATCH_TOO_LOW = "Game '(.-)' match too low", 15 | SKIPPED = "Skipping game '(.-)' since" 16 | } 17 | local log_patterns = { 18 | "Running Skyscraper v", 19 | "Fetching limits for user", 20 | "Starting scraping run", 21 | "Resource gathering run" 22 | } 23 | local return_types = { 24 | GAME = "game", 25 | LOG = "log", 26 | } 27 | 28 | --[[ 29 | Parse a line of Skyscraper output, returning: 30 | - A line to be logged, or a game title if found 31 | - An error message if the line is an error 32 | - A boolean indicating whether the game is skipped or not 33 | - A string indicating the return type 34 | --]] 35 | function parser.parse(line) 36 | local game_pattern = "'([^']*'.-)'" 37 | local line_match = nil 38 | 39 | for _, pattern in ipairs(log_patterns) do 40 | if line:find(pattern) then 41 | return line, nil, false, return_types.LOG 42 | end 43 | end 44 | 45 | for _, pattern in ipairs(line_patterns) do 46 | if line:find(pattern) then 47 | line_match = pattern 48 | break 49 | end 50 | end 51 | 52 | if line_match then 53 | -- print("Line matched: " .. line) 54 | -- Extract game title 55 | if line_match == line_patterns[1] then -- Found 56 | local game_title = string.match(line, game_title_patterns.FOUND) 57 | return game_title, nil, false, return_types.GAME 58 | elseif line_match == line_patterns[2] then -- Skipped 59 | local game_title = string.match(line, game_title_patterns.SKIPPED) 60 | return game_title, nil, false, return_types.GAME 61 | elseif line_match == line_patterns[3] then -- Not found 62 | local game_title = string.match(line, game_title_patterns.NOT_FOUND) 63 | return game_title, nil, true, return_types.GAME 64 | elseif line_match == line_patterns[4] then -- Match too low 65 | local game_title = string.match(line, game_title_patterns.MATCH_TOO_LOW) 66 | return game_title, nil, true, return_types.GAME 67 | end 68 | return "N/A", nil, true, return_types.GAME 69 | else 70 | -- print("Line did not match: " .. line) 71 | for _, error in ipairs(SKYSCRAPER_ERRORS) do 72 | if line:find(error) then 73 | return nil, line, true, return_types.LOG 74 | end 75 | end 76 | return nil, nil, nil 77 | end 78 | end 79 | 80 | return parser 81 | -------------------------------------------------------------------------------- /lib/scenes.lua: -------------------------------------------------------------------------------- 1 | local nativefs = require("lib.nativefs") 2 | 3 | local scenes = { 4 | states = {}, 5 | focus = {}, 6 | action = { switch = false, push = false, pop = false, newid = 0 } 7 | } 8 | scenes.__index = scenes 9 | 10 | function scenes:load(initial_state) 11 | for _, file in ipairs(nativefs.getDirectoryItems("scenes")) do 12 | if string.find(file, ".lua") then 13 | self.states[string.gsub(file, ".lua", "")] = require("scenes." .. string.gsub(file, ".lua", "")) 14 | end 15 | end 16 | if initial_state then 17 | self:push(initial_state) 18 | end 19 | end 20 | 21 | function scenes:push(state) 22 | self.states[state]:load() 23 | self.focus[#self.focus + 1] = state 24 | end 25 | 26 | function scenes:pop() 27 | local cfocus = self:currentFocus() 28 | if #self.focus > 1 then 29 | if (self.states[cfocus].close ~= nil) then 30 | self.states[cfocus]:close() 31 | end 32 | self.focus[#self.focus] = nil 33 | end 34 | end 35 | 36 | function scenes:switch(state) 37 | for i, _ in ipairs(self.focus) do 38 | self.focus[i] = nil 39 | end 40 | self.focus = {} 41 | self:push(state) 42 | end 43 | 44 | function scenes:currentFocus() 45 | return self.focus[#self.focus] 46 | end 47 | 48 | function scenes:keypressed(key) 49 | self.states[self:currentFocus()]:keypressed(key) 50 | end 51 | 52 | function scenes:update(dt) 53 | self.states[self:currentFocus()]:update(dt) 54 | end 55 | 56 | function scenes:draw() 57 | for _, v in pairs(self.focus) do 58 | self.states[v]:draw() 59 | end 60 | end 61 | 62 | return scenes 63 | -------------------------------------------------------------------------------- /lib/skyscraper.lua: -------------------------------------------------------------------------------- 1 | require("globals") 2 | 3 | local json = require("lib.json") 4 | local log = require("lib.log") 5 | local channels = require("lib.backend.channels") 6 | local skyscraper_config = require("helpers.config").skyscraper_config 7 | 8 | local skyscraper = { 9 | base_command = "./Skyscraper", 10 | module = "screenscraper", 11 | config_path = "", 12 | peas_json = {} 13 | } 14 | 15 | local cache_thread, gen_thread 16 | 17 | local function push_cache_command(command) 18 | if channels.SKYSCRAPER_INPUT then 19 | channels.SKYSCRAPER_INPUT:push(command) 20 | end 21 | end 22 | local function push_command(command) 23 | if channels.SKYSCRAPER_GEN_INPUT then 24 | channels.SKYSCRAPER_GEN_INPUT:push(command) 25 | end 26 | end 27 | 28 | function skyscraper.init(config_path, binary) 29 | log.write("Initializing Skyscraper") 30 | skyscraper.config_path = WORK_DIR .. "/" .. config_path 31 | skyscraper.base_command = "./" .. binary 32 | 33 | -- Create threads for cache and generate commands 34 | cache_thread = love.thread.newThread("lib/backend/skyscraper_backend.lua") 35 | gen_thread = love.thread.newThread("lib/backend/skyscraper_generate_backend.lua") 36 | 37 | -- Load peas.json file 38 | local peas_file = nativefs.read(string.format("%s/static/.skyscraper/peas.json", WORK_DIR)) 39 | if peas_file then 40 | skyscraper.peas_json = json.decode(peas_file) 41 | else 42 | log.write("Unable to load peas.json file") 43 | end 44 | 45 | cache_thread:start() 46 | gen_thread:start() 47 | push_cache_command({ command = string.format("%s -v", skyscraper.base_command) }) 48 | end 49 | 50 | function skyscraper.filename_matches_extension(filename, platform) 51 | local formats = skyscraper.peas_json[platform] and skyscraper.peas_json[platform].formats 52 | if not formats then 53 | log.write("Unable to determine file formats for platform " .. platform) 54 | return true 55 | end 56 | 57 | -- .zip and .7z are added by default 58 | -- https://gemba.github.io/skyscraper/PLATFORMS/#sample-usecase-adding-platform-satellaview 59 | local match_patterns = { '%.*%.zip$', '%.*%.7z$' } 60 | -- Convert patterns to Lua-compatible patterns 61 | for _, pattern in ipairs(formats) do 62 | local lua_pattern = pattern:gsub("%*", ".*"):gsub("%.", "%%.") 63 | -- Add '$' to ensure the pattern matches the end of the string 64 | lua_pattern = lua_pattern .. "$" 65 | table.insert(match_patterns, lua_pattern) 66 | end 67 | 68 | -- Check if a file matches any of the patterns 69 | for _, pattern in ipairs(match_patterns) do 70 | if filename:match(pattern) then 71 | return true 72 | end 73 | end 74 | 75 | return false 76 | end 77 | 78 | local function generate_command(config) 79 | if config.fetch == nil then 80 | config.fetch = false 81 | end 82 | if config.use_config == nil then 83 | config.use_config = true 84 | end 85 | if config.module == nil then 86 | config.module = skyscraper.module 87 | end 88 | 89 | local command = "" 90 | if config.platform then 91 | command = string.format('%s -p %s', command, config.platform) 92 | end 93 | if config.fetch then 94 | command = string.format('%s -s %s', command, config.module) 95 | end 96 | if config.use_config then 97 | command = string.format('%s -c "%s"', command, skyscraper.config_path) 98 | end 99 | if config.cache then 100 | command = string.format('%s -d "%s"', command, config.cache) 101 | end 102 | if config.input then 103 | command = string.format('%s -i "%s"', command, config.input) 104 | end 105 | if config.rom then 106 | command = string.format('%s --startat "%s" --endat "%s"', command, config.rom, config.rom) 107 | end 108 | if config.artwork then 109 | command = string.format('%s -a "%s"', command, config.artwork) 110 | end 111 | if config.flags and next(config.flags) then 112 | command = string.format('%s --flags %s', command, table.concat(config.flags, ",")) 113 | end 114 | 115 | -- Force maximum number of threads 116 | command = string.format('%s -t 8', command) 117 | -- Use 'pegasus' frontend for simpler gamelist generation 118 | command = string.format('%s -f pegasus', command) 119 | return command 120 | end 121 | 122 | function skyscraper.run(command, input_folder, platform, op, game) 123 | platform = platform or "none" 124 | op = op or "generate" 125 | game = game or "none" 126 | if op == "generate" then 127 | push_command({ 128 | command = skyscraper.base_command .. command, 129 | platform = platform, 130 | op = op, 131 | game = game, 132 | input_folder = input_folder, 133 | }) 134 | else 135 | push_cache_command({ 136 | command = skyscraper.base_command .. command, 137 | platform = platform, 138 | op = op, 139 | game = game, 140 | input_folder = input_folder, 141 | }) 142 | end 143 | end 144 | 145 | function skyscraper.change_artwork(artworkXml) 146 | skyscraper_config:insert("main", "artworkXml", '"' .. artworkXml .. '"') 147 | skyscraper_config:save() 148 | end 149 | 150 | function skyscraper.update_sample(artwork_path) 151 | local command = generate_command({ 152 | use_config = false, 153 | platform = "megadrive", 154 | cache = WORK_DIR .. "/sample", 155 | input = WORK_DIR .. "/sample", 156 | artwork = artwork_path, 157 | flags = { "unattend" }, 158 | }) 159 | skyscraper.run(command, "N/A", "N/A", "generate", "fake-rom") 160 | end 161 | 162 | function skyscraper.custom_update_artwork(platform, cache, input, artwork) 163 | local command = generate_command({ 164 | use_config = false, 165 | platform = platform, 166 | cache = cache, 167 | input = input, 168 | artwork = artwork, 169 | flags = { "unattend" }, 170 | }) 171 | skyscraper.run(command) 172 | end 173 | 174 | function skyscraper.fetch_artwork(rom_path, input_folder, platform) 175 | local command = generate_command({ 176 | platform = platform, 177 | input = rom_path, 178 | fetch = true, 179 | flags = { "unattend", "onlymissing" }, 180 | }) 181 | skyscraper.run(command, input_folder, platform, "update") 182 | end 183 | 184 | function skyscraper.update_artwork(rom_path, rom, input_folder, platform, artwork) 185 | local artwork = WORK_DIR .. "/templates/" .. artwork .. ".xml" 186 | local update_command = generate_command({ 187 | platform = platform, 188 | input = rom_path, 189 | artwork = artwork, 190 | rom = rom, 191 | }) 192 | skyscraper.run(update_command, input_folder, platform, "generate", rom) 193 | end 194 | 195 | function skyscraper.fetch_single(rom_path, rom, input_folder, platform, ...) 196 | local flags = select(1, ...) or { "unattend" } 197 | local fetch_command = generate_command({ 198 | platform = platform, 199 | input = rom_path, 200 | fetch = true, 201 | rom = rom, 202 | flags = flags, 203 | }) 204 | skyscraper.run(fetch_command, input_folder, platform, "fetch", rom) 205 | end 206 | 207 | function skyscraper.custom_import(rom_path, platform) 208 | local command = generate_command({ 209 | platform = platform, 210 | input = rom_path, 211 | module = "import", 212 | fetch = true, 213 | }) 214 | skyscraper.run(command, "N/A", platform, "import") 215 | end 216 | 217 | return skyscraper 218 | -------------------------------------------------------------------------------- /lib/splash.lua: -------------------------------------------------------------------------------- 1 | require("globals") 2 | 3 | local splash = { finished = false } 4 | 5 | local app_name = love.graphics.newText(love.graphics.getFont(), "Scrappy") 6 | local version = love.graphics.newText(love.graphics.getFont(), version) 7 | local credits = love.graphics.newText(love.graphics.getFont(), string.format("by gabrielfvale")) 8 | 9 | local logo = love.graphics.newImage("assets/scrappy_logo.png") 10 | local anim = { value = 0 } 11 | 12 | local colors = { 13 | main = { 1, 1, 1 }, 14 | background = { 0, 0, 0 }, 15 | } 16 | 17 | function splash.load(delay) 18 | delay = delay or 1 19 | timer.tween(delay, anim, { value = 1 }, 'in-out-cubic') 20 | timer.after(delay + 0.2, function() 21 | timer.tween(0.5, anim, { value = 0 }, 'in-out-cubic', function() 22 | splash.finished = true 23 | end) 24 | end) 25 | end 26 | 27 | function splash.draw() 28 | if splash.finished then return end 29 | local width, height = love.graphics.getDimensions() 30 | local logo_scale = 1 31 | local half_logo_height = logo:getHeight() * 0.5 32 | 33 | love.graphics.clear(colors.background) 34 | 35 | love.graphics.push() 36 | love.graphics.translate(width * 0.5, height * 0.5) 37 | love.graphics.setColor(colors.main) 38 | love.graphics.draw(logo, 0, -anim.value * half_logo_height, 0, logo_scale, 39 | logo_scale, 40 | logo:getWidth() * 0.5, 41 | half_logo_height) 42 | love.graphics.setColor(1, 1, 1, anim.value) 43 | love.graphics.push() 44 | love.graphics.translate(0, half_logo_height) 45 | love.graphics.scale(1.5) 46 | love.graphics.draw(app_name, -app_name:getWidth() * 0.5, 47 | -anim.value * app_name:getHeight()) 48 | love.graphics.pop() 49 | love.graphics.push() 50 | love.graphics.setColor(1, 1, 1, 0.5) 51 | love.graphics.translate(0, height * 0.5 - 20) 52 | love.graphics.scale(0.75) 53 | love.graphics.draw(credits, -credits:getWidth() * 0.5, -credits:getHeight() - 20) 54 | love.graphics.draw(version, -version:getWidth() * 0.5, -version:getHeight()) 55 | love.graphics.pop() 56 | love.graphics.setColor(colors.background) 57 | love.graphics.pop() 58 | end 59 | 60 | function splash.finish() 61 | splash.finished = true 62 | end 63 | 64 | return splash 65 | -------------------------------------------------------------------------------- /lib/timer.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Copyright (c) 2010-2013 Matthias Richter 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | Except as contained in this notice, the name(s) of the above copyright holders 15 | shall not be used in advertising or otherwise to promote the sale, use or 16 | other dealings in this Software without prior written authorization. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | THE SOFTWARE. 25 | ]] -- 26 | 27 | local Timer = {} 28 | Timer.__index = Timer 29 | 30 | local function _nothing_() end 31 | 32 | local function updateTimerHandle(handle, dt) 33 | -- handle: { 34 | -- time = , 35 | -- after = , 36 | -- during = , 37 | -- limit = , 38 | -- count = , 39 | -- } 40 | handle.time = handle.time + dt 41 | handle.during(dt, math.max(handle.limit - handle.time, 0)) 42 | 43 | while handle.time >= handle.limit and handle.count > 0 do 44 | if handle.after(handle.after) == false then 45 | handle.count = 0 46 | break 47 | end 48 | handle.time = handle.time - handle.limit 49 | handle.count = handle.count - 1 50 | end 51 | end 52 | 53 | function Timer:update(dt) 54 | -- timers may create new timers, which leads to undefined behavior 55 | -- in pairs() - so we need to put them in a different table first 56 | local to_update = {} 57 | for handle in pairs(self.functions) do 58 | to_update[handle] = handle 59 | end 60 | 61 | for handle in pairs(to_update) do 62 | if self.functions[handle] then 63 | updateTimerHandle(handle, dt) 64 | if handle.count == 0 then 65 | self.functions[handle] = nil 66 | end 67 | end 68 | end 69 | end 70 | 71 | function Timer:during(delay, during, after) 72 | local handle = { time = 0, during = during, after = after or _nothing_, limit = delay, count = 1 } 73 | self.functions[handle] = true 74 | return handle 75 | end 76 | 77 | function Timer:after(delay, func) 78 | return self:during(delay, _nothing_, func) 79 | end 80 | 81 | function Timer:every(delay, after, count) 82 | local count = count or math.huge -- exploit below: math.huge - 1 = math.huge 83 | local handle = { time = 0, during = _nothing_, after = after, limit = delay, count = count } 84 | self.functions[handle] = true 85 | return handle 86 | end 87 | 88 | function Timer:cancel(handle) 89 | self.functions[handle] = nil 90 | end 91 | 92 | function Timer:clear() 93 | self.functions = {} 94 | end 95 | 96 | function Timer:script(f) 97 | local co = coroutine.wrap(f) 98 | co(function(t) 99 | self:after(t, co) 100 | coroutine.yield() 101 | end) 102 | end 103 | 104 | Timer.tween = setmetatable({ 105 | -- helper functions 106 | out = function(f) -- 'rotates' a function 107 | return function(s, ...) return 1 - f(1 - s, ...) end 108 | end, 109 | chain = function(f1, f2) -- concatenates two functions 110 | return function(s, ...) return (s < .5 and f1(2 * s, ...) or 1 + f2(2 * s - 1, ...)) * .5 end 111 | end, 112 | 113 | -- useful tweening functions 114 | linear = function(s) return s end, 115 | quad = function(s) return s * s end, 116 | cubic = function(s) return s * s * s end, 117 | quart = function(s) return s * s * s * s end, 118 | quint = function(s) return s * s * s * s * s end, 119 | sine = function(s) return 1 - math.cos(s * math.pi / 2) end, 120 | expo = function(s) return 2 ^ (10 * (s - 1)) end, 121 | circ = function(s) return 1 - math.sqrt(1 - s * s) end, 122 | 123 | back = function(s, bounciness) 124 | bounciness = bounciness or 1.70158 125 | return s * s * ((bounciness + 1) * s - bounciness) 126 | end, 127 | 128 | bounce = function(s) -- magic numbers ahead 129 | local a, b = 7.5625, 1 / 2.75 130 | return math.min(a * s ^ 2, a * (s - 1.5 * b) ^ 2 + .75, a * (s - 2.25 * b) ^ 2 + .9375, a * (s - 2.625 * b) ^ 2 + 131 | .984375) 132 | end, 133 | 134 | elastic = function(s, amp, period) 135 | amp, period = amp and math.max(1, amp) or 1, period or .3 136 | return (-amp * math.sin(2 * math.pi / period * (s - 1) - math.asin(1 / amp))) * 2 ^ (10 * (s - 1)) 137 | end, 138 | }, { 139 | 140 | -- register new tween 141 | __call = function(tween, self, len, subject, target, method, after, ...) 142 | -- recursively collects fields that are defined in both subject and target into a flat list 143 | local function tween_collect_payload(subject, target, out) 144 | for k, v in pairs(target) do 145 | local ref = subject[k] 146 | assert(type(v) == type(ref), 'Type mismatch in field "' .. k .. '".') 147 | if type(v) == 'table' then 148 | tween_collect_payload(ref, v, out) 149 | else 150 | local ok, delta = pcall(function() return (v - ref) * 1 end) 151 | assert(ok, 'Field "' .. k .. '" does not support arithmetic operations') 152 | out[#out + 1] = { subject, k, delta } 153 | end 154 | end 155 | return out 156 | end 157 | 158 | method = tween[method or 'linear'] -- see __index 159 | local payload, t, args = tween_collect_payload(subject, target, {}), 0, { ... } 160 | 161 | local last_s = 0 162 | return self:during(len, function(dt) 163 | t = t + dt 164 | local s = method(math.min(1, t / len), unpack(args)) 165 | local ds = s - last_s 166 | last_s = s 167 | for _, info in ipairs(payload) do 168 | local ref, key, delta = unpack(info) 169 | ref[key] = ref[key] + delta * ds 170 | end 171 | end, after) 172 | end, 173 | 174 | -- fetches function and generated compositions for method `key` 175 | __index = function(tweens, key) 176 | if type(key) == 'function' then return key end 177 | 178 | assert(type(key) == 'string', 'Method must be function or string.') 179 | if rawget(tweens, key) then return rawget(tweens, key) end 180 | 181 | local function construct(pattern, f) 182 | local method = rawget(tweens, key:match(pattern)) 183 | if method then return f(method) end 184 | return nil 185 | end 186 | 187 | local out, chain = rawget(tweens, 'out'), rawget(tweens, 'chain') 188 | return construct('^in%-([^-]+)$', function(...) return ... end) 189 | or construct('^out%-([^-]+)$', out) 190 | or construct('^in%-out%-([^-]+)$', function(f) return chain(f, out(f)) end) 191 | or construct('^out%-in%-([^-]+)$', function(f) return chain(out(f), f) end) 192 | or error('Unknown interpolation method: ' .. key) 193 | end 194 | }) 195 | 196 | -- Timer instancing 197 | function Timer.new() 198 | return setmetatable({ functions = {}, tween = Timer.tween }, Timer) 199 | end 200 | 201 | -- default instance 202 | local default = Timer.new() 203 | 204 | -- module forwards calls to default instance 205 | local module = {} 206 | for k in pairs(Timer) do 207 | if k ~= "__index" then 208 | module[k] = function(...) return default[k](default, ...) end 209 | end 210 | end 211 | module.tween = setmetatable({}, { 212 | __index = Timer.tween, 213 | __newindex = function(k, v) Timer.tween[k] = v end, 214 | __call = function(t, ...) return default:tween(...) end, 215 | }) 216 | 217 | return setmetatable(module, { __call = Timer.new }) 218 | -------------------------------------------------------------------------------- /logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/logs/.gitkeep -------------------------------------------------------------------------------- /main.lua: -------------------------------------------------------------------------------- 1 | require("globals") 2 | local log = require("lib.log") 3 | local pprint = require("lib.pprint") 4 | 5 | local scenes = require("lib.scenes") 6 | local skyscraper = require("lib.skyscraper") 7 | local splash = require("lib.splash") 8 | 9 | local configs = require("helpers.config") 10 | local input = require("helpers.input") 11 | local utils = require("helpers.utils") 12 | 13 | log.start() 14 | 15 | local user_config, skyscraper_config = configs.user_config, configs.skyscraper_config 16 | local theme = configs.theme 17 | 18 | local font = love.graphics.newFont( 19 | theme:read("main", "FONT") or "assets/ChakraPetch-Regular.ttf", 20 | theme:read_number("main", "FONT_SIZE") or 20) 21 | love.graphics.setFont(font) 22 | 23 | local footer = require("lib.gui.footer")() 24 | local w_width, w_height = love.window.getMode() 25 | 26 | function love.load(args) 27 | splash.load() 28 | 29 | if #args > 0 then 30 | local res = args[1] 31 | if res then 32 | _G.resolution = res 33 | res = utils.split(res, "x") 34 | love.window.setMode(tonumber(res[1]) or 640, tonumber(res[2]) or 480) 35 | w_width, w_height = love.window.getMode() 36 | end 37 | end 38 | 39 | 40 | -- Debug mode 41 | local debug = user_config:read("main", "debug") 42 | if debug ~= "1" then 43 | _G.print = function() end 44 | setmetatable(pprint, { __call = function() end }) 45 | end 46 | 47 | scenes:load("main") 48 | 49 | skyscraper.init( 50 | skyscraper_config.path, 51 | user_config:read("overrides", "binary") or "bin/Skyscraper.aarch64") 52 | input.load() 53 | 54 | footer:updatePosition(w_width * 0.5 - footer.width * 0.5 - 20, w_height - footer.height - 10) 55 | end 56 | 57 | function love.update(dt) 58 | timer.update(dt) 59 | input.update(dt) 60 | scenes:update(dt) 61 | input.onEvent(function(key) 62 | scenes:keypressed(key) 63 | end) 64 | end 65 | 66 | function love.draw() 67 | splash.draw() 68 | 69 | if splash.finished then 70 | scenes:draw() 71 | footer:draw() 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /mux_launch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # HELP: Scrappy 3 | # ICON: scrappy 4 | # GRID: Scrappy 5 | 6 | . /opt/muos/script/var/func.sh 7 | 8 | # Define global variables 9 | SCREEN_WIDTH=$(GET_VAR device mux/width) 10 | SCREEN_HEIGHT=$(GET_VAR device mux/height) 11 | SCREEN_RESOLUTION="${SCREEN_WIDTH}x${SCREEN_HEIGHT}" 12 | 13 | if pgrep -f "playbgm.sh" >/dev/null; then 14 | killall -q "playbgm.sh" "mpg123" 15 | fi 16 | 17 | echo app >/tmp/act_go 18 | 19 | # Define paths and commands 20 | LOVEDIR="$(GET_VAR "device" "storage/rom/mount")/MUOS/application/Scrappy/.scrappy" 21 | GPTOKEYB="$(GET_VAR "device" "storage/rom/mount")/MUOS/emulator/gptokeyb/gptokeyb2.armhf" 22 | STATICDIR="$LOVEDIR/static/" 23 | BINDIR="$LOVEDIR/bin" 24 | 25 | # Export environment variables 26 | export SDL_GAMECONTROLLERCONFIG_FILE="/usr/lib/gamecontrollerdb.txt" 27 | export XDG_DATA_HOME="$STATICDIR" 28 | export HOME="$STATICDIR" 29 | export LD_LIBRARY_PATH="$BINDIR/libs.aarch64:$LD_LIBRARY_PATH" 30 | export QT_PLUGIN_PATH="$BINDIR/plugins" 31 | 32 | # Create Skyscraper folders 33 | mkdir -p $HOME/.skyscraper/resources 34 | cp -r $LOVEDIR/templates/resources/* $HOME/.skyscraper/resources 35 | 36 | # Launcher 37 | cd "$LOVEDIR" || exit 38 | SET_VAR "system" "foreground_process" "love" 39 | 40 | # Run Application 41 | $GPTOKEYB "love" & 42 | ./bin/love . "${SCREEN_RESOLUTION}" 43 | 44 | kill -9 "$(pidof gptokeyb2.armhf)" 45 | -------------------------------------------------------------------------------- /sample/covers/screenscraper/fake-rom: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/sample/covers/screenscraper/fake-rom -------------------------------------------------------------------------------- /sample/db.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Megadrive 4 | Streets of Rage 5 | covers/screenscraper/fake-rom 6 | screenshots/screenscraper/fake-rom 7 | wheels/screenscraper/fake-rom 8 | marquees/screenscraper/fake-rom 9 | textures/screenscraper/fake-rom 10 | 11 | -------------------------------------------------------------------------------- /sample/fake-rom.zip: -------------------------------------------------------------------------------- 1 | Sonic the Hedgehog -------------------------------------------------------------------------------- /sample/marquees/screenscraper/fake-rom: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/sample/marquees/screenscraper/fake-rom -------------------------------------------------------------------------------- /sample/media/covers/fake-rom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/sample/media/covers/fake-rom.png -------------------------------------------------------------------------------- /sample/media/marquees/fake-rom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/sample/media/marquees/fake-rom.png -------------------------------------------------------------------------------- /sample/priorities.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | import 6 | esgamelist 7 | openretro 8 | arcadedb 9 | screenscraper 10 | mobygames 11 | thegamesdb 12 | 13 | 14 | screenscraper 15 | thegamesdb 16 | mobygames 17 | 18 | 19 | import 20 | esgamelist 21 | arcadedb 22 | screenscraper 23 | openretro 24 | thegamesdb 25 | mobygames 26 | 27 | 28 | import 29 | esgamelist 30 | arcadedb 31 | mobygames 32 | 33 | 34 | import 35 | esgamelist 36 | arcadedb 37 | mobygames 38 | 39 | 40 | import 41 | esgamelist 42 | arcadedb 43 | openretro 44 | screenscraper 45 | mobygames 46 | thegamesdb 47 | 48 | 49 | import 50 | esgamelist 51 | screenscraper 52 | mobygames 53 | thegamesdb 54 | arcadedb 55 | openretro 56 | 57 | 58 | import 59 | esgamelist 60 | arcadedb 61 | mobygames 62 | openretro 63 | screenscraper 64 | 65 | 66 | import 67 | mobygames 68 | 69 | 70 | import 71 | esgamelist 72 | mobygames 73 | thegamesdb 74 | screenscraper 75 | openretro 76 | 77 | 78 | import 79 | esgamelist 80 | screenscraper 81 | mobygames 82 | openretro 83 | thegamesdb 84 | 85 | 86 | import 87 | esgamelist 88 | screenscraper 89 | mobygames 90 | arcadedb 91 | openretro 92 | 93 | 94 | import 95 | screenscraper 96 | thegamesdb 97 | 98 | 99 | import 100 | esgamelist 101 | screenscraper 102 | thegamesdb 103 | 104 | 105 | import 106 | esgamelist 107 | screenscraper 108 | 109 | 110 | import 111 | esgamelist 112 | screenscraper 113 | 114 | 115 | -------------------------------------------------------------------------------- /sample/quickid.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /sample/screenshots/screenscraper/fake-rom: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/sample/screenshots/screenscraper/fake-rom -------------------------------------------------------------------------------- /sample/textures/screenscraper/fake-rom: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/sample/textures/screenscraper/fake-rom -------------------------------------------------------------------------------- /sample/wheels/screenscraper/fake-rom: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/sample/wheels/screenscraper/fake-rom -------------------------------------------------------------------------------- /scenes/settings.lua: -------------------------------------------------------------------------------- 1 | local scenes = require("lib.scenes") 2 | local configs = require("helpers.config") 3 | local utils = require("helpers.utils") 4 | 5 | local component = require 'lib.gui.badr' 6 | local button = require 'lib.gui.button' 7 | local label = require 'lib.gui.label' 8 | local checkbox = require 'lib.gui.checkbox' 9 | local scroll_container = require 'lib.gui.scroll_container' 10 | 11 | local user_config = configs.user_config 12 | local theme = configs.theme 13 | local w_width, w_height = love.window.getMode() 14 | 15 | local settings = {} 16 | 17 | local menu, checkboxes 18 | 19 | local all_check = true 20 | 21 | 22 | local function on_filter_resolution(index) 23 | local filtering = user_config:read("main", "filterTemplates") == "1" 24 | user_config:insert("main", "filterTemplates", filtering and "0" or "1") 25 | user_config:save() 26 | end 27 | 28 | local function on_change_platform(platform) 29 | local selected_platforms = user_config:get().platformsSelected 30 | local checked = tonumber(selected_platforms[platform]) == 1 31 | user_config:insert("platformsSelected", platform, checked and "0" or "1") 32 | user_config:save() 33 | end 34 | 35 | local function update_checkboxes() 36 | checkboxes.children = {} 37 | local platforms = user_config:get().platforms 38 | local selected_platforms = user_config:get().platformsSelected 39 | for platform in utils.orderedPairs(platforms or {}) do 40 | checkboxes = checkboxes + checkbox { 41 | text = platform, 42 | id = platform, 43 | onToggle = function() on_change_platform(platform) end, 44 | checked = selected_platforms[platform] == "1", 45 | width = w_width - 20, 46 | } 47 | end 48 | end 49 | 50 | local function on_refresh_press() 51 | user_config:load_platforms() 52 | user_config:save() 53 | update_checkboxes() 54 | end 55 | 56 | local on_check_all_press = function() 57 | local selected_platforms = user_config:get().platformsSelected 58 | for platform, _ in pairs(selected_platforms) do 59 | user_config:insert("platformsSelected", platform, all_check and "0" or "1") 60 | end 61 | all_check = not all_check 62 | user_config:save() 63 | update_checkboxes() 64 | end 65 | 66 | function settings:load() 67 | menu = component:root { column = true, gap = 10 } 68 | checkboxes = component { column = true, gap = 0 } 69 | 70 | menu = menu 71 | + label { text = 'Resolution', icon = "display" } 72 | + checkbox { 73 | text = 'Filter templates for my resolution', 74 | onToggle = on_filter_resolution, 75 | checked = user_config:read("main", "filterTemplates") == "1" 76 | } 77 | + label { text = 'Platforms', icon = "folder" } 78 | + (component { row = true, gap = 10 } 79 | + button { text = 'Rescan folders', width = 200, onClick = on_refresh_press } 80 | + button { text = 'Un/check all', width = 200, onClick = on_check_all_press }) 81 | 82 | local menu_height = menu.height 83 | 84 | update_checkboxes() 85 | 86 | menu:updatePosition(10, 10) 87 | menu:focusFirstElement() 88 | 89 | if not user_config:has_platforms() then 90 | menu = menu + label { 91 | text = "No platforms found; your paths might not have cores assigned", 92 | icon = "warn", 93 | } 94 | else 95 | menu = menu 96 | + (scroll_container { 97 | width = w_width - 20, 98 | height = w_height - menu_height - 60, 99 | scroll_speed = 30, 100 | } 101 | + checkboxes) 102 | end 103 | end 104 | 105 | function settings:update(dt) 106 | menu:update(dt) 107 | end 108 | 109 | function settings:draw() 110 | love.graphics.clear(theme:read_color("main", "BACKGROUND", "#000000")) 111 | menu:draw() 112 | end 113 | 114 | function settings:keypressed(key) 115 | menu:keypressed(key) 116 | if key == "escape" or key == "lalt" then 117 | scenes:pop() 118 | end 119 | end 120 | 121 | return settings 122 | -------------------------------------------------------------------------------- /scenes/single_scrape.lua: -------------------------------------------------------------------------------- 1 | local scenes = require("lib.scenes") 2 | local skyscraper = require("lib.skyscraper") 3 | local pprint = require("lib.pprint") 4 | local channels = require("lib.backend.channels") 5 | local configs = require("helpers.config") 6 | local utils = require("helpers.utils") 7 | local artwork = require("helpers.artwork") 8 | 9 | local component = require 'lib.gui.badr' 10 | local label = require 'lib.gui.label' 11 | local popup = require 'lib.gui.popup' 12 | local listitem = require 'lib.gui.listitem' 13 | local scroll_container = require 'lib.gui.scroll_container' 14 | 15 | local w_width, w_height = love.window.getMode() 16 | local single_scrape = {} 17 | 18 | 19 | local menu, info_window, platform_list, rom_list 20 | local user_config = configs.user_config 21 | local theme = configs.theme 22 | 23 | local last_selected_platform = nil 24 | local last_selected_rom = nil 25 | local active_column = 1 -- 1 for platforms, 2 for ROMs 26 | 27 | local function toggle_info() 28 | info_window.visible = not info_window.visible 29 | end 30 | local function dispatch_info(title, content) 31 | info_window.title = title 32 | info_window.content = content 33 | end 34 | 35 | local function on_select_platform(platform) 36 | last_selected_platform = platform 37 | active_column = 2 38 | for _, item in ipairs(platform_list.children) do 39 | item.disabled = true 40 | item.active = item.id == platform 41 | end 42 | for _, item in ipairs(rom_list.children) do 43 | item.disabled = false 44 | end 45 | rom_list:focusFirstElement() 46 | end 47 | 48 | local function on_rom_press(rom) 49 | last_selected_rom = rom 50 | local rom_path, _ = user_config:get_paths() 51 | local platforms = user_config:get().platforms 52 | 53 | rom_path = string.format("%s/%s", rom_path, last_selected_platform) 54 | 55 | local artwork_name = artwork.get_artwork_name() 56 | 57 | if artwork_name then 58 | local platform_dest = platforms[last_selected_platform] 59 | dispatch_info(rom, "Scraping ROM, please wait...") 60 | skyscraper.fetch_single(rom_path, rom, last_selected_platform, platform_dest) 61 | else 62 | dispatch_info("Error", "Artwork XML not found") 63 | end 64 | toggle_info() 65 | end 66 | 67 | local function on_return() 68 | if info_window.visible then 69 | toggle_info() 70 | return 71 | end 72 | if active_column == 2 then 73 | active_column = 1 74 | for _, item in ipairs(platform_list.children) do 75 | item.disabled = false 76 | item.active = false 77 | end 78 | for _, item in ipairs(rom_list.children) do 79 | item.disabled = true 80 | end 81 | local active_element = platform_list % last_selected_platform 82 | platform_list:setFocus(active_element) 83 | else 84 | scenes:pop() 85 | end 86 | end 87 | 88 | local function load_rom_buttons(src_platform, dest_platform) 89 | rom_list.children = {} -- Clear existing ROM items 90 | rom_list.height = 0 91 | 92 | -- Set label 93 | (menu ^ "roms_label").text = string.format("%s (%s)", src_platform, dest_platform) 94 | 95 | local rom_path, _ = user_config:get_paths() 96 | local platform_path = string.format("%s/%s", rom_path, src_platform) 97 | local roms = nativefs.getDirectoryItems(platform_path) 98 | 99 | -- pprint(dest_platform, artwork.cached_game_ids[dest_platform]) 100 | 101 | for _, rom in ipairs(roms) do 102 | local file_info = nativefs.getInfo(string.format("%s/%s", platform_path, rom)) 103 | if file_info and file_info.type == "file" then 104 | local is_cached = artwork.cached_game_ids[dest_platform] and artwork.cached_game_ids[dest_platform][rom] 105 | rom_list = rom_list + listitem { 106 | text = rom, 107 | width = ((w_width - 30) / 3) * 2, 108 | onClick = function() 109 | on_rom_press(rom) 110 | end, 111 | disabled = true, 112 | active = true, 113 | indicator = is_cached and 2 or 3 114 | } 115 | end 116 | end 117 | end 118 | 119 | local function load_platform_buttons() 120 | platform_list.children = {} -- Clear existing platforms 121 | platform_list.height = 0 122 | 123 | local platforms = user_config:get().platforms 124 | 125 | for src, dest in utils.orderedPairs(platforms or {}) do 126 | platform_list = platform_list + listitem { 127 | id = src, 128 | text = src, 129 | width = ((w_width - 30) / 3), 130 | onFocus = function() load_rom_buttons(src, dest) end, 131 | onClick = function() on_select_platform(src) end, 132 | disabled = false, 133 | } 134 | end 135 | end 136 | 137 | local function process_fetched_game() 138 | local t = channels.SKYSCRAPER_GAME_QUEUE:pop() 139 | if t then 140 | if t.skipped then 141 | dispatch_info("Error", "Unable to generate artwork for selected game [skipped]") 142 | return 143 | end 144 | dispatch_info("Fetched", "Game fetched. Generating artwork...") 145 | local rom_path, _ = user_config:get_paths() 146 | rom_path = string.format("%s/%s", rom_path, last_selected_platform) 147 | local artwork_name = artwork.get_artwork_name() 148 | skyscraper.update_artwork(rom_path, last_selected_rom, t.input_folder, t.platform, artwork_name) 149 | end 150 | end 151 | 152 | local function update_scrape_state() 153 | local t = channels.SKYSCRAPER_OUTPUT:pop() 154 | if t then 155 | if t.error and t.error ~= "" then 156 | dispatch_info("Error", t.error) 157 | end 158 | if t.title then 159 | dispatch_info("Finished", t.success and "Scraping finished successfully" or "Scraping failed or skipped") 160 | artwork.copy_to_catalogue(t.platform, t.title) 161 | artwork.process_cached_by_platform(t.platform) 162 | load_rom_buttons(last_selected_platform) 163 | rom_list:focusFirstElement() 164 | end 165 | end 166 | end 167 | 168 | function single_scrape:load() 169 | if #artwork.cached_game_ids == 0 then 170 | artwork.process_cached_data() 171 | end 172 | 173 | menu = component:root { column = true, gap = 0 } 174 | 175 | info_window = popup { visible = false } 176 | platform_list = component { column = true, gap = 0 } 177 | rom_list = component { column = true, gap = 0 } 178 | 179 | load_platform_buttons() 180 | 181 | local left_column = component { column = true, gap = 10 } 182 | + label { text = 'Platforms', icon = "folder" } 183 | + (scroll_container { 184 | width = (w_width - 30) / 3, 185 | height = w_height - 90, 186 | scroll_speed = 30, 187 | } 188 | + platform_list) 189 | 190 | local right_column = component { column = true, gap = 10 } 191 | + label { id = "roms_label", text = 'ROMs', icon = "cd" } 192 | + (scroll_container { 193 | width = ((w_width - 30) / 3) * 2, 194 | height = w_height - 90, 195 | scroll_speed = 30, 196 | } 197 | + rom_list) 198 | 199 | menu = menu 200 | + (component { row = true, gap = 10 } 201 | + left_column 202 | + right_column) 203 | 204 | menu:updatePosition(10, 10) 205 | menu:focusFirstElement() 206 | end 207 | 208 | function single_scrape:update(dt) 209 | menu:update(dt) 210 | update_scrape_state() 211 | process_fetched_game() 212 | end 213 | 214 | function single_scrape:draw() 215 | love.graphics.clear(theme:read_color("main", "BACKGROUND", "#000000")) 216 | menu:draw() 217 | info_window:draw() 218 | end 219 | 220 | function single_scrape:keypressed(key) 221 | menu:keypressed(key) 222 | if key == "escape" then on_return() end 223 | if key == "lalt" then scenes:push("settings") end 224 | end 225 | 226 | return single_scrape 227 | -------------------------------------------------------------------------------- /scenes/tools.lua: -------------------------------------------------------------------------------- 1 | local log = require("lib.log") 2 | local pprint = require("lib.pprint") 3 | local scenes = require("lib.scenes") 4 | local skyscraper = require("lib.skyscraper") 5 | local channels = require("lib.backend.channels") 6 | local configs = require("helpers.config") 7 | local artwork = require("helpers.artwork") 8 | local utils = require("helpers.utils") 9 | 10 | local component = require 'lib.gui.badr' 11 | local popup = require 'lib.gui.popup' 12 | local listitem = require 'lib.gui.listitem' 13 | local scroll_container = require 'lib.gui.scroll_container' 14 | local output_log = require 'lib.gui.output_log' 15 | 16 | local tools = {} 17 | local theme = configs.theme 18 | local scraper_opts = { "screenscraper", "thegamesdb" } 19 | local scraper_index = 1 20 | 21 | local w_width, w_height = love.window.getMode() 22 | 23 | local menu, info_window 24 | 25 | 26 | local user_config, skyscraper_config = configs.user_config, configs.skyscraper_config 27 | local finished_tasks = 0 28 | local command_output = "" 29 | 30 | local function dispatch_info(title, content) 31 | if title then info_window.title = title end 32 | if content then 33 | local scraping_log = info_window ^ "scraping_log" 34 | scraping_log.text = scraping_log.text .. "\n" .. content 35 | end 36 | info_window.visible = true 37 | end 38 | 39 | local function update_state() 40 | local t = channels.SKYSCRAPER_OUTPUT:pop() 41 | if t then 42 | -- if t.error and t.error ~= "" then 43 | -- dispatch_info("Error", t.error) 44 | -- end 45 | if t.data and next(t.data) then 46 | dispatch_info(string.format("Updating cache for %s, please wait...", t.data.platform)) 47 | end 48 | if t.success ~= nil then 49 | finished_tasks = finished_tasks + 1 50 | dispatch_info(nil, string.format("Finished %d games", finished_tasks)) 51 | end 52 | if t.command_finished then 53 | dispatch_info("Updated cache", "Cache has been updated.") 54 | finished_tasks = 0 55 | log.write("Cache updated successfully") 56 | artwork.process_cached_data() 57 | end 58 | end 59 | end 60 | 61 | local function update_task_state() 62 | local t = channels.TASK_OUTPUT:pop() 63 | if t then 64 | if t.error and t.error ~= "" then 65 | dispatch_info("Error", t.error) 66 | end 67 | if t.output and t.output ~= "" then 68 | command_output = command_output .. t.output .. "\n" 69 | local scraping_log = info_window ^ "scraping_log" 70 | scraping_log.text = command_output 71 | end 72 | if t.command_finished then 73 | if t.command == "backup" then 74 | dispatch_info("Backed up cache", 75 | "Cache has been backed up to SD2/ARCHIVE.\nYou can restore it using the muOS Archive Manager") 76 | log.write("Cache backed up successfully") 77 | elseif t.command == "migrate" then 78 | dispatch_info("Migrated cache", "Cache has been migrated to SD2.") 79 | skyscraper_config:insert("main", "cacheFolder", "\"/mnt/sdcard/scrappy_cache/\"") 80 | skyscraper_config:save() 81 | log.write("Cache migrated successfully") 82 | elseif t.command == "update_app" then 83 | dispatch_info("Updated Scrappy") 84 | end 85 | end 86 | end 87 | end 88 | 89 | local function on_refresh_press() 90 | user_config:load_platforms() 91 | user_config:save() 92 | dispatch_info("Refreshed platforms", "Platforms have been refreshed.") 93 | end 94 | 95 | local function on_update_press() 96 | log.write("Updating cache") 97 | local platforms = user_config:get().platforms 98 | local rom_path, _ = user_config:get_paths() 99 | 100 | dispatch_info("Updating cache", "Updating cache, please wait...") 101 | 102 | for src, dest in utils.orderedPairs(platforms or {}) do 103 | if dest ~= "unmapped" then 104 | local platform_path = string.format("%s/%s", rom_path, src) 105 | skyscraper.fetch_artwork(platform_path, src, dest) 106 | end 107 | end 108 | end 109 | 110 | local function on_import_press() 111 | log.write("Importing custom data") 112 | dispatch_info("Importing custom data", "Running import command...") 113 | local import_path = WORK_DIR .. "/static/.skyscraper/import" 114 | local lookup_folders = {} 115 | 116 | for _, item in ipairs(nativefs.getDirectoryItems(import_path) or {}) do 117 | local file_info = nativefs.getInfo(string.format("%s/%s", import_path, item)) 118 | if file_info and file_info.type == "directory" then 119 | table.insert(lookup_folders, item) 120 | end 121 | end 122 | 123 | if #lookup_folders == 0 then 124 | log.write("Import Error: No folders to import") 125 | dispatch_info("Error", "Error: no folders to import.") 126 | return 127 | end 128 | 129 | local platforms = user_config:get().platforms 130 | local rom_path, _ = user_config:get_paths() 131 | 132 | local any_match = false 133 | 134 | for _, folder in ipairs(lookup_folders) do 135 | for src, dest in utils.orderedPairs(platforms or {}) do 136 | if folder == dest then 137 | any_match = true 138 | local platform_path = string.format("%s/%s", rom_path, src) 139 | skyscraper.custom_import(platform_path, dest) 140 | end 141 | end 142 | end 143 | 144 | if not any_match then 145 | log.write("No matching platforms found") 146 | dispatch_info("Error", "Error: No matching platforms found.") 147 | return 148 | end 149 | end 150 | 151 | local function on_change_scraper() 152 | local index = scraper_index + 1 153 | if index > #scraper_opts then index = 1 end 154 | local item = menu ^ "scraper_module" 155 | 156 | skyscraper.module = scraper_opts[index] 157 | scraper_index = index 158 | item.text = "Change Skyscraper module (current: " .. scraper_opts[scraper_index] .. ")" 159 | end 160 | 161 | local function on_reset_configs() 162 | user_config:start_fresh() 163 | skyscraper_config:start_fresh() 164 | dispatch_info("Configs reset", "Configs have been reset.") 165 | end 166 | 167 | local function on_backup_cache() 168 | log.write("Backing up cache to ARCHIVE folder") 169 | dispatch_info("Backing up cache to SD2/ARCHIVE folder", "Please wait...") 170 | local thread = love.thread.newThread("lib/backend/task_backend.lua") 171 | thread:start("backup") 172 | end 173 | 174 | local function on_migrate_cache() 175 | log.write("Migrating cache to SD2") 176 | dispatch_info("Migrating cache to SD2", "Please wait...") 177 | local thread = love.thread.newThread("lib/backend/task_backend.lua") 178 | thread:start("migrate") 179 | end 180 | 181 | local function on_app_update() 182 | log.write("Updating Scrappy") 183 | dispatch_info("Updating Scrappy", "Please wait...") 184 | local thread = love.thread.newThread("lib/backend/task_backend.lua") 185 | thread:start("update_app") 186 | end 187 | 188 | function tools:load() 189 | menu = component:root { column = true, gap = 10 } 190 | info_window = popup { visible = false } 191 | local item_width = w_width - 20 192 | 193 | menu = menu 194 | + (scroll_container { 195 | width = w_width, 196 | height = w_height - 60, 197 | scroll_speed = 30, 198 | } 199 | + (component { column = true, gap = 10 } 200 | -- TODO: Implement auto update 201 | + listitem { 202 | text = "Update Scrappy", 203 | width = item_width, 204 | onClick = on_app_update, 205 | icon = "download" 206 | } 207 | + listitem { 208 | text = "Migrate cache to SD2", 209 | width = item_width, 210 | onClick = on_migrate_cache, 211 | icon = "sd_card" 212 | } 213 | + listitem { 214 | text = "Backup cache to SD2/ARCHIVE folder", 215 | width = item_width, 216 | onClick = on_backup_cache, 217 | icon = "sd_card" 218 | } 219 | + listitem { 220 | id = "scraper_module", 221 | text = "Change Skyscraper module (current: " .. scraper_opts[scraper_index] .. ")", 222 | width = item_width, 223 | onClick = on_change_scraper, 224 | icon = "canvas" 225 | } 226 | + listitem { 227 | text = "Update cache (uses threads, doesn't generate artwork)", 228 | width = item_width, 229 | onClick = on_update_press, 230 | icon = "sd_card" 231 | } 232 | + listitem { 233 | text = "Run custom import (adds custom data to cache, read Wiki!)", 234 | width = item_width, 235 | onClick = on_import_press, 236 | icon = "file_import" 237 | } 238 | + listitem { 239 | text = "Rescan ROMs folders (overwrites [platforms] config)", 240 | width = item_width, 241 | onClick = on_refresh_press, 242 | icon = "folder" 243 | } 244 | + listitem { 245 | text = "Reset configs (can't be undone!)", 246 | width = item_width, 247 | onClick = on_reset_configs, 248 | icon = "refresh" 249 | } 250 | ) 251 | ) 252 | 253 | info_window = info_window 254 | + ( 255 | component { column = true, gap = 15 } 256 | + output_log { 257 | visible = false, 258 | id = "scraping_log", 259 | width = info_window.width, 260 | height = w_height * 0.50, 261 | } 262 | ) 263 | 264 | menu:updatePosition(10, 10) 265 | menu:focusFirstElement() 266 | end 267 | 268 | function tools:update(dt) 269 | menu:update(dt) 270 | update_state() 271 | update_task_state() 272 | end 273 | 274 | function tools:draw() 275 | love.graphics.clear(theme:read_color("main", "BACKGROUND", "#000000")) 276 | menu:draw() 277 | info_window:draw() 278 | end 279 | 280 | function tools:keypressed(key) 281 | menu:keypressed(key) 282 | if key == "escape" then 283 | if info_window.visible then 284 | info_window.visible = false 285 | -- Clear logs 286 | command_output = "" 287 | local scraping_log = info_window ^ "scraping_log" 288 | scraping_log.text = "" 289 | else 290 | scenes:pop() 291 | end 292 | end 293 | end 294 | 295 | return tools 296 | -------------------------------------------------------------------------------- /scripts/update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Variables 4 | REPO_URL="https://api.github.com/repos/gabrielfvale/scrappy/releases/latest" 5 | TARGET_DIR="/mnt/mmc/MUOS/application" 6 | TEMP_DIR=$(mktemp -d) 7 | 8 | # Fetch the latest release information 9 | echo "Fetching latest release information..." 10 | RELEASE_DATA=$(curl -s "$REPO_URL") 11 | if [ $? -ne 0 ]; then 12 | echo "Error: Failed to fetch release data." 13 | exit 1 14 | fi 15 | 16 | # Extract the release tag 17 | TAG=$(echo "$RELEASE_DATA" | grep -oP '"tag_name": "\K[^"]+') 18 | if [ -z "$TAG" ]; then 19 | echo "Error: Failed to extract release tag." 20 | exit 1 21 | fi 22 | 23 | # Find the asset URL for "Scrappy_{tag}_update.muxapp" 24 | ASSET_URL=$(echo "$RELEASE_DATA" | grep -oP '"browser_download_url": "\K[^"]+Scrappy_'${TAG}'_update\.muxapp') 25 | if [ -z "$ASSET_URL" ]; then 26 | # If "Scrappy_{tag}_update.muxapp" is not found, look for any "Scrappy_{tag}*.muxapp" 27 | ASSET_URL=$(echo "$RELEASE_DATA" | grep -oP '"browser_download_url": "\K[^"]+Scrappy_'${TAG}'[^"]*\.muxapp') 28 | if [ -z "$ASSET_URL" ]; then 29 | echo "Error: No matching asset found for tag $TAG." 30 | exit 1 31 | fi 32 | fi 33 | 34 | # Download the asset 35 | echo "Downloading asset: $ASSET_URL" 36 | ASSET_NAME=$(basename "$ASSET_URL") 37 | curl -L -o "$TEMP_DIR/$ASSET_NAME" "$ASSET_URL" 38 | if [ $? -ne 0 ]; then 39 | echo "Error: Failed to download asset." 40 | exit 1 41 | fi 42 | 43 | # Unzip the asset into the target directory 44 | echo "Unzipping $ASSET_NAME to $TARGET_DIR..." 45 | unzip -o "$TEMP_DIR/$ASSET_NAME" -d "$TARGET_DIR" 46 | if [ $? -ne 0 ]; then 47 | echo "Error: Failed to unzip asset." 48 | exit 1 49 | fi 50 | 51 | # Clean up temporary files 52 | rm -rf "$TEMP_DIR" 53 | 54 | echo "Success: Scrappy updated to version $TAG." 55 | echo "Please restart the app to apply the update." 56 | exit 0 57 | -------------------------------------------------------------------------------- /skyscraper_config.ini.example: -------------------------------------------------------------------------------- 1 | [main] 2 | cacheFolder="" 3 | gameListFolder="" 4 | gameListBackup="false" 5 | artworkXml="" 6 | videos="false" 7 | verbosity="0" 8 | hints="false" 9 | unattend="true" 10 | regionPrios="us, wor, eu, jp, ss, uk, au, ame, de, cus, cn, kr, asi, br, sp, fr, gr, it, no, dk, nz, nl, pl, ru, se, tw, ca" 11 | subdirs="false" 12 | [screenscraper] 13 | userCreds="USER:PASS" 14 | -------------------------------------------------------------------------------- /static/.skyscraper/import/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/static/.skyscraper/import/.gitkeep -------------------------------------------------------------------------------- /static/.skyscraper/platforms_idmap.csv: -------------------------------------------------------------------------------- 1 | folder,screenscraper_id,mobygames_id,tgdb_id 2 | 3 | # Mapping between platform folder and 4 | # - screenscraper system id 5 | # - mobygames platform id 6 | # - thegamesdb platform id 7 | # 8 | # If you need more than one match use the alias property in platforms.json. 9 | # Use supplementary/scraperdata/peas_and_idmap_verify.py to view platform names 10 | # of the ids below. 11 | 12 | ### Begin RetroPie OOTB supported systems 13 | 14 | 3do,29,35,25 15 | ags,138,3,1 16 | amiga,64,19,4911 17 | amstradcpc,65,60,4914 18 | apple2,86,31,4942 19 | arcade,75,143,23 20 | arcadia,94,162,4963 21 | atari2600,26,28,22 22 | atari5200,40,33,26 23 | atari7800,41,34,27 24 | atari800,43,39,4943 25 | atarijaguar,27,17,28 26 | atarilynx,28,18,4924 27 | atarist,42,24,4937 28 | c64,66,27,40 29 | channelf,80,76,4928 30 | coco,144,62,4941 31 | coleco,48,29,31 32 | crvision,241,212,5005 33 | daphne,49,-1,-1 34 | dragon32,91,79,4952 35 | dreamcast,23,8,16 36 | fba,75,143,23 37 | fds,106,22,4936 38 | fm7,97,126,4978 39 | gameandwatch,52,-1,4950 40 | gamegear,21,25,20 41 | gb,9,10,4 42 | gba,12,12,5 43 | gbc,10,11,41 44 | gc,13,14,2 45 | intellivision,115,30,32 46 | love,-1,-1,-1 47 | macintosh,146,74,37 48 | mame,75,143,23 49 | mame-advmame,75,143,23 50 | mame-libretro,75,143,23 51 | mame-mame4all,75,143,23 52 | mastersystem,2,26,35 53 | megadrive,1,16,36 54 | moto,141,147,-1 55 | msx,113,57,4929 56 | n64,14,9,3 57 | nds,15,44,8 58 | neogeo,142,36,24 59 | nes,3,22,7 60 | ngp,25,52,4922 61 | ngpc,82,53,4923 62 | openbor,214,-1,-1 63 | oric,131,111,4986 64 | pc,135,2,1 65 | pc88,221,94,4933 66 | pc98,208,95,4934 67 | pcengine,31,40,34 68 | pcfx,72,-1,4930 69 | pico8,234,103,4958 70 | pokemini,211,152,4957 71 | ports,135,3,1 72 | ps2,58,7,11 73 | psp,61,46,13 74 | psx,57,6,10 75 | samcoupe,213,120,4979 76 | saturn,22,23,17 77 | scummvm,123,-1,-1 78 | sega32x,19,21,33 79 | segacd,20,20,21 80 | sg-1000,109,114,4949 81 | snes,4,15,6 82 | solarus,223,-1,-1 83 | steam,138,-1,-1 84 | stratagus,-1,-1,-1 85 | ti99,205,47,4953 86 | tic80,222,-1,-1 87 | trs-80,144,58,4941 88 | vectrex,102,37,4939 89 | videopac,104,128,4927 90 | virtualboy,11,38,4918 91 | wii,16,82,9 92 | wonderswan,45,48,4925 93 | wonderswancolor,46,49,4926 94 | x1,220,121,4977 95 | x68000,79,106,4931 96 | zmachine,215,169,-1 97 | zx81,77,119,5010 98 | zxspectrum,76,41,4913 99 | 100 | ### End of RetroPie OOTB supported systems 101 | 102 | 3ds,17,101,4912 103 | actionmax,81,-1,4976 104 | apple2gs,217,-1,-1 105 | arduboy,263,215,-1 106 | astrocade,44,160,4968 107 | atarijaguarcd,171,-1,29 108 | atomiswave,53,-1,23 109 | cd32,130,56,4947 110 | cdi,133,73,4917 111 | cdtv,129,83,-1 112 | easyrpg,231,-1,-1 113 | gamecom,121,50,4940 114 | gmaster,103,-1,-1 115 | j2me,-1,-1,5018 116 | lowresnx,244,-1,-1 117 | megaduck,90,-1,4948 118 | msx2,116,-1,-1 119 | n64dd,122,-1,-1 120 | naomi,56,-1,23 121 | neogeocd,70,54,4956 122 | palm,219,65,-1 123 | pcenginecd,114,45,4955 124 | plus4,99,115,5007 125 | ps3,59,81,12 126 | ps4,60,141,4919 127 | psvita,62,105,39 128 | pv1000,74,125,4964 129 | scv,67,138,4966 130 | supervision,207,109,4959 131 | switch,225,203,4971 132 | symbian,-1,67,-1 133 | uzebox,216,-1,-1 134 | vic20,73,43,4945 135 | vircon32,272,-1,-1 136 | vsmile,120,42,4988 137 | wasm4,262,-1,-1 138 | wiiu,18,132,38 139 | xbox,32,13,14 140 | xbox360,33,69,15 141 | PORTS,135,3,1 142 | -------------------------------------------------------------------------------- /static/.skyscraper/tgdb_genres.json: -------------------------------------------------------------------------------- 1 | { 2 | "1": "Action", 3 | "2": "Adventure", 4 | "20": "Board", 5 | "3": "Construction and Management Simulation", 6 | "21": "Education", 7 | "22": "Family", 8 | "10": "Fighting", 9 | "13": "Flight Simulator", 10 | "29": "GBA Video / PSP Video", 11 | "18": "Horror", 12 | "9": "Life Simulation", 13 | "14": "MMO", 14 | "17": "Music", 15 | "23": "Party", 16 | "15": "Platform", 17 | "24": "Productivity", 18 | "5": "Puzzle", 19 | "25": "Quiz", 20 | "7": "Racing", 21 | "4": "Role-Playing", 22 | "12": "Sandbox", 23 | "8": "Shooter", 24 | "11": "Sports", 25 | "16": "Stealth", 26 | "6": "Strategy", 27 | "28": "Unofficial", 28 | "26": "Utility", 29 | "19": "Vehicle Simulation", 30 | "27": "Virtual Console" 31 | } 32 | -------------------------------------------------------------------------------- /static/.skyscraper/tgdb_platforms.json: -------------------------------------------------------------------------------- 1 | { 2 | "25": "3DO", 3 | "4944": "Acorn Archimedes", 4 | "5014": "Acorn Atom", 5 | "4954": "Acorn Electron", 6 | "4976": "Action Max", 7 | "4911": "Amiga", 8 | "4947": "Amiga CD32", 9 | "4914": "Amstrad CPC", 10 | "4999": "Amstrad GX4000", 11 | "4916": "Android", 12 | "4969": "APF MP-1000", 13 | "4942": "Apple II", 14 | "5001": "Apple Pippin", 15 | "23": "Arcade", 16 | "22": "Atari 2600", 17 | "26": "Atari 5200", 18 | "27": "Atari 7800", 19 | "4943": "Atari 800", 20 | "28": "Atari Jaguar", 21 | "29": "Atari Jaguar CD", 22 | "4924": "Atari Lynx", 23 | "4937": "Atari ST", 24 | "30": "Atari XE", 25 | "4968": "Bally Astrocade", 26 | "4995": "Bandai TV Jack 5000", 27 | "4997": "BBC Bridge Companion", 28 | "5013": "BBC Micro", 29 | "4991": "Casio Loopy", 30 | "4964": "Casio PV-1000", 31 | "4970": "Coleco Telstar Arcade", 32 | "31": "Colecovision", 33 | "4946": "Commodore 128", 34 | "5006": "Commodore 16", 35 | "40": "Commodore 64", 36 | "5008": "Commodore PET", 37 | "5007": "Commodore Plus/4", 38 | "4945": "Commodore VIC-20", 39 | "5012": "Didj", 40 | "4952": "Dragon 32/64", 41 | "4963": "Emerson Arcadia 2001", 42 | "4974": "Entex Adventure Vision", 43 | "4973": "Entex Select-a-Game", 44 | "4965": "Epoch Cassette Vision", 45 | "4966": "Epoch Super Cassette Vision", 46 | "4985": "Evercade", 47 | "4928": "Fairchild Channel F", 48 | "4936": "Famicom Disk System", 49 | "4932": "FM Towns Marty", 50 | "4978": "Fujitsu FM-7", 51 | "4962": "Gakken Compact Vision", 52 | "5004": "Gamate", 53 | "4950": "Game & Watch", 54 | "5002": "Game Wave", 55 | "4940": "Game.com", 56 | "4992": "Gizmondo", 57 | "5015": "GP32", 58 | "4951": "Handheld Electronic Games (LCD)", 59 | "4987": "HyperScan", 60 | "32": "Intellivision", 61 | "4994": "Interton VC 4000", 62 | "4915": "iOS", 63 | "5018": "J2ME (Java Platform, Micro Edition)", 64 | "37": "Mac OS", 65 | "4961": "Magnavox Odyssey 1", 66 | "4927": "Magnavox Odyssey 2", 67 | "4989": "Mattel Aquarius", 68 | "4948": "Mega Duck", 69 | "14": "Microsoft Xbox", 70 | "15": "Microsoft Xbox 360", 71 | "4920": "Microsoft Xbox One", 72 | "4981": "Microsoft Xbox Series X", 73 | "4972": "Milton Bradley Microvision", 74 | "4929": "MSX", 75 | "4938": "N-Gage", 76 | "24": "Neo Geo", 77 | "4956": "Neo Geo CD", 78 | "4922": "Neo Geo Pocket", 79 | "4923": "Neo Geo Pocket Color", 80 | "4912": "Nintendo 3DS", 81 | "3": "Nintendo 64", 82 | "8": "Nintendo DS", 83 | "7": "Nintendo Entertainment System (NES)", 84 | "4": "Nintendo Game Boy", 85 | "5": "Nintendo Game Boy Advance", 86 | "41": "Nintendo Game Boy Color", 87 | "2": "Nintendo GameCube", 88 | "4957": "Nintendo Pokémon Mini", 89 | "4971": "Nintendo Switch", 90 | "4918": "Nintendo Virtual Boy", 91 | "9": "Nintendo Wii", 92 | "38": "Nintendo Wii U", 93 | "4935": "Nuon", 94 | "4990": "Oculus Quest", 95 | "4986": "Oric-1", 96 | "4921": "Ouya", 97 | "5003": "Palmtex Super Micro", 98 | "1": "PC", 99 | "4933": "PC-88", 100 | "4934": "PC-98", 101 | "4930": "PC-FX", 102 | "4917": "Philips CD-i", 103 | "4993": "Philips Tele-Spiel ES-2201", 104 | "4975": "Pioneer LaserActive", 105 | "5016": "Playdate", 106 | "5000": "Playdia", 107 | "4983": "R-Zone", 108 | "4967": "RCA Studio II", 109 | "4979": "SAM Coupé", 110 | "33": "Sega 32X", 111 | "21": "Sega CD", 112 | "16": "Sega Dreamcast", 113 | "20": "Sega Game Gear", 114 | "18": "Sega Genesis", 115 | "35": "Sega Master System", 116 | "36": "Sega Mega Drive", 117 | "4958": "Sega Pico", 118 | "17": "Sega Saturn", 119 | "4949": "SEGA SG-1000", 120 | "4977": "Sharp X1", 121 | "4931": "Sharp X68000", 122 | "4996": "SHG Black Point", 123 | "4913": "Sinclair ZX Spectrum", 124 | "5009": "Sinclair ZX80", 125 | "5010": "Sinclair ZX81", 126 | "10": "Sony Playstation", 127 | "11": "Sony Playstation 2", 128 | "12": "Sony Playstation 3", 129 | "4919": "Sony Playstation 4", 130 | "4980": "Sony Playstation 5", 131 | "13": "Sony Playstation Portable", 132 | "39": "Sony Playstation Vita", 133 | "5011": "Stadia", 134 | "6": "Super Nintendo (SNES)", 135 | "4982": "Tandy Visual Interactive System", 136 | "5017": "Tapwave Zodiac", 137 | "4953": "Texas Instruments TI-99/4A", 138 | "4960": "Tomy Tutor", 139 | "4941": "TRS-80 Color Computer", 140 | "34": "TurboGrafx 16", 141 | "4955": "TurboGrafx CD", 142 | "4988": "V.Smile", 143 | "4939": "Vectrex", 144 | "5005": "VTech CreatiVision", 145 | "4998": "VTech Socrates", 146 | "4959": "Watara Supervision", 147 | "4925": "WonderSwan", 148 | "4926": "WonderSwan Color", 149 | "4984": "Xavix Port" 150 | } 151 | -------------------------------------------------------------------------------- /templates/box2d-splash-preview.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /templates/box2d.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /templates/jdcross_brush_torn.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /templates/md9000-split.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /templates/outlined-wheel-box-texture.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /templates/resources/boxfront.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/templates/resources/boxfront.png -------------------------------------------------------------------------------- /templates/resources/boxside.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/templates/resources/boxside.png -------------------------------------------------------------------------------- /templates/resources/mask/3px_dither.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/templates/resources/mask/3px_dither.png -------------------------------------------------------------------------------- /templates/resources/mask/3px_dither_720.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/templates/resources/mask/3px_dither_720.png -------------------------------------------------------------------------------- /templates/resources/mask/BlackGradientShadow_bordered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/templates/resources/mask/BlackGradientShadow_bordered.png -------------------------------------------------------------------------------- /templates/resources/mask/brush_transparent_inverted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/templates/resources/mask/brush_transparent_inverted.png -------------------------------------------------------------------------------- /templates/resources/mask/md9000-split.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/templates/resources/mask/md9000-split.png -------------------------------------------------------------------------------- /templates/resources/scanlines1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/templates/resources/scanlines1.png -------------------------------------------------------------------------------- /templates/resources/scanlines2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabrielfvale/scrappy/357536fcba4b5a867294d471f3caaf8c2bdfb13b/templates/resources/scanlines2.png -------------------------------------------------------------------------------- /templates/retro-dither-logo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /templates/retro-dither-logo_720.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /templates/retro-dither.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /templates/three-mix.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /theme.ini: -------------------------------------------------------------------------------- 1 | [main] 2 | FONT=assets/ChakraPetch-Regular.ttf 3 | FONT_SIZE=20 4 | BACKGROUND=000000 5 | 6 | [label] 7 | LABEL_TEXT=dfe6e9 8 | 9 | [button] 10 | BUTTON_BACKGROUND=2d3436 11 | BUTTON_FOCUS=636e72 12 | BUTTON_TEXT=dfe6e9 13 | 14 | [select] 15 | SELECT_BACKGROUND=2d3436 16 | SELECT_FOCUS=636e72 17 | SELECT_TEXT=dfe6e9 18 | 19 | [checkbox] 20 | CHECKBOX_BACKGROUND=000000 21 | CHECKBOX_FOCUS=2d3436 22 | CHECKBOX_INDICATOR=dfe6e9 23 | CHECKBOX_INDICATOR_BG=636e72 24 | CHECKBOX_TEXT=dfe6e9 25 | 26 | [scroll] 27 | SCROLLBAR_WIDTH=6 28 | SCROLLBAR_COLOR=636e72 29 | 30 | [listitem] 31 | ITEM_HEIGHT=16 32 | ITEM_BACKGROUND=000000 33 | ITEM_FOCUS=2d3436 34 | ITEM_INDICATOR_DEFAULT=dfe6e9 35 | ITEM_INDICATOR_SUCCESS=2ecc71 36 | ITEM_INDICATOR_ERROR=e74c3c 37 | ITEM_TEXT=dfe6e9 38 | 39 | [progress] 40 | BAR_BACKGROUND=2d3436 41 | BAR_COLOR=ffffff 42 | BAR_BORDER=636e72 43 | 44 | [popup] 45 | POPUP_BACKGROUND=000000 46 | POPUP_OPACITY=0.75 47 | POPUP_BOX=2d3436 48 | --------------------------------------------------------------------------------