diff --git a/.github/workflows/build-mgba.yml b/.github/workflows/build-mgba.yml new file mode 100644 index 00000000..4d5568aa --- /dev/null +++ b/.github/workflows/build-mgba.yml @@ -0,0 +1,72 @@ +name: build-mgba + +# Builds libpkforge_mgba.so (the embedded mGBA core shim) for arm64-v8a and +# commits it into src/PKForge.App/Libraries/, so the app build picks it up and +# the emulator runs in-process (the option-A companion). Runs on every push that +# touches the shim, the script or this workflow, plus a weekly cron for freshness. +on: + push: + branches: ["**"] + paths: + - "tools/build-mgba/**" + - ".github/workflows/build-mgba.yml" + schedule: + - cron: "0 4 * * 1" # Mondays 04:00 UTC — keeps the mGBA tarball cache warm + workflow_dispatch: + +permissions: + contents: write # the build commits the .so back to the branch + +concurrency: + group: build-mgba-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # The commit at the end needs the full history of the branch. + fetch-depth: 0 + ref: ${{ github.head_ref || github.ref_name }} + + - name: Resolve the NDK + run: | + # ubuntu-latest ships the NDK preinstalled; ANDROID_NDK_HOME is set by the + # runner image. Verify it exists, and pin the version mGBA was built against. + test -n "$ANDROID_NDK_HOME" && test -d "$ANDROID_NDK_HOME" + echo "NDK at $ANDROID_NDK_HOME" + + - name: Build the shim + run: | + chmod +x tools/build-mgba/build.sh + NDK_HOME="$ANDROID_NDK_HOME" tools/build-mgba/build.sh + + - name: Verify the output + run: | + test -f src/PKForge.App/Libraries/arm64-v8a/libpkforge_mgba.so + # The library must target arm64 Android, not the runner's x86. + file src/PKForge.App/Libraries/arm64-v8a/libpkforge_mgba.so | grep -q "ARM aarch64" + + - name: Commit the .so + run: | + git config user.name "PKForge CI" + git config user.email "ci@pkforge.local" + git add src/PKForge.App/Libraries/arm64-v8a/libpkforge_mgba.so + if git diff --cached --quiet; then + echo "No change to commit." + exit 0 + fi + git commit -m "chore(ci): rebuild libpkforge_mgba.so [skip ci]" + git push + + - name: Upload the artifact + uses: actions/upload-artifact@v4 + with: + name: libpkforge_mgba-arm64-v8a + path: src/PKForge.App/Libraries/arm64-v8a/libpkforge_mgba.so + retention-days: 30 + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 9aad4270..ffdb65a6 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ coverage/ tools/**/out/ tools/**/bin/ tools/**/obj/ +tools/**/build/ /pkhex/ /PKSafe/ src/PKForge.App/Resources/Raw/sgdb.key diff --git a/README.md b/README.md index af7424f0..5a73a8ea 100644 --- a/README.md +++ b/README.md @@ -1,235 +1,119 @@

PKForge

-

A Pokémon save editor and bank for Android, with support for dual-screen handhelds.

+

Le hub Pokémon ultime pour consoles Android double écran — émulateurs, ROMs, trophées et companion, pensés pour l'AYN Thor.

- Discord - Download - Wiki - License + Discord + Download + Roadmap + License

--- -A Pokémon save editor and cross-generation Bank for Android, tuned for dual-screen -handhelds like the AYN Thor. -Built on [PKHeX.Core](https://github.com/kwsch/PKHeX) with the -[Auto Legality Mod](https://github.com/santacrab2/PKHeX-Plugins) built in, so -legalizing a Pokémon works fully offline, on device. +**PKForge** relie vos ROMs à vos émulateurs, lit vos sauvegardes pour en tirer trophées +et musée, et transforme le second écran de votre console en companion de jeu. Construit +sur [PKHeX.Core](https://github.com/kwsch/PKHeX) et l' +[Auto Legality Mod](https://github.com/santacrab2/PKHeX-Plugins), tout fonctionne +hors-ligne, sur la console elle-même.

- A box on the main screen, the Pokémon inspector on the second screen + Une boîte sur l'écran principal, l'inspecteur Pokémon sur le second écran

-Every screen, every emulator and every feature is explained with screenshots in the -**[PKForge wiki](https://github.com/sofianeelhor/PKForge/wiki)**. - -Not affiliated with Nintendo, Game Freak, or The Pokémon Company. - -> [!CAUTION] -> pkforgeapp.com is **not** an official PKForge site and is not run by the maintainer. Do not download anything from it. Only use releases from this repository. - -## Features - -- **Manage your Pokémon.** Open any save from a linked emulator or a single file and - take full control of your team and boxes. Edit stats, IVs, EVs, moves, nature, - ability, held item, Met/Origin, Tera type, Hyper Training, shininess, friendship, - Pokérus, and more, with a live legality check as you go. -- **One-tap legalize.** The Auto Legality Mod is built into the app, so legalizing a - Pokémon works fully offline, on device. -- **Move Pokémon between games.** Grab a Pokémon from one save and drop it into another, - across generations, from Gen 1 to Gen 9 and back. A preview shows every change and - the legality verdict before anything is written. -- **A Bank like Pokémon Home.** Store your whole collection in a cross-game Bank with - as many themed boxes as you need. Organize with the gamepad, keep living dexes, and pull - anything back out into any save whenever you need it. -- **Track your collection.** The Living Dex tracker follows all 1025 Pokémon across - every generation, including shiny variants, so you always know what you still need. -- **Living Dex Autopilot.** Builds a living dex from the Pokémon you already own across - all your games and the Bank. It moves Pokémon (never copies them), evolves trade - evolutions on the way, and writes nothing until you confirm. -- **Browse and inject events.** The full Mystery Gift database is bundled and works - offline. Browse Wonder Cards from old distributions and inject them straight into - your save. -- **Edit the bag.** The bag editor handles any pouch, any item, any quantity, with - presets and one-tap refills. -- **Edit the trainer.** Change your name, IDs, money, and gender on the trainer card, - and manage trainer profiles and records. -- **RNG tools.** Inspect PID, IVs, and nature, and reroll a nature while keeping - shininess. -- **Batch edit.** The organizer handles multi-select operations: copy, move, duplicate, - delete, release, and send to the Bank or Poképark in bulk. -- **Breed and hatch.** The egg factory and Day Care / Nursery tools generate eggs and - hatch Pokémon, including one egg of every species. -- **Complete the Pokédex.** Mark everything seen, generate a living dex, and track - what is missing. -- **Play with your Pokémon.** The Poképark is a living world where up to 24 of your - Pokémon live by their tempers, friendships and rivalries, act out scenes together and - have rare events. Feed them, play with them, take photos. The Nuzlocke report tracks - first encounters and dupes per route, and the encounter browser shows every way to - catch a species in each game. -- **Share your teams.** Import and export Showdown sets, and generate QR codes to move - a set between devices. -- **Game-specific editors.** Grand Underground, Poké Beans, Fashion, and more for each - game. -- **World & events.** Clock repair for Ruby / Sapphire / Emerald, roaming legendaries, - re-arming one-time encounters, Entralink and Dream World, Apricorn Box and Pokéwalker, - honey trees, event flags, and key item events such as the Eon Ticket or the GS Ball. -- **Organize boxes.** Box manager (move, lock, copy, rename, wallpapers), sort by dex, - level, IVs, type or age, and a legality check and audit of the whole save. -- **ROM hacks.** Pokémon Unbound, Radical Red, GS Chronicles, Luminescent Platinum and - Pokémon Compass are supported with their own data. Unknown hacks open read-only with a - warning. -- **HaX and Hardcore modes.** HaX lifts the legality limits. Hardcore turns data editing - off for challenge runs and RetroAchievements players, while moving Pokémon stays - possible. -- **Built for handhelds.** Fully playable with a gamepad or by touch. On dual-screen - devices the second screen shows the Pokémon inspector, game art, Pokédex entries or - the Poképark journal. -- **Keep your saves safe.** Every write is validated, then preceded by a restore point. - Only the Pokémon you touched may change, and risky saves open read-only. The 20 most - recent restore points are kept. - -## Screenshots +## ✨ Fonctionnalités + +### 🎮 Launcher & émulateurs +- **Bibliothèque de jeux** : les 37 jeux Pokémon (Gen 1 → 9) en cartes avec artwork, + label coloré possédée/absente, et scan automatique de l'arborescence ES-DE + (`/storage/emulated/0/ROMs/`) — dumps No-Intro, français et anglais reconnus. +- **Liaison console → émulateur** (style Cocoon) : un tap par console choisit + l'émulateur qui lance ses ROMs. RetroArch, melonDS, Azahar, Eden, Dolphin, DraStic, + Pizza Boy, Linkboy détectés automatiquement. +- **Play** : le jeu démarre directement dans le bon émulateur, sans passer par son menu. + +### 🏆 Trophées & progression +- **Trophées maison** (~30 par jeu) : paliers de dex, shinies, temps de jeu, + collection — débloqués par scan de vos sauvegardes, permanents. +- **Trophées manuels** : champion, boss, événements — confirmés par le joueur. +- **RetroAchievements** : connexion à votre compte, sets officiels Gen 1-5, + progression en cache hors-ligne. +- **Watcher automatique** : au retour d'un émulateur, les nouveaux trophées sont + détectés et annoncés par notification. + +### 🏛️ Musée & banque +- **Musée** : le Pokédex par jeu (vu / capturé / chromatique) lu depuis vos sauvegardes. +- **Banque** : stockage inter-générations, import/export `.pk`, éditeur complet, + autopilot living dex. +- **Écran secondaire** : trophées, boîtes et journal Poképark sur l'écran Thor. + +## 🗺️ Roadmap + +### ✅ Fait +- [x] Onglet Émulateurs + liaison par console (style Cocoon) +- [x] Bibliothèque de ROMs avec scan ES-DE et catalogue complet Gen 1-9 +- [x] Launcher Play (RetroArch, melonDS, Azahar, Eden + détection étendue) +- [x] **GB/GBC/GBA joués nativement dans l'app, uniquement** : cœur mGBA embarqué + (`libpkforge_mgba.so`, arm64), rendu SkiaSharp pixel-crisp à 59,7275 Hz, + audio 32768 Hz, sauvegardes `.sav` mappées. Les liaisons émulateur externes + pour GB/GBC/GBA sont retirées : ces jeux se lancent dans PKForge, point. +- [x] **Companion live (modèle PokéDaisy)** : pendant qu'un jeu GBA tourne dans + l'app, le second écran lit la mémoire de la console en direct — dex, équipe, + temps de jeu — rafraîchi chaque seconde +- [x] Trophées maison par scan de sauvegardes + trophées manuels +- [x] RetroAchievements (Gen 1-5) avec cache hors-ligne +- [x] Watcher automatique + notifications de déblocage +- [x] Écran secondaire : ladder de trophées pendant la page Trophées +- [x] Interface française par défaut (anglais disponible) + +### 🚧 En cours +- [ ] **Companion de jeu externe** : le second écran reste vivant pendant l'émulation + externe (émulateurs tiers) — stats, Pokédex, points de restauration +- [ ] **Intégration des mods** : Radical Red, Unbound, GS Chronicles… détectés et + lancés depuis l'app avec leurs réglages dédiés +- [ ] Traduction française complète des surfaces restantes (box browser, éditeurs) + +### 🔮 Planifié +- [ ] Cœur DS embarqué (melonDS) pour les jeux DS natifs +- [ ] Musée (grille Pokédex) sur l'écran secondaire +- [ ] Watcher en arrière-plan (WorkManager) — scans sans l'app ouverte +- [ ] Mises à jour in-app des définitions de trophées et du catalogue +- [ ] Social : partage de progression, classements entre amis +- [ ] Hardcore mode RetroAchievements complet + +## 📥 Téléchargement + +Les APK signés sont publiés dans [Releases](../../releases). Le build +diagnostic (sideload) s'installe à côté de la version Play sans la toucher. -

- Home - Editor -

- -

- Bank - Living Dex -

- -

- Mystery Gift - Bag -

- -

- RNG tools -

- -

- Trainer card - Showdown QR -

- -

- Nuzlocke report - Encounter browser -

- -

- Generate a Pokémon - Transfer preview -

- -

- Living Dex Autopilot - Linking an emulator -

- -## Supported games - -PKForge reads and edits saves from every mainline generation, plus the GameCube side -games and popular romhacks: - -- **Generation I:** Red, Blue, Green (JP), Yellow -- **Generation II:** Gold, Silver, Crystal -- **Generation III:** Ruby, Sapphire, Emerald, FireRed, LeafGreen, Pokémon Box: Ruby & Sapphire, Colosseum, XD: Gale of Darkness -- **Generation IV:** Diamond, Pearl, Platinum, HeartGold, SoulSilver -- **Generation V:** Black, White, Black 2, White 2 -- **Generation VI:** X, Y, Omega Ruby, Alpha Sapphire -- **Generation VII:** Sun, Moon, Ultra Sun, Ultra Moon, Let's Go Pikachu, Let's Go Eevee -- **Generation VIII:** Sword, Shield, Brilliant Diamond, Shining Pearl, Legends: Arceus -- **Generation IX:** Scarlet, Violet -- **Romhacks:** Pokémon Unbound, Radical Red, GS Chronicles, Luminescent Platinum, Pokémon Compass - -## Supported emulators - -Link a storage unit and PKForge finds your saves automatically: - -- **Game Boy / Game Boy Color:** RetroArch, Linkboy, Pizza Boy C -- **Game Boy Advance:** RetroArch, Linkboy, Pizza Boy A -- **Nintendo DS:** melonDS, DraStic, RetroArch -- **GameCube:** Dolphin -- **Nintendo 3DS:** Azahar, Lime3DS, Citra MMJ -- **Nintendo Switch:** Eden - -You can also open a single save file directly. - -## Installation - -Download the APK from [Releases](https://github.com/sofianeelhor/PKForge/releases) and -allow installs from unknown sources. First run walks you through linking an emulator or -opening a single save file. - -New to PKForge? Start with [Getting Started](https://github.com/sofianeelhor/PKForge/wiki/Getting-Started). -If PKForge finds no saves, the folder picked is usually the wrong one: see -[Choosing the right folder](https://github.com/sofianeelhor/PKForge/wiki/Choosing-the-Right-Folder). - -## 💬 Discord - -Join for updates, support, bug reports, feature requests, or just to chat about the project. - -👉 **[Join the PKForge Discord](https://discord.gg/bMtzZmTDfu)** - -[![Discord](https://discordapp.com/api/guilds/1542192456018427926/widget.png?style=banner3&time-)](https://discord.gg/XkeD2vKJCZ) - -## Building +```bash +dotnet build src/PKForge.App/PKForge.App.csproj -f net10.0-android -p:DiagnosticBuild=true +``` -.NET 10 SDK with the `maui-android` workload, Android SDK (API 36). +## 🛠️ Compilation ```bash -git submodule update --init --recursive -dotnet test tests/PKForge.Domain.Tests/PKForge.Domain.Tests.csproj -dotnet test tests/PKForge.Engine.Tests/PKForge.Engine.Tests.csproj +git clone --recurse-submodules https://github.com/Varonal/PKForge +cd PKForge +dotnet restore PKForge.sln +dotnet test tests/PKForge.Domain.Tests --configuration Release dotnet build src/PKForge.App/PKForge.App.csproj -f net10.0-android ``` -Version tags build and publish the APK from CI. +- **Domain** : règles pures (trophées, parsing des ROMs, catalogues) — testées xunit +- **Infrastructure** : persistance JSON atomique +- **App** : .NET MAUI 10 (net10.0-android), MVVM, double écran -### Layout +## 📄 Licence -``` -src/PKForge.Domain contracts and DTOs, no engine or Android dependencies -src/PKForge.Engine adapters over pinned PKHeX.Core -src/PKForge.AutoMod compiles the Auto Legality Mod against our Core -src/PKForge.Infrastructure bank, backups, atomic save writer -src/PKForge.App MAUI app, SkiaSharp UI, gamepad and second screen -src/PKForge.Chrome the design system: tokens and painters, pure Skia -tools/ChromePreview renders the design system off-device -docs/ architecture, bank model, development, art direction -``` +GPLv3 — voir [LICENSE](LICENSE). Basé sur +[PKForge](https://github.com/sofianeelhor/PKForge) et +[PKHeX](https://github.com/kwsch/PKHeX). -## Credits - -- [PKHeX](https://github.com/kwsch/PKHeX) by Kaphotics and every contributor over the - years, the engine everything runs on -- [spritedmistery](https://www.instagram.com/spritedmistery/), the PKForge logo, the app's - look, its color schemes and logos, its buttons, and the Poképark's map -- AlchemyBats, the tileset the Poképark's map is built on (tiles by AlchemyBats) -- [PKHeX-Plugins / Auto Legality Mod](https://github.com/santacrab2/PKHeX-Plugins), the - offline legalizer built into the app -- [PKSM](https://github.com/FlagBrew/PKSM), the pixel chrome this UI builds on (GPL-3, - see [UI attribution](src/PKForge.App/Resources/UI/ATTRIBUTION.md)) -- [SteamGridDB](https://www.steamgriddb.com), cartridge icons, second-screen logos, and - hero banners for the game library (community submissions; see - [game art attribution](src/PKForge.App/Resources/GameArt/ATTRIBUTION.md)) -- [Pokémon Showdown](https://pokemonshowdown.com) by [Smogon](https://www.smogon.com) and - the artists of its sprite projects, front sprites and box icons -- [PMDCollab SpriteCollab](https://sprites.pmdcollab.org) and its artists, the Poképark's - animated sprites and portraits (CC BY-NC 4.0), fetched at runtime and credited by name in - the park -- [PokeAPI](https://pokeapi.co), item art fetched at runtime and cached on device, and the - evolution data of the Poképark (BSD-3) -- [game-icons.net](https://game-icons.net) (CC-BY 3.0, © Lorc, Delapouite, Guard13007, - Carl Olsen and other contributing artists), UI symbols -- [Bulbagarden Archives](https://archives.bulbagarden.net), Pokérus status sprites -- Sprites and Pokémon names © Nintendo, Creatures Inc., GAME FREAK inc. - -## License - -GPLv3 or later, inherited from PKHeX.Core. See [LICENSE](LICENSE). +Non affilié à Nintendo, Game Freak ou The Pokémon Company. + +--- + +

+ Built with ❤️ for dual-screen handhelds +

diff --git a/src/PKForge.App/Libraries/arm64-v8a/libpkforge_mgba.so b/src/PKForge.App/Libraries/arm64-v8a/libpkforge_mgba.so new file mode 100755 index 00000000..f6221c0a Binary files /dev/null and b/src/PKForge.App/Libraries/arm64-v8a/libpkforge_mgba.so differ diff --git a/src/PKForge.App/MauiProgram.cs b/src/PKForge.App/MauiProgram.cs index 40213c23..c1fffa7f 100644 --- a/src/PKForge.App/MauiProgram.cs +++ b/src/PKForge.App/MauiProgram.cs @@ -20,6 +20,8 @@ public static MauiApp CreateMauiApp() App.Trace("CreateMauiApp enter"); #if ANDROID Views.CapsuleSkin.Register(); + // Probe the embedded mGBA shim once: GB/GBC/GBA play in-app only when it is packed. + Domain.EmulatorCatalog.EmbeddedCoreAvailable = Platforms.Android.EmbeddedGameSession.IsAvailable; #endif builder.UseMauiApp().UseSkiaSharp() .ConfigureFonts(fonts => @@ -87,6 +89,23 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(new Infrastructure.JsonRomLinkStore( + Path.Combine(FileSystem.AppDataDirectory, "rom-links.json"))); + builder.Services.AddSingleton(new Infrastructure.JsonRetroProgressStore( + Path.Combine(FileSystem.AppDataDirectory, "retro-progress.json"))); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(new Infrastructure.JsonTrophyStore( + Path.Combine(FileSystem.AppDataDirectory, "trophies.json"))); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(new Infrastructure.JsonConsoleEmulatorStore( + Path.Combine(FileSystem.AppDataDirectory, "console-emulators.json"))); + builder.Services.AddSingleton(new Infrastructure.JsonRomLibraryStore( + Path.Combine(FileSystem.AppDataDirectory, "rom-library.json"))); #if ANDROID builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -96,6 +115,9 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); #endif builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -107,6 +129,10 @@ public static MauiApp CreateMauiApp() builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); App.Trace("builder.Build()"); return builder.Build(); } diff --git a/src/PKForge.App/PKForge.App.csproj b/src/PKForge.App/PKForge.App.csproj index 111ded40..51a8a054 100644 --- a/src/PKForge.App/PKForge.App.csproj +++ b/src/PKForge.App/PKForge.App.csproj @@ -13,6 +13,8 @@ 311 3.1.1 android-arm64 + + true @@ -82,6 +84,11 @@ + + + diff --git a/src/PKForge.App/Platforms/Android/AndroidEmulatorLauncher.cs b/src/PKForge.App/Platforms/Android/AndroidEmulatorLauncher.cs new file mode 100644 index 00000000..d268d921 --- /dev/null +++ b/src/PKForge.App/Platforms/Android/AndroidEmulatorLauncher.cs @@ -0,0 +1,225 @@ +using Android.App; +using Android.Content; +using Android.Content.PM; +using AndroidUri = Android.Net.Uri; +using PKForge.App.ViewModels; +using PKForge.Domain; + +namespace PKForge.App; + +/// +/// Hands a linked ROM to its emulator, PlayHub-style: package-scoped intents, never +/// explicit components (an explicit component that does not match the installed build +/// throws SecurityException and the launch dies silently). RetroArch gets a MAIN intent +/// scoped to its package with the ROM as data plus its content-loading extras; every +/// standalone emulator gets an ACTION_VIEW scoped to its package with the ROM URI and +/// a MIME type, resolved first — when nothing resolves, the emulator's own library +/// opens with a clear message instead of a black screen. +/// +public sealed class AndroidEmulatorLauncher : IEmulatorLauncher +{ + public bool IsInstalled(string packageId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packageId); + var manager = Platform.AppContext?.PackageManager + ?? throw new InvalidOperationException("PackageManager is unavailable."); + try + { + manager.GetPackageInfo(packageId, 0); + return true; + } + catch (PackageManager.NameNotFoundException) + { + return false; + } + } + + public async Task LaunchAsync(RomLink link, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(link); + var app = EmulatorCatalog.App(link.Emulator); + var package = app.PackageIds.FirstOrDefault(IsInstalled) ?? app.PrimaryPackage; + if (!IsInstalled(package)) + { + Services.AppLog.Info("launch", $"{SaveDescriptions.EmulatorName(link.Emulator)} is not installed ({package})."); + return false; + } + + // One URI per launch: content ids pass through, plain paths become file://. + var rom = ResolveUri(link.RomUri); + if (rom is null) + { + Services.AppLog.Info("launch", $"The linked ROM cannot be read: {link.RomUri}"); + return false; + } + + var activity = Platform.CurrentActivity ?? throw new InvalidOperationException("No foreground Android activity is available."); + var context = activity.ApplicationContext ?? (Android.Content.Context)activity; + var intent = BuildIntent(app.LaunchStyle, package, rom, link); + if (intent is null) return await OpenAppAsync(link.Emulator, cancellationToken).ConfigureAwait(true); + + // Nothing in the target package answers this intent: open its library instead + // of a dead screen, and say so. + if (intent.ResolveActivity(context.PackageManager!) is null) + { + Services.AppLog.Info("launch", $"{package} has no activity for the ROM handoff; opening its library."); + return await OpenAppAsync(link.Emulator, cancellationToken).ConfigureAwait(true); + } + return StartActivity(activity, intent, link); + } + + public Task OpenAppAsync(EmulatorKind kind, CancellationToken cancellationToken = default) + { + var activity = Platform.CurrentActivity ?? throw new InvalidOperationException("No foreground Android activity is available."); + var app = EmulatorCatalog.App(kind); + var manager = activity.PackageManager!; + var opened = false; + foreach (var id in app.PackageIds.Where(IsInstalled)) + { + try + { + var launch = manager.GetLaunchIntentForPackage(id); + if (launch is null) continue; + launch.AddFlags(ActivityFlags.NewTask); + activity.StartActivity(launch); + opened = true; + break; + } + catch (ActivityNotFoundException) { } + catch (Java.Lang.SecurityException) { } + } + if (!opened) + Services.AppLog.Info("launch", $"Could not open {SaveDescriptions.EmulatorName(kind)}: no launchable activity."); + return Task.FromResult(opened); + } + + /// + /// content:// ids pass through; plain file paths become FileProvider content:// + /// URIs — the only URI kind Android can grant another app read access to + /// (FLAG_GRANT_READ_URI_PERMISSION does nothing on file:// URIs, which is why the + /// emulators opened but could not load the game). + /// + private static AndroidUri? ResolveUri(string romUri) + { + if (romUri.StartsWith("content://", StringComparison.Ordinal)) + return AndroidUri.Parse(romUri); + try + { + if (!File.Exists(romUri)) return null; + return AndroidX.Core.Content.FileProvider.GetUriForFile( + Platform.AppContext!, "org.pkforge.app.romprovider", new Java.IO.File(romUri)); + } + catch (Exception) + { + return null; + } + } + + /// The package-scoped intent per launch style. No explicit components: + /// the emulator's own manifest decides which activity answers. + private static Intent? BuildIntent(RomLaunchStyle style, string package, AndroidUri rom, RomLink link) => style switch + { + RomLaunchStyle.RetroArch => RetroArchIntent(package, rom, link), + RomLaunchStyle.PathExtra => PathExtraIntent(package, rom, link), + RomLaunchStyle.UriData => ViewIntent(package, rom), + _ => null, + }; + + /// + /// RetroArch's content-loading contract: a MAIN intent scoped to its package with + /// the ROM as data and MIME, plus the EXTRA_CONTENT / RETROARCH_LOAD_CONTENT_PATH + /// pair and a core path hint. Package scoping (never a component) is what frontends + /// like Pegasus use; the extras make RetroArch load the game directly. + /// + private static Intent RetroArchIntent(string package, AndroidUri rom, RomLink link) + { + // The libretro CLI contract, exactly: "Only ROM and LIBRETRO are needed". + // A plain ROM path in the ROM extra; no core hint (RetroArch pairs the core + // from its own list — a wrong core path black-screens), no config file. + var romPath = RomPathFor(link); + var intent = new Intent(); + intent.SetPackage(package); + intent.SetAction(Intent.ActionMain); + intent.AddCategory(Intent.CategoryLauncher); + intent.PutExtra("ROM", romPath); + // LIBRETRO only when the generation's core is named: a null extra would make + // RetroArch load nothing, so unknowns let RetroArch pair the core itself. + var core = CorePathHint(link.Generation, package); + if (core is not null) intent.PutExtra("LIBRETRO", core); + return intent; + } + + /// + /// melonDS-android's verified frontend configuration (the Beacon/Pegasus setups): + /// ACTION_VIEW with the explicit EmulatorActivity component, the ROM as data, a + /// "uri" string extra with the URI, and a "PATH" string extra with the plain path — + /// all four together. The ROM must appear in melonDS's own library first (its + /// scanner); without it, melonDS opens but cannot load the game. + /// + private static Intent PathExtraIntent(string package, AndroidUri rom, RomLink link) + { + var intent = new Intent(Intent.ActionView); + intent.SetComponent(new ComponentName(package, $"{package}.ui.emulator.EmulatorActivity")); + intent.SetDataAndType(rom, "application/octet-stream"); + intent.PutExtra("uri", rom.ToString()); + intent.PutExtra("PATH", RomPathFor(link)); + intent.AddFlags(ActivityFlags.GrantReadUriPermission); + return intent; + } + + /// The generic handoff: ACTION_VIEW scoped to the package, ROM as data. + /// The yuzu family (Eden, Sudachi) and the Citra family (Azahar) load the game + /// from the data URI through their game-file viewer; when the build prefers its + /// own library screen, the resolver decides and the game opens from there. + private static Intent ViewIntent(string package, AndroidUri rom) + { + var intent = new Intent(Intent.ActionView); + intent.SetPackage(package); + intent.SetDataAndType(rom, "application/octet-stream"); + return intent; + } + + /// SAF ids are file://-shaped on primary storage; emulators only accept + /// plain paths, so the id is handed over as-is. + private static string RomPathFor(RomLink link) => + link.RomUri.StartsWith("file://", StringComparison.Ordinal) + ? link.RomUri["file://".Length..] + : link.RomUri; + + /// The core RetroArch pairs with the ROM's generation, inside its own + /// package directory; null for DS and unknowns, where RetroArch pairs on its own. + private static string? CorePathHint(int generation, string package) + { + var coreFile = generation switch + { + 1 or 2 => "gambatte_libretro_android.so", + 3 => "mgba_libretro_android.so", + _ => null, + }; + return coreFile is null ? null : $"/data/data/{package}/cores/{coreFile}"; + } + + /// Starts the intent; false when no activity accepted it or the ROM grant failed. + private static bool StartActivity(Activity activity, Intent intent, RomLink link) + { + bool started; + try + { + intent.AddFlags(ActivityFlags.GrantReadUriPermission | ActivityFlags.NewTask); + activity.StartActivity(intent); + started = true; + } + catch (ActivityNotFoundException) + { + started = false; + } + catch (Java.Lang.SecurityException) + { + started = false; + } + Services.AppLog.Info("launch", started + ? $"Launched {link.RomFileName} in {SaveDescriptions.EmulatorName(link.Emulator)}." + : $"No activity accepted the launch of {link.RomFileName} ({link.Emulator})."); + return started; + } +} diff --git a/src/PKForge.App/Platforms/Android/AndroidManifest.xml b/src/PKForge.App/Platforms/Android/AndroidManifest.xml index d0167391..aafae206 100644 --- a/src/PKForge.App/Platforms/Android/AndroidManifest.xml +++ b/src/PKForge.App/Platforms/Android/AndroidManifest.xml @@ -1,5 +1,39 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PKForge.App/Platforms/Android/AndroidRomScanner.cs b/src/PKForge.App/Platforms/Android/AndroidRomScanner.cs new file mode 100644 index 00000000..9154d05f --- /dev/null +++ b/src/PKForge.App/Platforms/Android/AndroidRomScanner.cs @@ -0,0 +1,102 @@ +using Android.OS; +using PKForge.Domain; + +namespace PKForge.App; + +/// +/// Scans the ES-DE ROM tree with direct filesystem access: /storage/emulated/0/ROMs by +/// default, the layout retrogamecorps/ES-DE-Directories documents (roms/gameboy, +/// roms/gameboyadvance, roms/nds, roms/switch, ...). Emulators on Android launch by plain +/// path, so the library stores paths, not SAF ids. Requires "All files access" +/// (MANAGE_EXTERNAL_STORAGE); without it the scan reports an empty root and the UI says so. +/// +public sealed class AndroidRomScanner : IRomScanner +{ + private static readonly string[] Roots = + [ + "/storage/emulated/0/ROMs", + "/storage/emulated/0/Roms", + "/storage/emulated/0/roms", + "/storage/emulated/0/EasyRoms", + ]; + + private string? _root; + + public string? RomRoot + { + get + { + if (_root is not null) return _root; + foreach (var candidate in Roots) + { + try + { + if (Directory.Exists(candidate)) + { + _root = candidate; + return _root; + } + } + catch (Exception) { /* an unreadable root is the same as an absent one */ } + } + return null; + } + } + + public IReadOnlyList Scan(GameConsole console, CancellationToken cancellationToken = default) + { + var results = new List(); + var root = RomRoot; + if (root is null) return results; + var extensions = ConsoleRules.RomExtensions(console).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var folder in ConsoleFolders(console, root)) + { + string[] files; + try + { + files = Directory.GetFiles(folder); + } + catch (Exception) + { + continue; // an unreadable folder simply holds no games for us + } + foreach (var file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + var name = Path.GetFileName(file); + if (!RomNameRules.LooksLikePokemon(name)) continue; + var extension = Path.GetExtension(name); + if (string.IsNullOrEmpty(extension) || !extensions.Contains(extension)) continue; + DateTimeOffset? modified = null; + try { modified = File.GetLastWriteTimeUtc(file); } catch (Exception) { } + results.Add(new LibraryRom(file, name, console, Path.GetFileName(folder), RomNameRules.GameName(name), modified)); + } + } + return results; + } + + /// Every folder that may hold this console's ROMs in the ES-DE layout. + private static IEnumerable ConsoleFolders(GameConsole console, string root) + { + // ES-DE's canonical folders per system; both the plain name and the roms/ nesting + // are accepted, because setups differ between frontends. + string[] names = console switch + { + GameConsole.GameBoy => ["gameboy", "gb"], + GameConsole.GameBoyColor => ["gameboycolor", "gbc", "gameboy"], + GameConsole.GameBoyAdvance => ["gameboyadvance", "gba"], + GameConsole.NintendoDs => ["nds", "ds"], + GameConsole.Nintendo3Ds => ["3ds", "n3ds", "3dscloud"], + GameConsole.GameCube => ["gamecube", "gc"], + GameConsole.NintendoSwitch => ["switch"], + _ => [], + }; + foreach (var name in names) + { + var nested = Path.Combine(root, name); + if (Directory.Exists(nested)) yield return nested; + var flat = Path.Combine(Path.GetDirectoryName(root) ?? root, name); + if (Directory.Exists(flat) && !string.Equals(flat, nested, StringComparison.OrdinalIgnoreCase)) yield return flat; + } + } +} diff --git a/src/PKForge.App/Platforms/Android/AndroidTrophyNotifier.cs b/src/PKForge.App/Platforms/Android/AndroidTrophyNotifier.cs new file mode 100644 index 00000000..37ef3911 --- /dev/null +++ b/src/PKForge.App/Platforms/Android/AndroidTrophyNotifier.cs @@ -0,0 +1,67 @@ +using Android.App; +using AndroidX.Core.App; +using PKForge.App.Services; +using PKForge.Domain; + +namespace PKForge.App; + +/// +/// The Android trophy notification: one channel, one notification per scan announcing +/// every newly unlocked trophy (one line each, up to three, then a count). Requires the +/// POST_NOTIFICATIONS runtime permission on Android 13+, requested by the Trophies page. +/// +public sealed class AndroidTrophyNotifier : Java.Lang.Object, Services.ITrophyNotifier +{ + private const string ChannelId = "pkforge_trophies"; + private const int NotificationId = 4110; + + public Task NotifyAsync(IReadOnlyList unlocked) + { + ArgumentNullException.ThrowIfNull(unlocked); + if (unlocked.Count == 0) return Task.CompletedTask; + var context = Platform.AppContext + ?? throw new InvalidOperationException("No Android application context is available."); + EnsureChannel(context); + + var manager = NotificationManagerCompat.From(context) + ?? throw new InvalidOperationException("NotificationManagerCompat is unavailable."); + if (!manager.AreNotificationsEnabled()) return Task.CompletedTask; + + var first = unlocked[0]; + var summary = unlocked.Count == 1 + ? $"{first.Glyph} {first.Title}" + : $"{unlocked.Count} trophies unlocked, starting with {first.Title}"; + var detail = string.Join("\n", unlocked.Take(3).Select(t => $"{t.Glyph} {t.Title} · {t.GameLabel}")); + + // The AndroidX binding annotates every setter's return as nullable, so the + // notification is built statement by statement instead of as a fluent chain. + var builder = new NotificationCompat.Builder(context, ChannelId)!; + builder.SetSmallIcon(Resource.Drawable.trophy_notification); + builder.SetContentTitle("PKForge trophies"); + builder.SetContentText(summary); + builder.SetStyle(new NotificationCompat.BigTextStyle()!.BigText(detail)); + builder.SetAutoCancel(true); + + try + { + manager.Notify(NotificationId, builder.Build()); + } + catch (Exception error) + { + Services.AppLog.Info("trophies", $"The trophy notification could not be shown: {error.Message}"); + } + return Task.CompletedTask; + } + + private static void EnsureChannel(Android.Content.Context context) + { + if (!OperatingSystem.IsAndroidVersionAtLeast(26)) return; + var manager = (NotificationManager)context.GetSystemService(Android.Content.Context.NotificationService)!; + if (manager.GetNotificationChannel(ChannelId) is not null) return; + var channel = new NotificationChannel(ChannelId, "Trophies", NotificationImportance.Default) + { + Description = "Homemade trophy unlocks, detected from save scans.", + }; + manager.CreateNotificationChannel(channel); + } +} diff --git a/src/PKForge.App/Platforms/Android/EmbeddedEngine.cs b/src/PKForge.App/Platforms/Android/EmbeddedEngine.cs new file mode 100644 index 00000000..c978ca55 --- /dev/null +++ b/src/PKForge.App/Platforms/Android/EmbeddedEngine.cs @@ -0,0 +1,179 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Android.Media; +using SkiaSharp; + +namespace PKForge.App.Platforms.Android; + +/// +/// Drives an on its own thread at the GBA's exact frame +/// rate (59.7275 Hz — the hardware's, not 60), and hands each finished frame to +/// the UI as a ready-to-draw SKBitmap. The UI thread never touches the core; +/// the engine never touches the UI except through the frame event. +/// +internal sealed class EmbeddedEngine : IDisposable +{ + // The GBA's PPU emits one frame every 123 us × 912 = 280896 cycles at + // 16777216 Hz → 59.7275 frames per second. Rounding to 60 loses sync with + // the audio clock over minutes (audible drift on long sessions). + private const double FramesPerSecond = 59.727468; + + private readonly MgbaCore _core; + private readonly Thread _thread; + private readonly CancellationTokenSource _cancel = new(); + private readonly object _frameGate = new(); + private SKBitmap? _frame; + private SKImage? _frameImage; + + // The GBA's audio: 32768 Hz stereo, ~548 samples per frame. + private const int AudioFramesPerDrain = 1024; + private readonly short[] _audioScratch = new short[AudioFramesPerDrain * 2]; + private readonly Java.Nio.ByteBuffer _audioBuffer; + private readonly AudioTrack? _track; + private volatile uint _keysPending; + private readonly object _keyGate = new(); + + /// Raised on the emu thread after each frame; the UI marshals itself. + public event Action? FrameProduced; + + public bool IsRunning => _thread.IsAlive; + + public EmbeddedEngine(MgbaCore core) + { + _core = core ?? throw new ArgumentNullException(nameof(core)); + if (!core.IsRunning) throw new ArgumentException("The core has no ROM loaded.", nameof(core)); + _thread = new Thread(RunLoop) { IsBackground = true, Name = "PKForge emu" }; + _audioBuffer = Java.Nio.ByteBuffer.AllocateDirect(_audioScratch.Length * sizeof(short)); + _audioBuffer.Order(Java.Nio.ByteOrder.LittleEndian!); + try + { + // 32768 Hz is the GBA's own rate. The buffer holds ~116 ms of audio + // (3840 frames × 4 bytes): responsive, and one GC pause never starves. +#pragma warning disable CA1416 // AudioTrack.Builder needs API 23+; minSdk is 21 but every shipped device runs 23+. + var attributesBuilder = new AudioAttributes.Builder(); + attributesBuilder.SetUsage(AudioUsageKind.Game); + attributesBuilder.SetContentType(AudioContentType.Music); + var attributes = attributesBuilder.Build()!; + + var formatBuilder = new AudioFormat.Builder(); + formatBuilder.SetEncoding(Encoding.Pcm16bit); + formatBuilder.SetSampleRate(32768); + formatBuilder.SetChannelMask(ChannelOut.Stereo); + var format = formatBuilder.Build()!; + + var trackBuilder = new AudioTrack.Builder(); + trackBuilder.SetAudioAttributes(attributes); + trackBuilder.SetAudioFormat(format); + trackBuilder.SetBufferSizeInBytes(3840 * 4); + trackBuilder.SetTransferMode(AudioTrackMode.Stream); + _track = trackBuilder.Build(); +#pragma warning restore CA1416 + _track.Play(); + } + catch (Exception) + { + _track = null; // no audio device: the game still plays, silently + } + } + + public void Start() => _thread.Start(); + + /// Sets the held buttons (thread-safe; latched for the next frame). + public void SetKeys(uint bitmask) + { + lock (_keyGate) _keysPending = bitmask; + } + + /// Draws the newest frame into the canvas, scaled to fit, pixel-crisp. + public void Draw(SKCanvas canvas, SKRect destination) + { + lock (_frameGate) + { + if (_frameImage is null) return; + // Nearest-neighbour: the GBA's pixels must stay square and sharp, + // never smoothed — an integer scale of 240×160. + var sampling = new SKSamplingOptions(SKFilterMode.Nearest, SKMipmapMode.None); + canvas.DrawImage(_frameImage, destination, sampling); + } + } + + private void RunLoop() + { + var (width, height) = _core.VideoSize; + var frame = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Opaque); + var periodTicks = Stopwatch.Frequency / FramesPerSecond; + // The deadline accumulates: each frame is due periodTicks after the last + // one, so per-frame jitter never accumulates into drift (audio stays in + // sync over hours). + long deadline = Stopwatch.GetTimestamp(); + + while (!_cancel.IsCancellationRequested) + { + // Latch the newest input, drop anything older (a toggle during a frame). + uint keys; + lock (_keyGate) keys = _keysPending; + _core.SetKeys(keys); + _core.RunFrame(); + + // The frame is mGBA's 0x00BBGGRR little-endian: bytes R,G,B,X — the + // bitmap's Rgba8888 row layout, copied as-is. + _core.CopyFrame(MemoryMarshal.Cast(frame.GetPixelSpan())); + + lock (_frameGate) + { + _frameImage?.Dispose(); + _frame = frame; + _frameImage = SKImage.FromBitmap(frame); + } + FrameProduced?.Invoke(); + + // Drain this frame's audio into the track: the audio clock paces the + // loop when the track's buffer fills (writes block), which is the + // PokeDaisy-verified pattern — the game can never outrun its sound. + var frames = _core.ReadAudio(_audioScratch, AudioFramesPerDrain); + if (frames > 0 && _track is not null) + { + _audioBuffer.Rewind(); + _audioBuffer.AsShortBuffer().Put(_audioScratch, 0, frames * 2); + _audioBuffer.Rewind(); + // The track is in bytes: frames × 2 channels × 2 bytes. + _track.Write(_audioBuffer, frames * 4, WriteMode.Blocking); + } + + // Pace to the hardware rate. Sleep granularity (~15 ms on some + // kernels) is corrected by measuring and carrying the error forward. + deadline += (long)periodTicks; + var remaining = TimeSpan.FromSeconds((double)(deadline - Stopwatch.GetTimestamp()) / Stopwatch.Frequency); + if (remaining > TimeSpan.Zero) + { + // Sleep with a 1 ms margin, then spin the sub-millisecond slice: + // Sleep alone drifts with the scheduler's granularity. + Thread.Sleep(remaining + TimeSpan.FromMilliseconds(1)); + while (Stopwatch.GetTimestamp() < deadline) + Thread.SpinWait(64); + } + } + frame.Dispose(); + } + + private void DisposeFrame() + { + lock (_frameGate) + { + _frameImage?.Dispose(); + _frameImage = null; + _frame?.Dispose(); + _frame = null; + } + } + + public void Dispose() + { + _cancel.Cancel(); + try { _thread.Join(500); } catch (ThreadStateException) { } + DisposeFrame(); + _track?.Stop(); + _track?.Release(); + _cancel.Dispose(); + } +} diff --git a/src/PKForge.App/Platforms/Android/EmbeddedGameSession.cs b/src/PKForge.App/Platforms/Android/EmbeddedGameSession.cs new file mode 100644 index 00000000..6a14a8d8 --- /dev/null +++ b/src/PKForge.App/Platforms/Android/EmbeddedGameSession.cs @@ -0,0 +1,109 @@ +using PKForge.Domain; +using SkiaSharp; + +namespace PKForge.App.Platforms.Android; + +/// +/// One running game: the core plus its engine, owned together. Created by the +/// Play flow when the embedded library is present; disposed when the player +/// leaves the game screen. The pages and the second-screen companion both read +/// through this facade. +/// +public sealed class EmbeddedGameSession : IDisposable +{ + private readonly MgbaCore _core; + private EmbeddedEngine? _engine; + + /// Raised on the emu thread after each frame; no-ops once disposed. + public event Action? FrameProduced + { + add { if (_engine is not null) _engine.FrameProduced += value; } + remove { if (_engine is not null) _engine.FrameProduced -= value; } + } + + public (int Width, int Height) VideoSize => _core.VideoSize; + public string GameTitle => _core.GameTitle; + public string GameCode => _core.GameCode; + + private EmbeddedGameSession(MgbaCore core, EmbeddedEngine engine) + { + _core = core; + _engine = engine; + } + + /// + /// Loads a ROM (and its save when given) and starts the emu thread. Returns + /// null when the embedded core is unavailable (no library) or the file is not + /// a GBA/GB ROM — the caller falls back to the external emulator path. + /// + /// True when libpkforge_mgba.so is packed in this APK (the embedded core can run). + public static bool IsAvailable + { + get + { + try + { + var core = new MgbaCore(); + core.Dispose(); + Domain.EmulatorCatalog.EmbeddedCoreAvailable = true; + return true; + } + catch (DllNotFoundException) { return false; } + catch (EntryPointNotFoundException) { return false; } + } + } + + public static EmbeddedGameSession? TryStart(string romPath, string? savePath) + { + try + { + var core = new MgbaCore(); + if (!core.Load(romPath, savePath)) { core.Dispose(); return null; } + var engine = new EmbeddedEngine(core); + var session = new EmbeddedGameSession(core, engine); + engine.Start(); + return session; + } + catch (DllNotFoundException) + { + return null; // the .so is not packed in this build — no embedded play + } + catch (EntryPointNotFoundException) + { + return null; // the .so is older than the shim — no embedded play + } + } + + /// Sets the held buttons (thread-safe). + public void SetKeys(uint bitmask) => _engine?.SetKeys(bitmask); + + /// Draws the newest frame, pixel-crisp. + public void Draw(SKCanvas canvas, SKRect destination) => _engine?.Draw(canvas, destination); + + /// Reads the live game memory — the companion's data bridge. + public bool ReadBytes(uint address, Span destination) => _core.ReadBytes(address, destination); + + /// + /// The live companion snapshot (dex, party, playtime) read from the running + /// core's save RAM. Null when the game isn't a Gen 3 GBA title or the memory + /// is unreadable. Gen 1/2 (GB/GBC) saves live elsewhere and are not read live. + /// + public LiveGbaCompanion.LiveCompanionSnapshot? ReadLiveCompanion() + { + var code = _core.GameCode; + if (!IsGen3Code(code)) return null; + return LiveGbaCompanion.Read(_core, MaxSpeciesGen3); + } + + private static bool IsGen3Code(string code) => code is "BPRE" or "BPEE" or "BPEI" or "BPED" + or "BPSO" or "BPEO" or "AXVE" or "AXPE" or "AXVI" or "AXPI" or "AXVD" or "AXPD"; + + private const int MaxSpeciesGen3 = 386; // PKHeX Legal.MaxSpeciesID_3 + + public void Dispose() + { + _engine?.Dispose(); + _engine = null; + _core.Dispose(); + } +} diff --git a/src/PKForge.App/Platforms/Android/MainActivity.cs b/src/PKForge.App/Platforms/Android/MainActivity.cs index 081cfd92..244d2eca 100644 --- a/src/PKForge.App/Platforms/Android/MainActivity.cs +++ b/src/PKForge.App/Platforms/Android/MainActivity.cs @@ -24,6 +24,31 @@ protected override void OnCreate(Bundle? savedInstanceState) { base.OnCreate(savedInstanceState); if (savedInstanceState is null) MoveToMainScreenIfLaunchedBelow(); + RequestAllFilesAccessIfNeeded(); + } + + /// + /// The ROM library reads /storage/emulated/0/ROMs directly (the ES-DE layout emulators + /// themselves use), which needs "All files access". Android 11+ grants it through the + /// settings screen: the app opens it once, and only when the grant is missing. + /// + private static void RequestAllFilesAccessIfNeeded() + { + if (!OperatingSystem.IsAndroidVersionAtLeast(30)) return; + try + { + if (Android.OS.Environment.IsExternalStorageManager == true) return; + var intent = new Android.Content.Intent( + Android.Provider.Settings.ActionManageAppAllFilesAccessPermission, + Android.Net.Uri.Parse($"package:{Platform.AppContext?.PackageName}")); + intent.AddFlags(ActivityFlags.NewTask); + Platform.AppContext?.StartActivity(intent); + } + catch (Exception error) when (error is Android.Content.ActivityNotFoundException or Java.Lang.SecurityException) + { + // Some builds have no All-files screen: the library's scan simply finds no root. + Services.AppLog.Info("storage", "The All files access screen is unavailable on this build."); + } } /// diff --git a/src/PKForge.App/Platforms/Android/MgbaCore.cs b/src/PKForge.App/Platforms/Android/MgbaCore.cs new file mode 100644 index 00000000..554b8886 --- /dev/null +++ b/src/PKForge.App/Platforms/Android/MgbaCore.cs @@ -0,0 +1,151 @@ +using System.Buffers.Binary; +using System.Runtime.InteropServices; +using System.Text; + +namespace PKForge.App.Platforms.Android; + +/// +/// over PKForge's libmgba shim (jni_bridge.c): +/// load, run, read memory, save states. The core runs on the caller's thread — +/// owns the emu thread; this class never touches the +/// UI thread. One instance per ROM. +/// +internal sealed class MgbaCore : Domain.IEmbeddedCore +{ + private nint _core; + + public bool IsRunning { get; private set; } + + public string? RomPath { get; private set; } + + public bool Load(string romPath, string? savePath = null) + { + if (IsRunning) return false; + var core = MgbaNative.CoreCreate(romPath); + if (core == nint.Zero) return false; + try + { + if (MgbaNative.CoreLoad(core, romPath, savePath) == 0) + { + MgbaNative.CoreDestroy(core); + return false; + } + _core = core; + RomPath = romPath; + IsRunning = true; + return true; + } + catch (Exception) + { + try { MgbaNative.CoreDestroy(core); } catch (DllNotFoundException) { } + return false; + } + } + + private void ThrowWhenNotRunning() + { + if (!IsRunning) throw new InvalidOperationException("No ROM is loaded."); + } + + public void RunFrame() + { + ThrowWhenNotRunning(); + MgbaNative.CoreRunFrame(_core); + } + + public string GameTitle + { + get + { + if (!IsRunning) return string.Empty; + Span title = stackalloc byte[17]; + MgbaNative.CoreGameTitle(_core, title); + var length = title.IndexOf((byte)0); + return length < 0 ? Encoding.ASCII.GetString(title) : Encoding.ASCII.GetString(title[..length]); + } + } + + public string GameCode + { + get + { + if (!IsRunning) return string.Empty; + Span code = stackalloc byte[5]; + MgbaNative.CoreGameCode(_core, code); + var length = code.IndexOf((byte)0); + return length < 0 ? Encoding.ASCII.GetString(code) : Encoding.ASCII.GetString(code[..length]); + } + } + + public (int Width, int Height) VideoSize + { + get + { + if (!IsRunning) return (0, 0); + MgbaNative.CoreVideoSize(_core, out var width, out var height); + return ((int)width, (int)height); + } + } + + public void CopyFrame(Span buffer) + { + ThrowWhenNotRunning(); + var (width, height) = VideoSize; + if (buffer.Length < width * height) + throw new ArgumentException($"The buffer holds {buffer.Length} pixels; the frame needs {width * height}."); + // mGBA's native color_t is 0x00BBGGRR (interface.h's M_COLOR_*): the bytes in + // memory are R,G,B,X — SkiaSharp's Rgba8888 row layout, copied as-is. + MgbaNative.CoreCopyFrame(_core, buffer, (UIntPtr)(width * height)); + } + + public void SetKeys(uint bitmask) + { + if (!IsRunning) return; + MgbaNative.CoreSetKeys(_core, bitmask); + } + + public bool ReadBytes(uint address, Span destination) + { + if (!IsRunning || destination.IsEmpty) return false; + return MgbaNative.CoreReadBytes(_core, address, destination, (UIntPtr)destination.Length) != 0; + } + + public uint? Read32(uint address) + { + Span four = stackalloc byte[4]; + if (!ReadBytes(address, four)) return null; + return BinaryPrimitives.ReadUInt32LittleEndian(four); + } + + /// + /// Drains the core's audio into the destination as interleaved stereo int16 + /// (AudioTrack's PCM16 stereo layout). Returns the frames (sample pairs) read. + /// + public int ReadAudio(Span destination, int samplesPerChannel) + { + if (!IsRunning || samplesPerChannel <= 0) return 0; + return MgbaNative.CoreReadAudio(_core, destination, samplesPerChannel); + } + + public bool SaveState(string path) + { + ThrowWhenNotRunning(); + return MgbaNative.CoreSaveState(_core, path) != 0; + } + + public bool LoadState(string path) + { + ThrowWhenNotRunning(); + return MgbaNative.CoreLoadState(_core, path) != 0; + } + + public void Dispose() + { + if (!IsRunning) return; + IsRunning = false; + RomPath = null; + try { MgbaNative.CoreDestroy(_core); } + catch (DllNotFoundException) { } + _core = nint.Zero; + } +} diff --git a/src/PKForge.App/Platforms/Android/MgbaNative.cs b/src/PKForge.App/Platforms/Android/MgbaNative.cs new file mode 100644 index 00000000..9fa49c79 --- /dev/null +++ b/src/PKForge.App/Platforms/Android/MgbaNative.cs @@ -0,0 +1,80 @@ +using System.Runtime.InteropServices; + +namespace PKForge.App.Platforms.Android; + +/// +/// P/Invoke over PKForge's libmgba shim (tools/build-mgba/jni_bridge.c), built as +/// libpkforge_mgba.so for arm64-v8a. The shim is flat C over mGBA's mCore API +/// (0.10.5, signature-for-signature from mgba/core/core.h): every function +/// returns int (1 = success, 0 = failure) and every string is UTF-8 — no C bool +/// marshalling, no struct layouts on the managed side. +/// +internal static partial class MgbaNative +{ + private const string Library = "pkforge_mgba"; + + // ---- Lifecycle ---- + + /// mCoreFind + init + loadConfig; returns the core handle, 0 on failure. + [LibraryImport(Library, EntryPoint = "pkforg_core_create", StringMarshalling = StringMarshalling.Utf8)] + internal static partial nint CoreCreate(string romPath); + + [LibraryImport(Library, EntryPoint = "pkforg_core_destroy")] + internal static partial void CoreDestroy(nint core); + + /// loadROM + loadSave + reset; 1 on success. + [LibraryImport(Library, EntryPoint = "pkforg_core_load", StringMarshalling = StringMarshalling.Utf8)] + internal static partial int CoreLoad(nint core, string romPath, string? savePath); + + // ---- Run ---- + + [LibraryImport(Library, EntryPoint = "pkforg_core_run_frame")] + internal static partial void CoreRunFrame(nint core); + + [LibraryImport(Library, EntryPoint = "pkforg_core_reset")] + internal static partial void CoreReset(nint core); + + // ---- Video ---- + + [LibraryImport(Library, EntryPoint = "pkforg_core_video_size")] + internal static partial void CoreVideoSize(nint core, out uint width, out uint height); + + /// Copies the frame as RGBA8888 into buffer (pixels × 4 bytes). + [LibraryImport(Library, EntryPoint = "pkforg_core_copy_frame")] + internal static partial void CoreCopyFrame(nint core, Span buffer, UIntPtr pixels); + + // ---- Input ---- + + [LibraryImport(Library, EntryPoint = "pkforg_core_set_keys")] + internal static partial void CoreSetKeys(nint core, uint keys); + + // ---- Memory (the companion's data bridge) ---- + + /// Reads length bytes at a GBA bus address; 1 when the range is mapped. + [LibraryImport(Library, EntryPoint = "pkforg_core_read_bytes")] + internal static partial int CoreReadBytes(nint core, uint address, Span destination, UIntPtr length); + + // ---- Identity ---- + + /// Copies the ROM header's game title (max 16 bytes + NUL). + [LibraryImport(Library, EntryPoint = "pkforg_core_game_title")] + internal static partial void CoreGameTitle(nint core, Span title); + + /// Copies the ROM header's game code (4 bytes + NUL). + [LibraryImport(Library, EntryPoint = "pkforg_core_game_code")] + internal static partial void CoreGameCode(nint core, Span code); + + // ---- Audio (the GBA's 32768 Hz stereo, interleaved by the shim) ---- + + /// Drains up to samplesPerChannel×2 int16s; returns the frames read, 0 when none. + [LibraryImport(Library, EntryPoint = "pkforg_core_read_audio")] + internal static partial int CoreReadAudio(nint core, Span destination, int samplesPerChannel); + + // ---- Save states (mGBA's .ss format, desktop-compatible) ---- + + [LibraryImport(Library, EntryPoint = "pkforg_core_save_state", StringMarshalling = StringMarshalling.Utf8)] + internal static partial int CoreSaveState(nint core, string path); + + [LibraryImport(Library, EntryPoint = "pkforg_core_load_state", StringMarshalling = StringMarshalling.Utf8)] + internal static partial int CoreLoadState(nint core, string path); +} diff --git a/src/PKForge.App/Platforms/Android/Resources/drawable/trophy_notification.xml b/src/PKForge.App/Platforms/Android/Resources/drawable/trophy_notification.xml new file mode 100644 index 00000000..13958335 --- /dev/null +++ b/src/PKForge.App/Platforms/Android/Resources/drawable/trophy_notification.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/src/PKForge.App/Platforms/Android/Resources/xml/rom_provider_paths.xml b/src/PKForge.App/Platforms/Android/Resources/xml/rom_provider_paths.xml new file mode 100644 index 00000000..e7f8da57 --- /dev/null +++ b/src/PKForge.App/Platforms/Android/Resources/xml/rom_provider_paths.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/PKForge.App/Services/AppStrings.cs b/src/PKForge.App/Services/AppStrings.cs new file mode 100644 index 00000000..0b2733c4 --- /dev/null +++ b/src/PKForge.App/Services/AppStrings.cs @@ -0,0 +1,255 @@ +using PKForge.Domain; + +namespace PKForge.App.Services; + +/// +/// Every user-facing string the new screens add, in French and English. French is the +/// default (the project's own language). The rest of the app keeps its built-in English +/// copy; these tables cover the Emulators, Trophies, Awards and Library screens. +/// +public static class AppStrings +{ + private const string Key = "app_language"; + + /// The current language: the persisted choice, else the device locale, else French. + public static AppLanguage Language { get; private set; } = ResolveCurrent(); + + /// Raised when the language changes, so open pages can re-read their strings. + public static event Action? Changed; + + private static AppLanguage ResolveCurrent() + { + var hasChoice = Preferences.Default.ContainsKey(Key); + var choice = hasChoice ? (AppLanguage)Preferences.Default.Get(Key, (int)AppLanguage.French) : AppLanguage.French; + return LanguageRules.Resolve(hasChoice, choice, System.Globalization.CultureInfo.CurrentUICulture?.TwoLetterISOLanguageName); + } + + /// Persists the choice and switches every open surface to it. + public static void Set(AppLanguage language) + { + Preferences.Default.Set(Key, (int)language); + Language = language; + Changed?.Invoke(); + } + + /// True when the current language is French, for inline site-specific strings. + public static bool Fr => Language == AppLanguage.French; + + // ---- Emulators ---- + public static string EmulatorsTitle => Fr ? "Émulateurs" : "Emulators"; + public static string LinkAllInstalled => Fr ? "Tout lier" : "Link all installed"; + public static string Linked => Fr ? "Lié" : "Linked"; + public static string NotLinked => Fr ? "Non lié" : "Not linked"; + public static string NotInstalled => Fr ? "Non installé" : "Not installed"; + public static string BindConsole => Fr ? "Choisir l'émulateur" : "Choose emulator"; + public static string ConsoleBound => Fr ? "Émulateur des ROMs" : "ROMs emulator"; + public static string AlreadyAllLinked => Fr ? "Tous les émulateurs installés sont déjà liés." : "Every installed emulator is already linked."; + + // ---- Library ---- + public static string LibraryTitle => Fr ? "Bibliothèque" : "Library"; + public static string ScanRoms => Fr ? "Scanner les ROMs" : "Scan ROMs"; + public static string NoRomRoot => Fr + ? "Dossier ROMs introuvable. Accordez l'accès à tous les fichiers dans les réglages Android, puis rescannez." + : "No ROMs folder found. Grant All files access in Android settings, then scan again."; + public static string Scanning => Fr ? "Scan en cours…" : "Scanning…"; + public static string RomCount(int count) => Fr ? $"{count} ROM(s) Pokémon trouvée(s)." : $"{count} Pokémon ROM(s) found."; + public static string Play => Fr ? "Jouer" : "Play"; + + // ---- Cartridge menu ---- + public static string LinkRom => Fr ? "Lier une ROM…" : "Link ROM…"; + public static string UnlinkRom => Fr ? "Délier la ROM" : "Unlink ROM"; + public static string PickFromLibrary => Fr ? "Choisir dans la bibliothèque" : "Pick from library"; + public static string RomMissing => Fr ? "Émulateur manquant" : "Emulator missing"; + + // ---- Trophies ---- + public static string TrophiesTitle => Fr ? "Trophées" : "Trophies"; + public static string ScanSaves => Fr ? "Analyser les sauv." : "Scan saves"; + public static string Unlocked => Fr ? "Débloqué" : "Unlocked"; + + // ---- Home: cards and shelf ---- + public static string DexAutopilot => Fr ? "Autopilot Dex" : "Dex Autopilot"; + public static string Pokepark => "Poképark"; + public static string Games => Fr ? "Jeux" : "Games"; + public static string Bank => Fr ? "Banque" : "Bank"; + public static string Events => Fr ? "Événements" : "Events"; + public static string Settings => Fr ? "Réglages" : "Settings"; + public static string Open => Fr ? "Ouvrir" : "Open"; + public static string OpenSave => Fr ? "Ouvrir la sauv." : "Open save"; + public static string EditGameHold => Fr ? "Modifier · maintenir" : "Edit game · hold"; + public static string QuitPkforge => Fr ? "Quitter PKForge ?" : "Quit PKForge?"; + public static string Quit => Fr ? "Quitter" : "Quit"; + public static string Back => Fr ? "Retour" : "Back"; + public static string Cancel => Fr ? "Annuler" : "Cancel"; + public static string Ok => "OK"; + public static string Close => Fr ? "Fermer" : "Close"; + public static string WhichSaveToOpen => Fr ? "Quelle sauvegarde ouvrir ?" : "Which save do you want to open?"; + public static string SaveCouldNotOpen => Fr ? "La sauvegarde n'a pas pu s'ouvrir" : "Save could not open"; + + // ---- Home: link and storage ---- + public static string Link => Fr ? "Lier" : "Link"; + public static string LinkEmulator => Fr ? "Lier un émulateur" : "Link an emulator"; + public static string LinkStorageUnit => Fr ? "Lier un stockage" : "Link a storage unit"; + public static string ChoosePlatform => Fr ? "Choisissez une plateforme pour voir ses émulateurs." : "Choose a platform to see its emulators."; + public static string ChooseFolder => Fr ? "Choisir le dossier" : "Choose folder"; + public static string LinkedStorage => Fr ? "Stockages liés" : "Linked storage"; + public static string ManageLinkedStorage => Fr ? "Gérer les stockages liés" : "Manage linked storage"; + public static string NoLinkedStorage => Fr ? "Aucun stockage lié." : "No linked storage units."; + public static string UnlinkAllStorage => Fr ? "Délier tous les stockages" : "Unlink all storage units"; + public static string UnlinkAll => Fr ? "Tout délier" : "Unlink all"; + public static string Unlink => Fr ? "Délier" : "Unlink"; + public static string UnlinkStorageQuestion => Fr ? "Délier ce stockage ?" : "Unlink storage?"; + public static string UnlinkAllStorageQuestion => Fr ? "Tout délier ?" : "Unlink all storage?"; + public static string UnlinkAllRemoved => Fr ? "Tous les stockages liés ont été retirés." : "All linked storage units removed."; + public static string OpenSingleSave => Fr ? "Ouvrir un fichier de sauvegarde" : "Open a single save file"; + public static string SingleSaveFile => Fr ? "Fichier de sauvegarde unique" : "Single save file"; + public static string File => Fr ? "Fichier" : "File"; + public static string RescanGames => Fr ? "Rescanner les jeux" : "Rescan games"; + public static string ScanReport => Fr ? "Rapport de scan" : "Scan report"; + public static string ScanReportCopied => Fr ? "Rapport copié" : "Scan report copied"; + public static string CopyReport => Fr ? "Copier le rapport" : "Copy report"; + public static string ShareReport => Fr ? "Partager le rapport" : "Share report"; + public static string ShareLogs => Fr ? "Partager les logs" : "Share logs"; + public static string HiddenSaves => Fr ? "Sauvegardes masquées" : "Hidden saves"; + public static string ShowHiddenSaves(int count) => Fr ? $"Afficher les sauvegardes masquées ({count})" : $"Show hidden saves ({count})"; + + // ---- Settings ---- + public static string CheckForUpdate => Fr ? "Rechercher une mise à jour" : "Check for update"; + public static string AboutPkforge => Fr ? "À propos de PKForge" : "About PKForge"; + public static string SecondScreenOn => Fr ? "Second écran : activé" : "Second screen: ON"; + public static string SecondScreenOff => Fr ? "Second écran : désactivé" : "Second screen: OFF"; + public static string RestorePoints => Fr ? "Points de restauration" : "Restore points"; + public static string BoxBackground => Fr ? "Fond des boîtes" : "Box background"; + public static string ColorScheme => Fr ? "Thème de couleur" : "Color scheme"; + public static string EventDatabase => Fr ? "Base d'événements" : "Event database"; + public static string WonderCards => Fr ? "Wonder cards" : "Wonder cards"; + public static string CommunityBoxes => Fr ? "Boîtes communautaires" : "Community boxes"; + public static string Music => Fr ? "Musique" : "Music"; + public static string Misc => Fr ? "Divers" : "Misc"; + + // ---- Filter ---- + public static string Filter => Fr ? "Filtrer" : "Filter"; + public static string FilterGames => Fr ? "Filtrer les jeux" : "Filter games"; + public static string AllGames => Fr ? "Tous les jeux" : "All games"; + public static string All => "Tous"; + public static string ReleaseOrder => Fr ? "Ordre de sortie" : "Release order"; + public static string Alphabetical => Fr ? "Alphabétique (A-Z)" : "Alphabetical (A-Z)"; + + // ---- Wizard ---- + public static string GetStarted => Fr ? "Commencer" : "Get started"; + public static string Welcome => Fr ? "Bonjour ! Bienvenue sur PKForge !" : "Hello! Welcome to PKForge!"; + public static string FindYourGames => Fr ? "Et maintenant... trouvons vos jeux !" : "Now then... let's find your games!"; + public static string MaybeLater => Fr ? "Plus tard" : "Maybe later"; + public static string NotNow => Fr ? "Pas maintenant" : "Not now"; + public static string DecideLater => Fr ? "Décider plus tard" : "Decide later"; + public static string DownloadSpritePack => Fr ? "Télécharger le pack de sprites" : "Download the sprite pack"; + + // ---- Shelf status (SavePickerViewModel) ---- + public static string LinkStorageToBegin => Fr ? "Liez le stockage d'un émulateur pour commencer." : "Link an emulator's storage to begin."; + public static string NoStorageLinkedYet => Fr ? "Aucun stockage lié. Ouvrez Lier et choisissez votre plateforme et émulateur." : "No storage units linked yet. Open Link and choose your platform and emulator."; + public static string LinkCancelled => Fr ? "Liaison annulée." : "Link cancelled."; + public static string StorageLinked => Fr ? "Stockage lié." : "Storage linked."; + public static string Connected => Fr ? "Connecté." : "Connected."; + public static string SelectSaveFile => Fr ? "Sélectionnez le fichier de sauvegarde à lier…" : "Select the save file to link…"; + public static string LinkingFile(string name) => Fr ? $"Liaison de {name}…" : $"Linking {name}…"; + public static string ConnectingTo(string game) => Fr ? $"Connexion à {game}…" : $"Connecting to {game}…"; + public static string CouldNotConnect(string error) => Fr ? $"Connexion impossible : {error}" : $"Could not connect: {error}"; + public static string CouldNotLinkSave(string error) => Fr ? $"Liaison impossible : {error}" : $"Could not link save: {error}"; + public static string EmulatorLinked(string emulator) => Fr ? $"{emulator} lié" : $"{emulator} linked"; + public static string NoSavesFoundIn(string folder, int rejected) => Fr + ? $"Aucune sauvegarde trouvée dans {folder}" + (rejected > 0 ? $"{rejected} fichier(s) ressemblaient à des sauvegardes mais illisibles. " : "") + : $"No saves found in {folder}"; + public static string FoundSaves(int found, string folder) => Fr + ? $"{found} sauvegarde(s) Pokémon trouvée(s) dans {folder}." + : $"Found {found} Pokémon save{(found == 1 ? "" : "s")} in {folder}."; + public static string ScanningUnit(string emulator, string folder) => Fr ? $"Scan de l'unité {emulator} · {folder}…" : $"Scanning {emulator} unit · {folder}…"; + public static string ScanFailed(string emulator, string error) => Fr ? $"Le scan de {emulator} a échoué : {error}" : $"Scan of {emulator} failed: {error}"; + public static string SavesOnShelf(int saves, int files) => Fr + ? $"{saves} sauvegarde(s) sur l'étagère. {files} fichier(s) scanné(s)." + : $"{saves} save(s) on the shelf. Scanned {files} file(s)."; + public static string NoGamesFound(int files, int rejected) => Fr + ? $"Aucun jeu trouvé. {files} fichier(s) scanné(s), {rejected} ressemblaient à des sauvegardes sans se laisser lire." + : $"No games found. Scanned {files} file(s), {rejected} looked like saves but did not parse."; + public static string ScanReportEmpty => Fr ? "Aucun scan diagnostique n'a encore tourné. Utilisez Rescanner les jeux, puis rouvrez le Rapport de scan." : "No diagnostic scan has run yet. Use Rescan games, then open Scan report again."; + public static string FolderStaysLinked => Fr ? " Le dossier reste lié ; déliez-le depuis Gérer les stockages liés." : " The folder stays linked; unlink it from Manage linked storage."; + + // ---- Per-emulator folder guidance (the "nothing found" hints) ---- + public static string GuidanceDefault => Fr ? "Choisissez le dossier qui contient vos fichiers de sauvegarde." : "Pick the folder that holds your save files."; + public static string GuidanceAzahar => Fr ? "Choisissez le dossier utilisateur de l'émulateur, celui qui contient sdmc (sdmc ou Nintendo 3DS fonctionnent aussi). Les sauvegardes vivent dans sdmc/Nintendo 3DS/…/title/00040000//data/00000001/main." : "Pick the emulator's user folder, the one that contains sdmc (sdmc or Nintendo 3DS work too). Saves live in sdmc/Nintendo 3DS/…/title/00040000//data/00000001/main."; + public static string GuidanceMelonDs => Fr ? "Choisissez le dossier de vos fichiers .sav : à côté de vos ROMs par défaut, ou le dossier de sauvegardes réglé dans melonDS." : "Pick the folder that holds your .sav files: next to your ROMs by default, or the save folder set in melonDS's settings."; + public static string GuidanceDrastic => Fr ? "Choisissez le dossier de sauvegardes de DraStic (ses .dsv) ou son dossier de données." : "Pick DraStic's backup folder (its .dsv battery saves) or the DraStic data folder."; + public static string GuidanceEden => Fr ? "Choisissez la racine de fichiers d'Eden, le dossier qui contient son stockage émulé." : "Pick Eden's files root, the folder that contains its emulated storage."; + + // ---- Trophies page ---- + public static string ScanFailed(string error) => Fr ? $"Le scan a échoué : {error}" : $"Scan failed: {error}"; + public static string ScanningSaves => Fr ? "Analyse des sauvegardes…" : "Scanning saves…"; + public static string NoSaveScanned => Fr ? "Aucune sauvegarde liée n'a pu être analysée. Liez un émulateur d'abord, puis réanalysez." : "No linked save could be scanned. Link an emulator first, then scan again."; + public static string ScannedSaves(int count) => Fr ? $"{count} sauvegarde(s) analysée(s). Chaque trophée prouvé par le scan est débloqué." : $"Scanned {count} save(s). Every trophy the scans prove is unlocked."; + public static string Relock => Fr ? "Verrouiller à nouveau" : "Relock"; + public static string RelockQuestion(string title) => Fr ? $"Verrouiller à nouveau {title} ?" : $"Relock {title}?"; + public static string ManualTrophyRelockDetail => Fr ? "Les trophées manuels sont votre propre relevé. Le relock retire le déblocage." : "Manual trophies are your own record. Locking it back removes the unlock."; + + // ---- RetroAchievements page ---- + public static string SignIn => Fr ? "Se connecter" : "Sign in"; + public static string SignOut => Fr ? "Se déconnecter" : "Sign out"; + public static string SignedInAs(string user) => Fr ? $"Connecté en tant que {user}." : $"Signed in as {user}."; + public static string SignedOut => Fr ? "Déconnecté. Les succès officiels restent sur retroachievements.org." : "Signed out. Official achievements stay on retroachievements.org."; + public static string RaUsername => Fr ? "Nom d'utilisateur RA" : "RA username"; + public static string RaApiKey => Fr ? "Clé API web (panneau de contrôle)" : "Web API key (control panel)"; + public static string RaBadCredentials => Fr ? "RetroAchievements n'a pas reconnu ces identifiants." : "RetroAchievements did not recognise those credentials."; + public static string RaSignInFailed(string error) => Fr ? $"Échec de connexion : {error}" : $"Sign-in failed: {error}"; + public static string RaRefreshFailed(string error) => Fr ? $"Échec du rafraîchissement : {error}" : $"Refresh failed: {error}"; + public static string RaNoSets => Fr ? "Aucun set de succès officiel trouvé pour vos jeux Gen 1-5." : "No official achievement sets found for your Gen 1-5 games."; + public static string RaOffline => Fr ? "RetroAchievements injoignable : {0}. La progression en cache est affichée." : "Could not reach RetroAchievements: {0}. Cached progress is shown."; + + // ---- Restore points ---- + public static string RestorePointsTitle => Fr ? "Points de restauration" : "Restore points"; + public static string Restore => Fr ? "Restaurer" : "Restore"; + public static string RestoreThisPoint => Fr ? "Restaurer ce point ?" : "Restore this point?"; + public static string RestoreAnyway => Fr ? "Restaurer quand même" : "Restore anyway"; + public static string CancelKeepCurrent => Fr ? "Annuler (garder la sauvegarde actuelle)" : "Cancel (keep current save)"; + + // ---- Box editor: shared buttons and actions ---- + public static string CancelLabel => Fr ? "Annuler" : "Cancel"; + public static string Apply => Fr ? "Appliquer" : "Apply"; + public static string Delete => Fr ? "Supprimer" : "Delete"; + public static string Duplicate => Fr ? "Dupliquer" : "Duplicate"; + public static string Release => Fr ? "Relâcher" : "Release"; + public static string MoveLabel => Fr ? "Déplacer" : "Move"; + public static string SortLabel => Fr ? "Trier" : "Sort"; + public static string Heal => Fr ? "Soigner" : "Heal"; + public static string EditLabel => Fr ? "Modifier" : "Edit"; + public static string SendToBank => Fr ? "Envoyer vers la Banque" : "Send to Bank"; + public static string CopyToBank => Fr ? "Copier vers la Banque" : "Copy to Bank"; + public static string CopyToAnotherGame => Fr ? "Copier vers un autre jeu…" : "Copy to another game…"; + public static string BoxManager => Fr ? "Gestion des boîtes…" : "Box manager…"; + public static string BatchEditor => Fr ? "Éditeur par lot" : "Batch editor"; + public static string LegalizeAll => Fr ? "Légaliser tout l'illégal" : "Legalize all illegal"; + public static string Legalizing => Fr ? "Légalisation…" : "Legalizing…"; + public static string LegalityCheck => Fr ? "Vérification de légalité" : "Legality check"; + public static string SaveChanges => Fr ? "ENREGISTRER" : "SAVE CHANGES"; + public static string RibbonAlbum => Fr ? "Album de rubans" : "Ribbon album"; + public static string HeldItem => Fr ? "Objet tenu" : "Held item"; + public static string MaxFriendship => Fr ? "Amitié max" : "Max friendship"; + public static string ImportShowdown => Fr ? "Importer une équipe Showdown" : "Import Showdown team"; + public static string PasteShowdown => Fr ? "Coller un set Showdown" : "Paste a Showdown set"; + public static string CopyToClipboard => Fr ? "Copier dans le presse-papiers" : "Copy to clipboard"; + public static string AuditReport => Fr ? "Rapport d'audit" : "Audit report"; + public static string NuzlockeReport => Fr ? "Rapport Nuzlocke" : "Nuzlocke report"; + public static string EmptySelectedBoxes => Fr ? "Vider les boîtes sélectionnées" : "Empty selected boxes"; + public static string ExportSave => Fr ? "Exporter la sauvegarde modifiée" : "Export modified save"; + public static string ThisBox => Fr ? "Cette boîte" : "This box"; + public static string AllBoxes => Fr ? "Toutes les boîtes" : "All boxes"; + public static string MyPresets => Fr ? "Mes présets d'objets" : "My item presets"; + public static string SaveBagPreset => Fr ? "Enregistrer la sacoche actuelle comme préset" : "Save current bag as preset"; + public static string RngIvs => Fr ? "RNG / IVs" : "RNG / IVs"; + public static string RemoveItems => Fr ? "Retirer tous les objets de cette poche" : "Remove every item in this pouch"; + public static string WonderCardsLabel => Fr ? "Wonder cards" : "Wonder cards"; + + // ---- Platform names (menu Link) ---- + public static string PlatformGbGbc => Fr ? "Game Boy / Game Boy Color" : "Game Boy / Game Boy Color"; + public static string PlatformGba => "Game Boy Advance"; + public static string PlatformDs => "Nintendo DS"; + public static string PlatformGc => "GameCube"; + public static string Platform3ds => "Nintendo 3DS"; + public static string PlatformSwitch => "Nintendo Switch"; +} diff --git a/src/PKForge.App/Services/EmulatorSaveFolders.cs b/src/PKForge.App/Services/EmulatorSaveFolders.cs new file mode 100644 index 00000000..e15bfb34 --- /dev/null +++ b/src/PKForge.App/Services/EmulatorSaveFolders.cs @@ -0,0 +1,114 @@ +using PKForge.Domain; + +namespace PKForge.App.Services; + +/// +/// The standard save folders per emulator on Android handhelds (AYN Thor, Retroid, +/// Anbernic...), where frontends like ES-DE and Cocoon expect them. This answers the +/// "which folder?" question: none is asked — the tap scans these paths directly. +/// +public static class EmulatorSaveFolders +{ + private const string StorageRoot = "/storage/emulated/0"; + + /// One emulator's candidate save folders, relative to the storage root. + public static IReadOnlyList For(EmulatorKind kind) => kind switch + { + EmulatorKind.RetroArch => ["RetroArch/saves", "retroarch/saves", "RetroArch/system"], + EmulatorKind.MelonDS => ["melonDS", "melonds", "MelonDS", "Android/data/io.github.melonds/files/saves"], + EmulatorKind.Azahar or EmulatorKind.CitraMmj => + ["Azahar", "azahar", "Lime3DS", "lime3ds", "Citra", "citra-emu", "citra-emu"], + EmulatorKind.Eden => ["Eden", "eden", "Sudachi", "sudachi", "Yuzu", "yuzu", "Ryujinx", "ryujinx", "Skyline", "skyline"], + EmulatorKind.Dolphin => ["dolphin-emu", "Dolphin"], + EmulatorKind.DraStic => ["DraStic", "drastic", "Android/data/com.dsemu.drastic/files/backup"], + EmulatorKind.Linkboy => ["Linkboy", "linkboy"], + EmulatorKind.PizzaBoyGba => ["Pizza Boy GBA", "pizzaboy"], + EmulatorKind.PizzaBoyGbc => ["Pizza Boy GBC", "pizzaboygbc"], + _ => [], + }; + + /// The folders that exist on this device for one emulator. + public static IReadOnlyList ExistingFor(EmulatorKind kind) => + For(kind) + .Select(folder => Path.Combine(StorageRoot, folder)) + .Where(AbsoluteExists) + .ToList(); + + /// The ES-DE ROM roots, plus the emulator-specific ROM folders. + public static readonly IReadOnlyList RomRoots = + ["/storage/emulated/0/ROMs", "/storage/emulated/0/roms", "/storage/emulated/0/Roms"]; + + /// True when at least one standard ROM root is readable. + public static bool HasRomRoot() => RomRoots.Any(AbsoluteExists); + + private static bool AbsoluteExists(string path) + { + try + { + return Directory.Exists(path); + } + catch (Exception) + { + return false; + } + } +} + +/// +/// Scans the emulators' standard save folders directly on the filesystem — no SAF +/// picker, no "which folder?" question. Requires all-files access on Android 11+; +/// without it, the scan reports nothing and the caller says so. +/// +public sealed class DirectSaveScanner(ISaveEngine engine) +{ + private static readonly HashSet SaveExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".sav", ".srm", ".dsv", ".bin", ".dat", ".main", + }; + + public sealed record FoundSave(string FullPath, string FileName, string GameLabel, int Generation); + + /// Scans every existing standard folder of one emulator for Pokémon saves. + public IReadOnlyList Scan(EmulatorKind kind, CancellationToken cancellationToken = default) + { + var found = new List(); + foreach (var dir in EmulatorSaveFolders.ExistingFor(kind)) + { + cancellationToken.ThrowIfCancellationRequested(); + IEnumerable files; + try + { + files = Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories); + } + catch (Exception error) when (error is UnauthorizedAccessException or IOException) + { + continue; + } + foreach (var file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + var fileName = Path.GetFileName(file); + var extension = Path.GetExtension(fileName); + if (string.IsNullOrEmpty(extension) || !SaveExtensions.Contains(extension)) continue; + var label = RomNameRules.GameName(fileName); + if (string.IsNullOrWhiteSpace(label)) continue; + found.Add(new FoundSave(file, fileName, $"Pokémon {label}", RomNameRules.GenerationOf(label))); + } + } + return found; + } + + /// Quick probe: the bytes parse as a Pokémon save. + public bool LooksLikePokemonSave(string path) + { + try + { + var bytes = File.ReadAllBytes(path); + return bytes.Length > 0 && engine.Validate(bytes); + } + catch (Exception) + { + return false; + } + } +} diff --git a/src/PKForge.App/Services/GameArt.cs b/src/PKForge.App/Services/GameArt.cs index ff279a88..2205078a 100644 --- a/src/PKForge.App/Services/GameArt.cs +++ b/src/PKForge.App/Services/GameArt.cs @@ -82,8 +82,20 @@ public SecondScreenState() /// The Living Dex Autopilot's payload: its route map. [ObservableProperty] private LivingDexRoute? _autopilotRoute; + + /// The Trophies page's payload: the scanned game's ladder, one record per refresh. + [ObservableProperty] private TrophyLadder? _trophyLadder; } +/// The lower screen's trophy view: the game, its completion, and the recent unlocks. +public sealed record TrophyLadder( + string GameLabel, + int Generation, + int Percent, + int Unlocked, + int Total, + IReadOnlyList<(string Title, string Glyph, bool Unlocked, string Detail)> Rows); + /// What the inspector shows for a surface that drives it: the mon (null = empty slot), /// whether its legality verdict is still being computed, and the context line. public sealed record InspectorContent(MonSummary? Summary, bool LegalityPending, string? Caption); diff --git a/src/PKForge.App/Services/GameCompanionStore.cs b/src/PKForge.App/Services/GameCompanionStore.cs new file mode 100644 index 00000000..17edba9a --- /dev/null +++ b/src/PKForge.App/Services/GameCompanionStore.cs @@ -0,0 +1,56 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using PKForge.Domain; + +namespace PKForge.App.Services; + +/// +/// as an observable session state: set when Play +/// launches a game, cleared when the player comes back to PKForge. The second +/// screen's swap reads while the emulator owns the foreground. +/// +public sealed partial class GameCompanionStore : ObservableObject, IGameCompanionStore +{ + // The generated property "Current" implements the interface's reader. + [ObservableProperty] private GameCompanion? _current; + + [ObservableProperty] private LiveCompanionSnapshot? _live; + + public void Start(GameCompanion companion) => Current = companion; + + public void UpdateLive(LiveCompanionSnapshot snapshot) => Live = snapshot; + + public void ClearLive() => Live = null; + + public void Stop() + { + Current = null; + Live = null; + } + + /// + /// Starts the companion for a launched game: its dex progress and restore points + /// when the game's save is linked, the bare game card otherwise. Called by every + /// Play site after a successful handoff; the watcher's resume scan clears it. + /// + public async Task StartForGameAsync(RomLink link) + { + var services = IPlatformApplication.Current?.Services; + var points = new List(); + try + { + if (services?.GetService() is { } backups) + { + foreach (var info in await backups.ListAsync()) + points.Add(new CompanionRestorePoint(info.BackupId, info.CreatedUtc, info.Sha256)); + points.Reverse(); // newest first + } + } + catch (Exception error) + { + AppLog.Info("companion", $"Restore points unavailable: {error.Message}"); + } + // The dex lands with the watcher's first resume scan; the companion opens + // with the game and its restore points right away. + Start(new GameCompanion(link.GameLabel, link.Generation, 0, 0, 0, points)); + } +} diff --git a/src/PKForge.App/Services/HttpRetroAchievementsClient.cs b/src/PKForge.App/Services/HttpRetroAchievementsClient.cs new file mode 100644 index 00000000..55d49d86 --- /dev/null +++ b/src/PKForge.App/Services/HttpRetroAchievementsClient.cs @@ -0,0 +1,94 @@ +using System.Text.Json; +using PKForge.Domain; + +namespace PKForge.App.Services; + +/// +/// The RetroAchievements web API client (API_GetGameInfoAndUserProgress), following the +/// same HTTP shape as AppUpdateService: one shared client, short timeouts, and errors +/// thrown as messages the status line can show. Credentials live in the platform's +/// secure storage and are never written to plain files or logs. +/// +public sealed class HttpRetroAchievementsClient( + IRetroCredentialStore credentials, + IRetroProgressStore cache) : IRetroAchievementsClient +{ + private const string BaseUrl = "https://retroachievements.org/API/API_GetGameInfoAndUserProgress.php"; + private const string ProfileUrl = "https://retroachievements.org/API/API_GetUserSummary.php"; + + private static readonly HttpClient Http = new(new SocketsHttpHandler + { + ConnectTimeout = TimeSpan.FromSeconds(15), + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + }) + { Timeout = TimeSpan.FromSeconds(30) }; + + private RetroCredentials Credentials => + credentials.Load() ?? throw new InvalidOperationException("Sign in to RetroAchievements first."); + + public async Task VerifyCredentialsAsync(CancellationToken cancellationToken = default) + { + var auth = Credentials; + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(15)); + var url = $"{ProfileUrl}?y={Uri.EscapeDataString(auth.ApiKey)}&u={Uri.EscapeDataString(auth.Username)}&g=1"; + using var response = await Http.GetAsync(url, timeout.Token).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException($"RetroAchievements answered {response.StatusCode}."); + await using var stream = await response.Content.ReadAsStreamAsync(timeout.Token).ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: timeout.Token).ConfigureAwait(false); + // An unknown user comes back as an error object, a valid one as a profile with an ID. + return RetroAchievementsJson.LooksLikeProfile(document.RootElement); + } + + public async Task GetGameProgressAsync(int gameId, CancellationToken cancellationToken = default) + { + if (gameId <= 0) throw new ArgumentOutOfRangeException(nameof(gameId)); + var auth = Credentials; + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(20)); + var url = $"{BaseUrl}?y={Uri.EscapeDataString(auth.ApiKey)}&u={Uri.EscapeDataString(auth.Username)}&g={gameId}&a=1"; + using var response = await Http.GetAsync(url, timeout.Token).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException($"RetroAchievements answered {response.StatusCode} for game {gameId}."); + await using var stream = await response.Content.ReadAsStreamAsync(timeout.Token).ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: timeout.Token).ConfigureAwait(false); + var root = document.RootElement; + if (!RetroAchievementsJson.LooksLikeGame(root)) + throw new InvalidOperationException("RetroAchievements did not recognise that game."); + + var progress = RetroAchievementsJson.ParseGameProgress(root, gameId); + cache.Store(progress); + return progress; + } +} + +/// +/// over MAUI's SecureStorage: the API key is a +/// password-grade secret, so it never reaches plain Preferences or a JSON file. +/// +public sealed class SecureRetroCredentialStore : IRetroCredentialStore +{ + private const string UserKey = "ra_username"; + private const string KeyKey = "ra_api_key"; + + public RetroCredentials? Load() + { + var user = Preferences.Default.Get(UserKey, string.Empty); + var key = SecureStorage.Default.GetAsync(KeyKey).GetAwaiter().GetResult(); + if (string.IsNullOrWhiteSpace(user) || string.IsNullOrWhiteSpace(key)) return null; + return new RetroCredentials(user, key); + } + + public void Save(RetroCredentials? credentials) + { + if (credentials is null) + { + Preferences.Default.Remove(UserKey); + SecureStorage.Default.Remove(KeyKey); + return; + } + Preferences.Default.Set(UserKey, credentials.Username); + SecureStorage.Default.SetAsync(KeyKey, credentials.ApiKey).GetAwaiter().GetResult(); + } +} diff --git a/src/PKForge.App/Services/TrophyScanService.cs b/src/PKForge.App/Services/TrophyScanService.cs new file mode 100644 index 00000000..2ca3b7e3 --- /dev/null +++ b/src/PKForge.App/Services/TrophyScanService.cs @@ -0,0 +1,94 @@ +using PKForge.Domain; + +namespace PKForge.App.Services; + +/// +/// Scans linked saves into and updates the homemade trophy +/// store, the same read path as the Museum's dex editor: bytes in, dex progress, shiny +/// holdings and playtime out. Unlocks are permanent; the store keeps the best value. +/// +public sealed class TrophyScanService( + ISaveFileAccess files, + ISaveEngine engine, + ITrophyStore trophies) +{ + /// Scans one save and stores every trophy state of its game. Null when the bytes do not parse. + public async Task ScanAsync(DetectedSave save, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(save); + var bytes = await files.ReadAsync(save.DocumentId, cancellationToken).ConfigureAwait(false); + var facts = await Task.Run(() => Measure(save, bytes), cancellationToken).ConfigureAwait(false); + if (facts is null) return null; + foreach (var state in TrophyEngine.EvaluateGame(TrophyCatalog.ForGame(facts.GameLabel), facts)) + trophies.Store(state); + return facts; + } + + /// Scans every visible linked save; returns the facts per save that parsed. + public async Task> ScanAllAsync(IReadOnlyList saves, CancellationToken cancellationToken = default) + { + var scanned = new List(); + foreach (var save in saves.Where(s => s.Identity?.IsHidden != true)) + { + try + { + var facts = await ScanAsync(save, cancellationToken).ConfigureAwait(false); + if (facts is not null) scanned.Add(facts); + } + catch (Exception error) + { + AppLog.Error("trophies", $"Scanning {save.GameLabel} ({save.FileName}) failed", error); + } + } + return scanned; + } + + /// Reads one save's measurable facts; null when the engine cannot open it. + private SaveScanFacts? Measure(DetectedSave save, ReadOnlyMemory bytes) + { + try + { + using var session = engine.OpenSession(bytes, save.EngineHint); + var progress = session.GetDexProgress(); + var shinySpecies = new HashSet(); + var owned = 0; + foreach (var slot in session.Snapshot.Slots) + { + if (slot.Species is not { } species || species <= 0) continue; + owned++; + if (slot.IsShiny) shinySpecies.Add(species); + } + // The save's own playtime counter is the truth; the shelf string is only a + // fallback for formats whose stats the engine cannot open. + var stats = session.GetTrainerStats(); + var playtime = stats.SupportsPlayTime && (stats.PlayTimeHours > 0 || stats.PlayTimeMinutes > 0) + ? stats.PlayTimeHours + : TryParsePlaytime(save.PlayTime); + return new SaveScanFacts( + save.DocumentId, + save.EngineHint, + save.Generation, + progress.Caught, + progress.Seen, + progress.Total, + shinySpecies.Count, + owned, + playtime, + save.TrainerName); + } + catch (Exception error) + { + AppLog.Error("trophies", $"Measuring {save.GameLabel} ({save.FileName}) failed", error); + return null; + } + } + + /// The shelf's playtime strings: "123:45" (hours:minutes) or plain minutes. + private static int TryParsePlaytime(string? playtime) + { + if (string.IsNullOrEmpty(playtime)) return 0; + var colon = playtime.IndexOf(':'); + if (colon > 0 && int.TryParse(playtime[..colon], out var hours)) return hours; + return int.TryParse(playtime, out var minutes) ? minutes / 60 : 0; + } +} diff --git a/src/PKForge.App/Services/TrophyWatcherService.cs b/src/PKForge.App/Services/TrophyWatcherService.cs new file mode 100644 index 00000000..fcea4b4c --- /dev/null +++ b/src/PKForge.App/Services/TrophyWatcherService.cs @@ -0,0 +1,86 @@ +using System.Collections.Concurrent; +using PKForge.Domain; + +namespace PKForge.App.Services; + +/// +/// The automatic trophy watcher: whenever the app comes back to the foreground (typically +/// returning from an emulator), the linked saves are re-scanned off the UI thread and any +/// newly unlocked homemade trophy is announced. Announcements go to a queue the UI drains +/// and to the platform's local notification (Android notification channel), so the player +/// hears about a trophy even when the Trophies page is not open. +/// +public sealed class TrophyWatcherService( + TrophyScanService scanner, + ITrophyStore trophies) +{ + /// Raised on the UI thread with each newly unlocked trophy, in scan order. + public event Action? TrophyUnlocked; + + /// True while a background scan is in flight (the UI stays quiet about it). + public bool IsScanning => Volatile.Read(ref _scanning) != 0; + + private int _scanning; + private readonly ConcurrentDictionary _announced = new(StringComparer.Ordinal); + + /// + /// Scans every visible linked save and announces the trophies that were locked at the + /// last scan and are unlocked now. Safe to call repeatedly: one scan at a time, and a + /// scan while another is running is skipped (the next resume catches up). + /// + public async Task> ScanAndAnnounceAsync( + IReadOnlyList saves, + CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _scanning, 1) != 0) return []; + try + { + var unlocked = new List(); + foreach (var save in saves.Where(s => s.Identity?.IsHidden != true)) + { + var before = trophies.GetAll().Where(s => s.Unlocked).Select(s => s.TrophyId).ToHashSet(StringComparer.Ordinal); + var facts = await scanner.ScanAsync(save, cancellationToken).ConfigureAwait(false); + if (facts is null) continue; + foreach (var definition in TrophyCatalog.ForGame(facts.GameLabel)) + { + var state = trophies.Find(definition.Id); + if (state is not { Unlocked: true } || before.Contains(definition.Id)) continue; + if (!_announced.TryAdd(definition.Id, 0)) continue; + unlocked.Add(definition); + } + } + if (unlocked.Count > 0) + { + await AnnounceAsync(unlocked).ConfigureAwait(false); + foreach (var definition in unlocked) + MainThread.BeginInvokeOnMainThread(() => TrophyUnlocked?.Invoke(definition)); + } + return unlocked; + } + finally + { + Volatile.Write(ref _scanning, 0); + } + } + + /// The platform's local notification hook; Android shows a channel notification. + private static async Task AnnounceAsync(IReadOnlyList unlocked) + { + try + { + var notifier = IPlatformApplication.Current?.Services.GetService(); + if (notifier is null) return; + await notifier.NotifyAsync(unlocked).ConfigureAwait(false); + } + catch (Exception error) + { + AppLog.Info("trophies", $"Announcing {unlocked.Count} unlock(s) failed: {error.Message}"); + } + } +} + +/// Shows local notifications for trophy unlocks (implemented per platform). +public interface ITrophyNotifier +{ + Task NotifyAsync(IReadOnlyList unlocked); +} diff --git a/src/PKForge.App/ViewModels/RetroAchievementsViewModel.cs b/src/PKForge.App/ViewModels/RetroAchievementsViewModel.cs new file mode 100644 index 00000000..08b7b35d --- /dev/null +++ b/src/PKForge.App/ViewModels/RetroAchievementsViewModel.cs @@ -0,0 +1,192 @@ +using System.Collections.ObjectModel; +using System.Text.Json; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PKForge.App.Services; +using PKForge.Domain; + +namespace PKForge.App.ViewModels; + +/// +/// The RetroAchievements screen's state: the signed-in player, one progress card per +/// linked Gen 1-5 save, and the achievement list of the game under the cursor. RA game +/// ids are resolved by title from the console's game list (cached for the session) so +/// the mapping survives RA-side renumbering. +/// +public partial class RetroAchievementsViewModel( + IRetroCredentialStore credentials, + IRetroAchievementsClient client, + IRetroProgressStore cache) : ObservableObject +{ + private const string GameListUrl = "https://retroachievements.org/API/API_GetGameList.php"; + + private static readonly HttpClient Http = new(new SocketsHttpHandler + { + ConnectTimeout = TimeSpan.FromSeconds(15), + PooledConnectionLifetime = TimeSpan.FromMinutes(10), + }) + { Timeout = TimeSpan.FromSeconds(30) }; + + private readonly Dictionary> _consoleGames = []; + + [ObservableProperty] private string _status = Services.AppStrings.Fr ? "Connectez-vous à RetroAchievements pour voir les succès officiels." : "Sign in to RetroAchievements to see official achievements."; + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private bool _signedIn; + + public ObservableCollection Games { get; } = []; + + public sealed partial class RetroGameCard : ObservableObject + { + public int GameId { get; set; } + public string Title { get; set; } = ""; + public ObservableCollection Achievements { get; set; } = []; + public int Unlocked => Achievements.Count(a => a.Earned); + public int Total => Achievements.Count; + public int Percent => Total == 0 ? 100 : (int)Math.Round(Unlocked * 100.0 / Total); + } + + public string? CurrentUsername => credentials.Load()?.Username; + + private void AddGame(Domain.RetroGameProgress game) + { + var card = new RetroGameCard { GameId = game.GameId, Title = game.Title }; + foreach (var achievement in game.Achievements) card.Achievements.Add(achievement); + Games.Add(card); + } + + [RelayCommand] + public void SignOut() + { + credentials.Save(null); + Games.Clear(); + SignedIn = false; + Status = Services.AppStrings.SignedOut; + } + + [RelayCommand] + public async Task SignInAsync(string? apiKey) + { + if (IsBusy || string.IsNullOrWhiteSpace(apiKey)) return; + var username = CurrentUsername; + if (string.IsNullOrWhiteSpace(username)) + { + Status = Services.AppStrings.Fr ? "Saisissez d'abord votre nom d'utilisateur RetroAchievements." : "Enter your RetroAchievements username first."; + return; + } + try + { + IsBusy = true; + credentials.Save(new RetroCredentials(username.Trim(), apiKey.Trim())); + if (await client.VerifyCredentialsAsync()) + { + SignedIn = true; + Status = Services.AppStrings.SignedInAs(username); + } + else + { + credentials.Save(null); + Status = Services.AppStrings.RaBadCredentials; + } + } + catch (Exception error) + { + credentials.Save(null); + Status = Services.AppStrings.RaSignInFailed(error.Message); + } + finally + { + IsBusy = false; + } + } + + /// Sets the username before the API key is asked for. + public void RememberUsername(string username) => + credentials.Save(new RetroCredentials(username.Trim(), credentials.Load()?.ApiKey ?? "")); + + /// One console's game list from RA, cached per session (RA asks for aggressive caching). + private async Task> GameListAsync(int consoleId, CancellationToken token) + { + if (_consoleGames.TryGetValue(consoleId, out var cached)) return cached; + var auth = credentials.Load() ?? throw new InvalidOperationException("Sign in to RetroAchievements first."); + var url = $"{GameListUrl}?y={Uri.EscapeDataString(auth.ApiKey)}&i={consoleId}&f=1"; + using var response = await Http.GetAsync(url, token).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: token).ConfigureAwait(false); + var games = new List<(int, string, string)>(); + if (document.RootElement.ValueKind == JsonValueKind.Array) + { + foreach (var entry in document.RootElement.EnumerateArray()) + { + if (!entry.TryGetProperty("ID", out var id) || !id.TryGetInt32(out var gameId)) continue; + var title = entry.TryGetProperty("Title", out var t) ? t.GetString() ?? "" : ""; + games.Add((gameId, title, "")); + } + } + var list = (IReadOnlyList<(int, string, string)>)games.AsReadOnly(); + _consoleGames[consoleId] = list; + return list; + } + + /// Resolves one linked save to its RA game id, by console and short title. + private async Task ResolveGameIdAsync(DetectedSave save, CancellationToken token) + { + if (!RetroAchievementsRules.SupportsGeneration(save.Generation)) return null; + var consoleId = RetroAchievementsRules.ConsoleForGeneration[save.Generation]; + var games = await GameListAsync(consoleId, token).ConfigureAwait(false); + var shortName = RetroAchievementsRules.ShortName(save.EngineHint); + var consoleName = consoleId switch + { + 4 => "Game Boy", 5 => "Game Boy Color", 6 => "Game Boy Advance", 18 => "Nintendo DS", _ => "", + }; + foreach (var (id, title, _) in games) + if (RetroAchievementsRules.TitleMatches(title, consoleName, shortName)) return id; + return null; + } + + /// Refreshes every linked Gen 1-5 save's official achievement set. + public async Task RefreshForSavesAsync(IReadOnlyList saves, CancellationToken cancellationToken = default) + { + if (!SignedIn) + { + Games.Clear(); + return; + } + try + { + IsBusy = true; + Games.Clear(); + var seen = new HashSet(); + foreach (var save in saves.Where(s => !s.Identity?.IsHidden == true && RetroAchievementsRules.SupportsGeneration(s.Generation))) + { + var gameId = await ResolveGameIdAsync(save, cancellationToken).ConfigureAwait(false); + if (gameId is null || !seen.Add(gameId.Value)) continue; + var progress = await client.GetGameProgressAsync(gameId.Value, cancellationToken).ConfigureAwait(false); + var view = new RetroGameCard { GameId = progress.GameId, Title = progress.Title }; + foreach (var achievement in progress.Achievements) view.Achievements.Add(achievement); + Games.Add(view); + } + Status = Games.Count == 0 + ? Services.AppStrings.RaNoSets + : (Services.AppStrings.Fr ? $"{Games.Count} set(s) de jeu chargé(s)." : $"{Games.Count} game set(s) loaded."); + } + catch (Exception error) + { + Status = Services.AppStrings.Fr ? $"RetroAchievements injoignable : {error.Message}. La progression en cache est affichée." : $"Could not reach RetroAchievements: {error.Message}. Cached progress is shown."; + foreach (var progress in cache.GetAll()) + AddGame(progress); + } + finally + { + IsBusy = false; + } + } + + /// Fills the view from the offline cache, so the screen never opens empty. + public void LoadCached() + { + if (Games.Count > 0) return; + foreach (var progress in cache.GetAll()) + AddGame(progress); + } +} diff --git a/src/PKForge.App/ViewModels/SavePickerViewModel.cs b/src/PKForge.App/ViewModels/SavePickerViewModel.cs index dac5260b..f6636345 100644 --- a/src/PKForge.App/ViewModels/SavePickerViewModel.cs +++ b/src/PKForge.App/ViewModels/SavePickerViewModel.cs @@ -98,15 +98,7 @@ public static string GroupDetail(IReadOnlyList saves) return string.Join(" · ", parts); } - public static string EmulatorName(EmulatorKind kind) => kind switch - { - EmulatorKind.MelonDS => "melonDS", - EmulatorKind.DraStic => "DraStic", - EmulatorKind.PizzaBoyGba => "Pizza Boy GBA", - EmulatorKind.PizzaBoyGbc => "Pizza Boy GBC", - EmulatorKind.CitraMmj => "Citra MMJ", - _ => kind.ToString(), - }; + public static string EmulatorName(EmulatorKind kind) => EmulatorCatalog.Name(kind); } public partial class SavePickerViewModel : ObservableObject @@ -166,7 +158,7 @@ public SavePickerViewModel( ? "No diagnostic scan has run yet. Use Rescan games, then open Scan report again." : string.Join("\n", _scanDiagnostics); - [ObservableProperty] private string _status = "Link an emulator's storage to begin."; + [ObservableProperty] private string _status = Services.AppStrings.LinkStorageToBegin; [ObservableProperty] private bool _isBusy; private DateTimeOffset _busySince; @@ -244,7 +236,7 @@ public async Task RescanAsync() var roots = _roots.GetRoots(); if (roots.Count == 0) { - Status = "No storage units linked yet. Open Link and choose your platform and emulator."; + Status = Services.AppStrings.NoStorageLinkedYet; return; } @@ -268,7 +260,7 @@ public async Task RescanAsync() _scanDiagnostics.Add($"ROOT kind={root.Kind} name={root.DisplayName}"); _scanDiagnostics.Add($"TREE URI {root.TreeId}"); _scanningRoot = root.TreeId; - Status = $"Scanning {SaveDescriptions.EmulatorName(root.Kind)} unit · {root.DisplayName}…"; + Status = Services.AppStrings.ScanningUnit(SaveDescriptions.EmulatorName(root.Kind), root.DisplayName); try { if (_detection is IIncrementalEmulatorDetectionService incremental) @@ -309,15 +301,15 @@ public async Task RescanAsync() catch (Exception error) { _scanDiagnostics.Add($"ROOT FAILED {error}"); - Status = $"Scan of {SaveDescriptions.EmulatorName(root.Kind)} failed: {error.Message}"; + Status = Services.AppStrings.ScanFailed(SaveDescriptions.EmulatorName(root.Kind), error.Message); } } _scanningRoot = null; AppLog.Info("scan", $"{roots.Count} linked folder(s): {Saves.Count} save(s), {filesSeen} file(s) scanned, {_rejectedCandidates.Count} rejected"); RebuildGroups(); Status = Saves.Count == 0 - ? $"No games found. Scanned {filesSeen} file(s), {_rejectedCandidates.Count} looked like saves but did not parse." - : $"{Saves.Count} save(s) on the shelf. Scanned {filesSeen} file(s)."; + ? Services.AppStrings.NoGamesFound(filesSeen, _rejectedCandidates.Count) + : Services.AppStrings.SavesOnShelf(Saves.Count, filesSeen); } finally { @@ -487,15 +479,15 @@ private async Task LinkFileAsync() { _busyWith = "opening a save file"; IsBusy = true; - Status = "Select the save file to link…"; + Status = Services.AppStrings.SelectSaveFile; var document = await _filePicker.PickSaveAsync(); - if (document is null) { Status = "Link cancelled."; return; } - Status = $"Linking {document.DisplayName}…"; + if (document is null) { Status = Services.AppStrings.LinkCancelled; return; } + Status = Services.AppStrings.LinkingFile(document.DisplayName); await _sessions.OpenAsync(document); _boxBrowser.RefreshFromCurrentSession(); OpenedSave = true; CompleteSetup(); - Status = "Storage linked."; + Status = Services.AppStrings.StorageLinked; } catch (Exception error) { @@ -503,7 +495,7 @@ private async Task LinkFileAsync() // to the copyable scan report so a player can send it. _scanDiagnostics.Add($"OPEN FAILED (single file) {error}"); AppLog.Error("open", "Linking a single save file failed", error); - Status = $"Could not link save: {error.Message}"; + Status = Services.AppStrings.CouldNotLinkSave(error.Message); } finally { @@ -531,26 +523,24 @@ private async Task AddRootAndScanAsync(EmulatorKind kind) var emulator = SaveDescriptions.EmulatorName(kind); if (found > 0) { - LinkReported?.Invoke($"{emulator} linked", - $"Found {found} Pokémon save{(found == 1 ? "" : "s")} in {folder.DisplayName}."); + LinkReported?.Invoke(Services.AppStrings.EmulatorLinked(emulator), + Services.AppStrings.FoundSaves(found, folder.DisplayName)); return; } var rejected = _rejectedCandidates.Count(r => r.StartsWith($"{kind}:", StringComparison.Ordinal)); - LinkReported?.Invoke($"No saves found in {folder.DisplayName}", - (rejected > 0 ? $"{rejected} file(s) looked like saves but could not be read. " : "") - + WhereToPoint(kind) + " The folder stays linked; unlink it from Manage linked storage."); + LinkReported?.Invoke(Services.AppStrings.NoSavesFoundIn(folder.DisplayName, rejected), + (rejected > 0 && !Services.AppStrings.Fr ? $"{rejected} file(s) looked like saves but could not be read. " : "") + + WhereToPoint(kind) + Services.AppStrings.FolderStaysLinked); } /// Which folder to pick, per emulator, for the "nothing found" message. private static string WhereToPoint(EmulatorKind kind) => kind switch { - EmulatorKind.Azahar or EmulatorKind.CitraMmj => - "Pick the emulator's user folder, the one that contains sdmc (sdmc or Nintendo 3DS work too). Saves live in sdmc/Nintendo 3DS/…/title/00040000//data/00000001/main.", - EmulatorKind.MelonDS => - "Pick the folder that holds your .sav files: next to your ROMs by default, or the save folder set in melonDS's settings.", - EmulatorKind.DraStic => "Pick DraStic's backup folder (its .dsv battery saves) or the DraStic data folder.", - EmulatorKind.Eden => "Pick Eden's files root, the folder that contains its emulated storage.", - _ => "Pick the folder that holds your save files.", + EmulatorKind.Azahar or EmulatorKind.CitraMmj => Services.AppStrings.GuidanceAzahar, + EmulatorKind.MelonDS => Services.AppStrings.GuidanceMelonDs, + EmulatorKind.DraStic => Services.AppStrings.GuidanceDrastic, + EmulatorKind.Eden => Services.AppStrings.GuidanceEden, + _ => Services.AppStrings.GuidanceDefault, }; public async Task OpenAsync(DetectedSave save) @@ -561,20 +551,20 @@ public async Task OpenAsync(DetectedSave save) { _busyWith = $"opening {save.GameLabel}"; IsBusy = true; - Status = $"Connecting to {save.GameLabel}…"; + Status = Services.AppStrings.ConnectingTo(save.GameLabel); // EngineHint carries the chosen game (FireRed vs LeafGreen), never the custom // name; the session service reads the chosen engine route from the store. await _sessions.OpenAsync(new PickedDocument(save.DocumentId, save.EngineHint)); _boxBrowser.RefreshFromCurrentSession(); OpenedSave = true; AppLog.Info("open", $"Opened {save.GameLabel} ({save.Emulator}, gen {save.Generation})"); - Status = "Connected."; + Status = Services.AppStrings.Connected; } catch (Exception error) { _scanDiagnostics.Add($"OPEN FAILED {save.FileName} {error}"); AppLog.Error("open", $"Opening {save.GameLabel} ({save.Emulator}, {save.FileName}) failed", error); - Status = $"Could not connect: {error.Message}"; + Status = Services.AppStrings.CouldNotConnect(error.Message); } finally { diff --git a/src/PKForge.App/Views/BackupHistoryPage.cs b/src/PKForge.App/Views/BackupHistoryPage.cs index 9214743f..cab07ab7 100644 --- a/src/PKForge.App/Views/BackupHistoryPage.cs +++ b/src/PKForge.App/Views/BackupHistoryPage.cs @@ -17,15 +17,15 @@ public BackupHistoryPage(BackupHistoryViewModel viewModel) { _viewModel = viewModel; BindingContext = viewModel; - Title = "Restore points"; + Title = AppStrings.RestorePointsTitle; BackgroundColor = UiTokens.Housing; NavigationPage.SetHasNavigationBar(this, false); - var back = Kit.MiniCapsule("Back", UiTokens.Ink0); + var back = Kit.MiniCapsule(AppStrings.Back, UiTokens.Ink0); back.WidthRequest = 72; back.Clicked += async (_, _) => await Navigation.PopAsync(); - var header = Kit.HeaderBar("Restore points"); + var header = Kit.HeaderBar(AppStrings.RestorePointsTitle); header.VerticalOptions = LayoutOptions.Center; var titleRow = new Grid { @@ -193,7 +193,7 @@ private async Task ConfirmRestoreAsync(BackupInfo backup) { if (!_viewModel.CanRestore) { - await PadMenu.ShowAsync(_hostGrid, "Restore", "Connect to a save first - restoring writes into the connected save.", "OK"); + await PadMenu.ShowAsync(_hostGrid, AppStrings.Restore, AppStrings.Fr ? "Connectez-vous d'abord à une sauvegarde - la restauration écrit dans la sauvegarde connectée." : "Connect to a save first - restoring writes into the connected save.", AppStrings.Ok); return; } @@ -205,9 +205,9 @@ private async Task ConfirmRestoreAsync(BackupInfo backup) } var confirmed = await PadMenu.ConfirmAsync(_hostGrid, - "Restore this point?", - $"Write the {backup.CreatedUtc:yyyy-MM-dd HH:mm} UTC restore point into the connected save? The current state is preserved as a new restore point first.", - "Restore"); + AppStrings.RestoreThisPoint, + AppStrings.Fr ? $"Écrire le point de restauration du {backup.CreatedUtc:yyyy-MM-dd HH:mm} UTC dans la sauvegarde connectée ? L'état actuel est d'abord préservé comme nouveau point de restauration." : $"Write the {backup.CreatedUtc:yyyy-MM-dd HH:mm} UTC restore point into the connected save? The current state is preserved as a new restore point first.", + AppStrings.Restore); if (confirmed) await _viewModel.RestoreAsync(backup); } @@ -219,14 +219,16 @@ private async Task ConfirmRestoreAsync(BackupInfo backup) /// private async Task ConfirmHardcoreRestoreAsync(BackupInfo backup) { - const string Cancel = "Cancel (keep current save)"; - const string Anyway = "Restore anyway"; + var Cancel = AppStrings.CancelKeepCurrent; + var Anyway = AppStrings.RestoreAnyway; var check = await _viewModel.CheckResurrectionAsync(backup); if (check is { IsSafe: true }) - return await PadMenu.ConfirmAsync(_hostGrid, "Restore this point?", - $"Write the {backup.CreatedUtc:yyyy-MM-dd HH:mm} UTC restore point into the connected save? " + + return await PadMenu.ConfirmAsync(_hostGrid, AppStrings.RestoreThisPoint, + AppStrings.Fr ? $"Écrire le point de restauration du {backup.CreatedUtc:yyyy-MM-dd HH:mm} UTC dans la sauvegarde connectée ? " + + "Aucun Pokémon parti de cette sauvegarde depuis ne reviendrait. L'état actuel est d'abord préservé comme nouveau point de restauration." + : $"Write the {backup.CreatedUtc:yyyy-MM-dd HH:mm} UTC restore point into the connected save? " + "No Pokémon that has left this save since then would come back. The current state is preserved as a new restore point first.", - "Restore"); + AppStrings.Restore); string message; if (check is null) @@ -248,7 +250,7 @@ private async Task ConfirmHardcoreRestoreAsync(BackupInfo backup) "Release or withdraw those copies yourself if you restore."; } - var choice = await PadMenu.ShowAsync(_hostGrid, "Duplication risk", message, Cancel, Anyway); + var choice = await PadMenu.ShowAsync(_hostGrid, AppStrings.Fr ? "Risque de duplication" : "Duplication risk", message, Cancel, Anyway); return choice == Anyway; } diff --git a/src/PKForge.App/Views/BankPage.cs b/src/PKForge.App/Views/BankPage.cs index 50d4ffc7..5703e145 100644 --- a/src/PKForge.App/Views/BankPage.cs +++ b/src/PKForge.App/Views/BankPage.cs @@ -275,11 +275,11 @@ private async Task ExportArchiveAsync() } var choice = await PadMenu.ShowAsync(_hostGrid, "Export archive", "A folder of .pk files plus a manifest — readable by PKHeX and PKForge alike.", - new PadOption("Whole bank", IconPath: "bank"), - new PadOption("This box only", IconPath: "box")); + new PadOption(AppStrings.Fr ? "Toute la banque" : "Whole bank", IconPath: "bank"), + new PadOption(AppStrings.Fr ? "Cette boîte" : "This box only", IconPath: "box")); if (choice is null) return; - var candidates = choice == "This box only" + var candidates = choice == (AppStrings.Fr ? "Cette boîte" : "This box only") ? _bank.GetAll().Where(e => e.Box == _boxIndex).ToArray() : null; if (candidates is { Length: 0 }) @@ -287,7 +287,7 @@ private async Task ExportArchiveAsync() _boxViewModel.Status = "Nothing to export — this box is empty."; return; } - await ExportToFolderAsync(candidates, choice == "This box only" ? "this box" : "the whole bank"); + await ExportToFolderAsync(candidates, choice == (AppStrings.Fr ? "Cette boîte" : "This box only") ? (AppStrings.Fr ? "cette boîte" : "this box") : (AppStrings.Fr ? "toute la banque" : "the whole bank")); } /// Writes .pk files plus the manifest into a picked folder - the archive path @@ -333,10 +333,10 @@ private async Task ExportPksmAsync(List? selection) { var scope = await PadMenu.ShowAsync(_hostGrid, "Export PKSM bank", "Box layout and box names are kept. Gen 9, BDSP and Legends mons have no PKSM slot and are left out.", - new PadOption("Whole bank", IconPath: "bank"), - new PadOption("This box only", IconPath: "box")); + new PadOption(AppStrings.Fr ? "Toute la banque" : "Whole bank", IconPath: "bank"), + new PadOption(AppStrings.Fr ? "Cette boîte" : "This box only", IconPath: "box")); if (scope is null) return; - var box = scope == "This box only"; + var box = scope == (AppStrings.Fr ? "Cette boîte" : "This box only"); entries = box ? _bank.GetAll().Where(e => e.Box == _boxIndex).ToArray() : _bank.GetAll(); (layout, label) = (PksmExportLayout.KeepPositions, box ? $"box {_boxIndex + 1:00}" : "the whole bank"); } @@ -716,30 +716,30 @@ private async Task OpenCursorMenuAsync() // Hardcore mode keeps every move and drops every copy: the read-only Summary is always // offered, the editor only when editing is allowed; Duplicate and Copy to game are not offered at all. - var options = new List { new("Summary", IconPath: "info", Detail: "A read-only look at this Pokémon.") }; + var options = new List { new(AppStrings.Fr ? "Résumé" : "Summary", IconPath: "info", Detail: "A read-only look at this Pokémon.") }; if (Allowed(SaveAction.EditMon)) { options.Add(new PadOption("Edit", IconPath: "editor")); options.Add(new PadOption("Evolve…", IconPath: "evolve", Detail: "Evolves it right here in the bank.")); } - options.Add(new PadOption("Send to Poképark", IconPath: "park", Detail: "It joins the Poképark visitors. The bank is not changed.")); - if (Allowed(SaveAction.Duplicate)) options.Add(new PadOption("Duplicate", IconPath: "copy", Detail: "An exact copy goes into the first free bank slot.")); + options.Add(new PadOption(AppStrings.Fr ? "Envoyer au Poképark" : "Send to Poképark", IconPath: "park", Detail: "It joins the Poképark visitors. The bank is not changed.")); + if (Allowed(SaveAction.Duplicate)) options.Add(new PadOption(AppStrings.Fr ? "Dupliquer" : "Duplicate", IconPath: "copy", Detail: "An exact copy goes into the first free bank slot.")); options.Add(new PadOption("Send to game…", IconPath: "send", Detail: "Moves it into a game's boxes and out of the bank.")); if (Allowed(SaveAction.Duplicate)) options.Add(new PadOption("Copy to game…", IconPath: "copy", Detail: "Puts a copy in a game's boxes; this one stays in the bank.")); options.Add(new PadOption("Move (carry)", IconPath: "move", Detail: "Pick it up and drop it in another slot.")); - options.Add(new PadOption("Export .pk file", IconPath: "export", Detail: "Saves this Pokémon as a single .pk file.")); + options.Add(new PadOption(AppStrings.Fr ? "Exporter en .pk" : "Export .pk file", IconPath: "export", Detail: "Saves this Pokémon as a single .pk file.")); options.Add(new PadOption("Release from bank", IconPath: "release", Detail: "Removes it from the bank for good; there is no undo.")); - options.Add(new PadOption("Release to the Wilds", IconPath: "release", Detail: "It leaves the bank and lives on in the wild lands around the Poképark. It may come back to visit.")); + options.Add(new PadOption(AppStrings.Fr ? "Relâcher dans les terres sauvages" : "Release to the Wilds", IconPath: "release", Detail: "It leaves the bank and lives on in the wild lands around the Poképark. It may come back to visit.")); var choice = await PadMenu.ShowAsync(_hostGrid, entry.Info.Nickname, Note($"From {entry.Info.SourceName} · Gen {entry.Info.Generation} · deposited {entry.AddedUtc:yyyy-MM-dd}"), options.ToArray()); switch (choice) { - case "Send to Poképark": + case var c when c == (AppStrings.Fr ? "Envoyer au Poképark" : "Send to Poképark"): _boxViewModel.Status = IPlatformApplication.Current!.Services.GetRequiredService().AddBankVisitor(entry.Id); await PadMenu.ShowAsync(_hostGrid, "Poképark", _boxViewModel.Status, new PadOption("OK")); return; - case "Summary": + case var c when c == (AppStrings.Fr ? "Résumé" : "Summary"): await OpenSummaryAsync(); return; case "Edit": @@ -748,7 +748,7 @@ private async Task OpenCursorMenuAsync() case "Evolve…": await EvolveEntryAsync(entry); return; - case "Duplicate": + case var c when c == (AppStrings.Fr ? "Dupliquer" : "Duplicate"): if (await DeniedAsync(SaveAction.Duplicate)) return; var clone = _bank.Add(_bank.GetData(entry.Id), entry.Info); _boxIndex = clone.Box; @@ -770,7 +770,7 @@ private async Task OpenCursorMenuAsync() _carryId = entry.Id; _canvas.InvalidateSurface(); return; - case "Export .pk file": + case var c when c == (AppStrings.Fr ? "Exporter en .pk" : "Export .pk file"): await ExportAsync(entry); return; case "Release from bank": @@ -783,7 +783,7 @@ private async Task OpenCursorMenuAsync() UpdatePreview(); _canvas.InvalidateSurface(); return; - case "Release to the Wilds": + case var c when c == (AppStrings.Fr ? "Relâcher dans les terres sauvages" : "Release to the Wilds"): await ReleaseToWildsAsync(entry); return; } @@ -870,7 +870,7 @@ private async Task ShowBankActionsAsync() case "Move to a position…": await MoveSelectionToPositionAsync(selection); return; - case "Duplicate" or "Duplicate all": + case var dup when dup == (AppStrings.Fr ? "Dupliquer" : "Duplicate") || dup == (AppStrings.Fr ? "Tout dupliquer" : "Duplicate all"): if (await DeniedAsync(SaveAction.Duplicate)) return; DuplicateSelection(selection); return; @@ -1429,13 +1429,15 @@ private async Task OpenEmptySlotMenuAsync() // mode fills the vault only by moving mons in from a save. if (await DeniedAsync(SaveAction.CreateMon)) return; var choice = await PadMenu.ShowAsync(_hostGrid, "Add to bank", null, - new PadOption("Create a Pokémon", IconPath: "create", Detail: "Build a legal Pokémon step by step."), - new PadOption("Paste a Showdown set", IconPath: "script", Detail: "Turns a Pokémon Showdown text set into a Pokémon."), + new PadOption(AppStrings.Fr ? "Créer un Pokémon" : "Create a Pokémon", IconPath: "create", Detail: "Build a legal Pokémon step by step."), + new PadOption(AppStrings.Fr ? "Coller un set Showdown" : "Paste a Showdown set", IconPath: "script", Detail: "Turns a Pokémon Showdown text set into a Pokémon."), new PadOption("Import .pk file", IconPath: "import", Detail: "Adds Pokémon from .pk files you pick."), new PadOption("Scan a .pk QR", IconPath: "scan", Detail: "Pick a picture of a .pk QR code to add its Pokémon.")); - switch (choice) + // The era-picker flow needs a re-dispatch: the era is picked first, then the + // switch below routes on the unchanged choice (goto case needs a constant). + if (choice == (AppStrings.Fr ? "Créer un Pokémon" : "Create a Pokémon") || choice == (AppStrings.Fr ? "Coller un set Showdown" : "Paste a Showdown set")) { - case "Create a Pokémon" or "Paste a Showdown set" when session is null: + if (session is null) { // No game connected: pick the format, generate against a blank save with // a placeholder identity (OT "PKForge", editable afterwards in the editor). @@ -1448,10 +1450,11 @@ private async Task OpenEmptySlotMenuAsync() var generation = Array.IndexOf(eras, era) + 1; var engine = IPlatformApplication.Current!.Services.GetRequiredService(); session = engine.OpenBlankSession(generation); - if (choice == "Create a Pokémon") goto case "Create a Pokémon"; - goto case "Paste a Showdown set"; } - case "Create a Pokémon": + } + switch (choice) + { + case var c when c == (AppStrings.Fr ? "Créer un Pokémon" : "Create a Pokémon"): { var services = IPlatformApplication.Current!.Services; var data = services.GetRequiredService(); @@ -1468,7 +1471,7 @@ private async Task OpenEmptySlotMenuAsync() finally { overlay.Close(); } return; } - case "Paste a Showdown set": + case var c when c == (AppStrings.Fr ? "Coller un set Showdown" : "Paste a Showdown set"): { var text = await TextPopup.ShowAsync(_hostGrid, "Paste a Showdown set", "The set becomes a legal mon stored in the bank."); if (string.IsNullOrWhiteSpace(text)) return; diff --git a/src/PKForge.App/Views/BoxBrowserPage.cs b/src/PKForge.App/Views/BoxBrowserPage.cs index 4dbc2012..af909868 100644 --- a/src/PKForge.App/Views/BoxBrowserPage.cs +++ b/src/PKForge.App/Views/BoxBrowserPage.cs @@ -154,7 +154,7 @@ or nameof(BoxBrowserViewModel.SelectedSlot) or nameof(BoxBrowserViewModel.Visibl _canvas.InvalidateSurface(); _boxBar.InvalidateSurface(); } - // The footer's labels follow the selection ("Select box" / "Unmark box", the count). + // The footer's labels follow the selection (AppStrings.Fr ? "Sélectionner la boîte" : "Select box" / "Unmark box", the count). if (args.PropertyName is nameof(BoxBrowserViewModel.MarkedCount) or nameof(BoxBrowserViewModel.SelectMode) or nameof(BoxBrowserViewModel.BoxIndex) or nameof(BoxBrowserViewModel.VisibleSlots)) { @@ -270,13 +270,13 @@ private void SetStorageFooter() => _footerHost.Content = DsChrome.Footer( ("A", _viewModel.CarrySource is null ? "Grab" : "Place", null), ("-", "Multi-select", EnterSelectMode), ("LR", "Box", () => OnPadButton(PadButton.R)), - ("X", "Tools", () => _ = ShowToolsAsync()), ("Y", "Save data", () => _ = ShowSaveDataAsync()), + ("X", AppStrings.Fr ? "Outils" : "Tools", () => _ = ShowToolsAsync()), ("Y", "Save data", () => _ = ShowSaveDataAsync()), ("+", "Menu", () => OpenCursorMenu()), ("B", _viewModel.CarrySource is null ? "Back" : "Cancel", () => OnPadButton(PadButton.B))); private void SetSelectFooter() => _footerHost.Content = DsChrome.Footer( ("A", "Mark · hold for area", () => { OnPadButton(PadButton.A); OnPadReleased(PadButton.A); }), - ("Y", _viewModel.CurrentBoxFullyMarked ? "Unmark box" : "Select box", () => OnPadButton(PadButton.Y)), + ("Y", _viewModel.CurrentBoxFullyMarked ? (AppStrings.Fr ? "Dé-marquer la boîte" : "Unmark box") : (AppStrings.Fr ? "Sélectionner la boîte" : "Select box"), () => OnPadButton(PadButton.Y)), ("X", $"Actions ({_viewModel.MarkedCount})", () => _ = ShowOrganizerMenuAsync()), ("+", "Boxes", EnterBoxManageMode), ("LR", "Box", () => OnPadButton(PadButton.R)), @@ -636,11 +636,11 @@ private async Task ShowToolsAsync() var options = new List { new("Multi-select (- button)", IconPath: "select", Detail: "Mark several Pokémon to move, copy or edit them together."), - new("Box manager…", IconPath: "box", Detail: "Reorder, copy, lock or empty whole boxes."), + new(AppStrings.Fr ? "Gestion des boîtes…" : "Box manager…", IconPath: "box", Detail: AppStrings.Fr ? "Réorganiser, copier, verrouiller ou vider des boîtes entières." : "Reorder, copy, lock or empty whole boxes."), }; options.AddRange(Menu( Allowed(SaveAction.CreateMon, new("Import .pk files", IconPath: "import", Detail: "Pick Pokémon files to fill this box's empty slots.")), - Allowed(SaveAction.CreateMon, new("Import Showdown team", IconPath: "script", Detail: "Paste Showdown sets, see their legality, then place them in empty slots.")), + Allowed(SaveAction.CreateMon, new(AppStrings.Fr ? "Importer une équipe Showdown" : "Import Showdown team", IconPath: "script", Detail: "Paste Showdown sets, see their legality, then place them in empty slots.")), new("Export box to Showdown", IconPath: "script", Detail: "This box as Showdown text to copy or share."), Allowed(SaveAction.CreateMon, new("Generate Living Dex", IconPath: "create", Detail: "Replaces every box with one legal Pokémon of each species.")), new("How to get a Pokémon…", IconPath: "map", Detail: "How to obtain a species in the game you are editing."), @@ -653,20 +653,20 @@ private async Task ShowToolsAsync() if (boxTools) { options.AddRange(Menu( - Allowed(SaveAction.BatchEdit, new("Battle prep", IconPath: "battle", Detail: "Heal, max PP or set levels for the party or every box.")), + Allowed(SaveAction.BatchEdit, new(AppStrings.Fr ? "Préparation de combat" : "Battle prep", IconPath: "battle", Detail: "Heal, max PP or set levels for the party or every box.")), Allowed(SaveAction.BatchEdit, new("Batch editor", IconPath: "batch", Detail: "Change level, IVs, EVs, healing and more on many Pokémon at once.")), Allowed(SaveAction.BatchEdit, new("Batch rename / OT…", IconPath: "rename", Detail: "Reset nicknames, or make you the original trainer, on many Pokémon.")), Allowed(SaveAction.CreateMon, new("Random team…", IconPath: "dice", Detail: "Roll a random legal team into the party or empty box slots.")))); } options.AddRange(Menu( Allowed(SaveAction.BatchEdit, new("Presets…", IconPath: "preset", Detail: "One change, like Level 100 or perfect IVs, on a box or all boxes.")), - new("Trainer profiles…", IconPath: "profile", Detail: "Save trainer details and give them to Pokémon as their original trainer."), + new(AppStrings.Fr ? "Profils de dresseur…" : "Trainer profiles…", IconPath: "profile", Detail: "Save trainer details and give them to Pokémon as their original trainer."), new("Legality check", IconPath: "check", Detail: "Check every Pokémon in the save and repair the illegal ones."), new("Audit report", IconPath: "report", Detail: "Find cloned Pokémon and impossible values across the save."), new("Nuzlocke report", IconPath: "skull", Detail: "The first catch on each route, from where each Pokémon was met."), new("Collection dex…", IconPath: "pokedex", Detail: "Living dex tracker for the Bank and this save, normal and shiny."))); if (boxTools) - options.Add(new("Sort boxes…", IconPath: "sort", Detail: "Order this box or all boxes by dex number, level, type and more.")); + options.Add(new(AppStrings.Fr ? "Trier les boîtes…" : "Sort boxes…", IconPath: "sort", Detail: "Order this box or all boxes by dex number, level, type and more.")); var choice = await PadMenu.ShowAsync(_hostGrid, "Storage tools", Note(null), options.ToArray()); switch (choice) { @@ -676,7 +676,7 @@ private async Task ShowToolsAsync() case "Import .pk files": await BulkImportAsync(); return; - case "Import Showdown team": + case var c when c == (AppStrings.Fr ? "Importer une équipe Showdown" : "Import Showdown team"): await ImportShowdownTeamAsync(); return; case "Export box to Showdown": @@ -700,7 +700,7 @@ private async Task ShowToolsAsync() case "IV ranking…": await ShowIvRankingAsync(); return; - case "Battle prep": + case var c when c == (AppStrings.Fr ? "Préparation de combat" : "Battle prep"): await ShowBattlePrepAsync(); return; case "Egg factory…": @@ -732,7 +732,7 @@ private async Task ShowToolsAsync() case "Presets…": await ShowPresetsMenuAsync(); return; - case "Trainer profiles…": + case var c when c == (AppStrings.Fr ? "Profils de dresseur…" : "Trainer profiles…"): await ShowTrainerProfilesAsync(); return; case "Legality check": @@ -744,10 +744,10 @@ private async Task ShowToolsAsync() case "Nuzlocke report": await ShowNuzlockeReportAsync(); return; - case "Box manager…": + case var c when c == (AppStrings.Fr ? "Gestion des boîtes…" : "Box manager…"): EnterBoxManageMode(); return; - case "Sort boxes…": + case var c when c == (AppStrings.Fr ? "Trier les boîtes…" : "Sort boxes…"): await ShowSortMenuAsync(); return; case "Collection dex…": @@ -882,7 +882,7 @@ private async Task ShowBoxBulkActionsAsync() if (_boxHeld || _boxManageBusy) return; var selected = _markedBoxes.Count == 0 ? new[] { _viewModel.BoxIndex } : _markedBoxes.OrderBy(x => x).ToArray(); var noun = selected.Length == 1 ? $"box {selected[0] + 1:00}" : $"{selected.Length} selected boxes"; - var emptyLabel = _markedBoxes.Count > 0 ? "Empty selected boxes" : "Empty all boxes"; + var emptyLabel = _markedBoxes.Count > 0 ? AppStrings.Fr ? "Vider les boîtes sélectionnées" : "Empty selected boxes" : "Empty all boxes"; var choice = await PadMenu.ShowAsync(_hostGrid, $"Box actions · {noun}", Note("No selection means the current box. Every write creates a restore point."), Menu( @@ -890,7 +890,7 @@ private async Task ShowBoxBulkActionsAsync() new PadOption("Mark all boxes", IconPath: "selectall", Detail: "Select every box for the actions in this menu."), new PadOption("Lock / Unlock box(es)", IconPath: "padlock", Detail: "Locked boxes are skipped by sorting, batch edits and emptying."), Allowed(SaveAction.Duplicate, new("Copy box(es)…", IconPath: "copy", Detail: "Copy these boxes over other boxes, replacing what is there.")), - new PadOption("Delete box(es) (rescue Pokémon)", IconPath: "delete", Detail: "Removes the boxes; their Pokémon move to free slots first."), + new PadOption(AppStrings.Fr ? "Supprimer la/des boîte(s) (Pokémon secourus)" : "Delete box(es) (rescue Pokémon)", IconPath: "delete", Detail: "Removes the boxes; their Pokémon move to free slots first."), new PadOption(emptyLabel, IconPath: "clear", Detail: "Releases every Pokémon in the boxes. The party is not touched."), new PadOption("Clear box marks", IconPath: "deselect"), // Name/wallpaper act on the box under the cursor (PKHeX SAV_BoxLayout edits one box at a time). @@ -945,12 +945,14 @@ private async Task ShowBoxBulkActionsAsync() return; } var lockedTargets = selected.Where(box => DocumentId is not null && Protection.IsBoxLocked(DocumentId!, box)).ToList(); - if ((choice == "Empty selected boxes" || choice == "Delete box(es) (rescue Pokémon)") && lockedTargets.Count > 0) + if ((choice == (AppStrings.Fr ? "Vider les boîtes sélectionnées" : "Empty selected boxes") + || choice == (AppStrings.Fr ? "Supprimer la/des boîte(s) (Pokémon secourus)" : "Delete box(es) (rescue Pokémon)")) + && lockedTargets.Count > 0) { _viewModel.Status = $"{lockedTargets.Count} target box(ES) are locked"; return; } - if (choice == emptyLabel && choice == "Empty selected boxes") + if (choice == emptyLabel && choice == (AppStrings.Fr ? "Vider les boîtes sélectionnées" : "Empty selected boxes")) { var confirmed = await PadMenu.ConfirmAsync(_hostGrid, "Empty selected boxes?", $"Release every Pokémon in {selected.Length} box(es). A restore point is created first.", "Empty"); @@ -1002,7 +1004,7 @@ await _viewModel.RunMutationAsync(session => return new GenerationOutcome(true, $"Copied {copies.Length} box(es) starting at box {start + 1:00}."); }, Math.Max(0, _viewModel.SelectedSlot), refreshSlot: false, action: SaveAction.Duplicate); } - else if (choice == "Delete box(es) (rescue Pokémon)") + else if (choice == (AppStrings.Fr ? "Supprimer la/des boîte(s) (Pokémon secourus)" : "Delete box(es) (rescue Pokémon)")) { var rescueNote = selected.Length >= _viewModel.BoxCount ? "Every box is selected: rescued Pokémon have nowhere to go, so they are released." @@ -1171,7 +1173,7 @@ private async Task ShowOrganizerMenuAsync() var options = new List { new(_viewModel.CurrentBoxFullyMarked ? $"Unmark {page}" : $"Select all in {page}", IconPath: "selectall"), - new("Select all boxes", IconPath: "all"), + new(AppStrings.Fr ? "Tout sélectionner" : "Select all boxes", IconPath: "all"), new($"Invert {page}", IconPath: "invert", Detail: "Swap marked and unmarked Pokémon here."), }; if (count > 0) @@ -1180,16 +1182,16 @@ private async Task ShowOrganizerMenuAsync() options.AddRange(Menu( new PadOption("Move to box…", IconPath: "move"), new PadOption("Move to Bank", IconPath: "bank", Detail: "They leave this save and are stored in the Bank."), - Allowed(SaveAction.Duplicate, new("Copy to Bank", IconPath: "bank", Detail: "Copies go to the Bank; the originals stay here.")), + Allowed(SaveAction.Duplicate, new(AppStrings.CopyToBank, IconPath: "bank", Detail: AppStrings.Fr ? "Les copies vont dans la Banque ; les originaux restent ici." : "Copies go to the Bank; the originals stay here.")), new PadOption("Move to another game…", IconPath: "send", Detail: "They leave this save and join another linked game."), Allowed(SaveAction.Duplicate, new("Copy to another game…", IconPath: "copy", Detail: "Copies join another linked game; the originals stay here.")), - Allowed(SaveAction.Duplicate, new("Duplicate", IconPath: "copy", Detail: "Clone the marked Pokémon into the first free box slots.")), + Allowed(SaveAction.Duplicate, new(AppStrings.Fr ? "Dupliquer" : "Duplicate", IconPath: "copy", Detail: "Clone the marked Pokémon into the first free box slots.")), new PadOption("Export (.pk files)", IconPath: "export", Detail: "Share each marked Pokémon as its own file."), - new PadOption("Release", IconPath: "release"))); + new PadOption(AppStrings.Release, IconPath: "release"))); options.AddRange(BulkOptions()); } options.Add(new("Mark Pokémon holding…", IconPath: "item", Detail: "Find Pokémon holding an item, then mark them or go to one.")); - options.Add(new("Box manager…", IconPath: "box", Detail: "Reorder, copy, lock or empty whole boxes.")); + options.Add(new(AppStrings.Fr ? "Gestion des boîtes…" : "Box manager…", IconPath: "box", Detail: AppStrings.Fr ? "Réorganiser, copier, verrouiller ou vider des boîtes entières." : "Reorder, copy, lock or empty whole boxes.")); options.Add(new("Back to move mode", IconPath: "confirm", Detail: "Leave multi-select.")); var choice = await PadMenu.ShowAsync(_hostGrid, $"Multi-select · {count} marked", @@ -1205,7 +1207,7 @@ private async Task ShowOrganizerMenuAsync() case "Select all in this box" or "Select all in the party" or "Unmark this box" or "Unmark the party": _viewModel.ToggleBoxMarks(); break; - case "Select all boxes": + case var c when c == (AppStrings.Fr ? "Tout sélectionner" : "Select all boxes"): _viewModel.MarkAllBoxes(); break; case "Invert this box" or "Invert the party": @@ -1231,16 +1233,16 @@ private async Task ShowOrganizerMenuAsync() case "Move to another game…": await SendSelectionToGameAsync(copy: false); break; - case "Copy to another game…": + case var c when c == AppStrings.CopyToAnotherGame: await SendSelectionToGameAsync(copy: true); break; - case "Duplicate": + case var c when c == (AppStrings.Fr ? "Dupliquer" : "Duplicate"): await DuplicateSelectionAsync(); break; case "Move to Bank": await SendSelectionToBankAsync(copy: false); break; - case "Copy to Bank": + case var c when c == AppStrings.CopyToBank: await SendSelectionToBankAsync(copy: true); break; case "Export (.pk files)": @@ -1257,10 +1259,10 @@ await Share.Default.RequestAsync(new ShareMultipleFilesRequest _viewModel.Status = $"Exported {paths.Count} Pokémon."; break; } - case "Release": + case var c when c == AppStrings.Release: await ReleaseSelectionAsync(); break; - case "Box manager…": + case var c when c == (AppStrings.Fr ? "Gestion des boîtes…" : "Box manager…"): EnterBoxManageMode(); return; case "Back to move mode": @@ -1471,7 +1473,7 @@ await _viewModel.RunMutationAsync(session => } /// Paste a full Showdown team: every set is previewed with its legality, then placed into chosen empty slots. - private Task ImportShowdownTeamAsync() => ImportShowdownPreviewedAsync("Import Showdown team"); + private Task ImportShowdownTeamAsync() => ImportShowdownPreviewedAsync(AppStrings.Fr ? "Importer une équipe Showdown" : "Import Showdown team"); /// Every mon in the current box as one Showdown text: copy or share. private async Task ExportBoxShowdownAsync() @@ -1483,14 +1485,14 @@ private async Task ExportBoxShowdownAsync() .Select(s => session.GetShowdownText(_viewModel.BoxIndex, s.Slot)); var text = string.Join("\n\n", sets); if (text.Length == 0) { _viewModel.Status = "This box is empty."; return; } - var choice = await PadMenu.ShowAsync(_hostGrid, "Box as Showdown team", null, "Copy to clipboard", "Share as file", "Close"); + var choice = await PadMenu.ShowAsync(_hostGrid, "Box as Showdown team", null, "Copy to clipboard", AppStrings.Fr ? "Partager en fichier" : "Share as file", "Close"); switch (choice) { case "Copy to clipboard": await Clipboard.Default.SetTextAsync(text); _viewModel.Status = "Box copied as Showdown text."; return; - case "Share as file": + case var c when c == (AppStrings.Fr ? "Partager en fichier" : "Share as file"): var path = System.IO.Path.Combine(FileSystem.CacheDirectory, $"box-{_viewModel.BoxIndex + 1}.txt"); await File.WriteAllTextAsync(path, text); await Share.Default.RequestAsync(new ShareFileRequest { Title = "Showdown team", File = new ShareFile(path) }); @@ -1546,21 +1548,21 @@ await _viewModel.RunMutationAsync(s => /// Auto-sort: pick a criteria, pick a scope, one backed-up write compacts mons front. private async Task ShowSortMenuAsync() { - var choice = await PadMenu.ShowAsync(_hostGrid, "Sort", "How should the boxes be ordered?", + var choice = await PadMenu.ShowAsync(_hostGrid, AppStrings.Fr ? "Trier" : "Sort", AppStrings.Fr ? "Quel ordre pour les boîtes ?" : "How should the boxes be ordered?", new PadOption("Dex number", IconPath: "pokedex"), new PadOption("Alphabetical", IconPath: "alpha"), new PadOption("Level (strongest first)", IconPath: "level"), new PadOption("IV total (best first)", IconPath: "stats"), new PadOption("Type", IconPath: "type"), new PadOption("Age (oldest first)", IconPath: "calendar"), - new PadOption("Shiny first", IconPath: "shiny")); + new PadOption(AppStrings.Fr ? "Chromatiques d'abord" : "Shiny first", IconPath: "shiny")); if (choice is null) return; - var scope = await PadMenu.ShowAsync(_hostGrid, "Sort", "Which boxes?", + var scope = await PadMenu.ShowAsync(_hostGrid, AppStrings.Fr ? "Trier" : "Sort", AppStrings.Fr ? "Quelles boîtes ?" : "Which boxes?", new PadOption("This box", IconPath: "box"), new PadOption("All boxes", IconPath: "storage")); - var direction = await PadMenu.ShowAsync(_hostGrid, "Sort", "Which direction?", + var direction = await PadMenu.ShowAsync(_hostGrid, AppStrings.Fr ? "Trier" : "Sort", AppStrings.Fr ? "Quel sens ?" : "Which direction?", new PadOption("Normal order", IconPath: "sort"), new PadOption("Reversed", IconPath: "reverse")); if (direction is null) return; @@ -1573,7 +1575,7 @@ private async Task ShowSortMenuAsync() "IV total (best first)" => Domain.SortCriteria.IvTotalDesc, "Type" => Domain.SortCriteria.Type, "Age (oldest first)" => Domain.SortCriteria.AgeOldest, - "Shiny first" => Domain.SortCriteria.ShinyFirst, + var shinyFirst when shinyFirst == (AppStrings.Fr ? "Chromatiques d'abord" : "Shiny first") => Domain.SortCriteria.ShinyFirst, _ => Domain.SortCriteria.DexNumber, }; IReadOnlyList? boxes = scope == "This box" ? [_viewModel.BoxIndex] : null; @@ -1714,7 +1716,7 @@ private async Task ShowRngAsync(int slot) Spacing = 8, Children = { - Kit.HeaderBar("RNG / IVs"), + Kit.HeaderBar(AppStrings.RngIvs), new Label { Text = $"PID {current.Pid:X8}", FontFamily = DsChrome.PixelFont, FontSize = 14, TextColor = UiTokens.Ink0 }, new Label { Text = current.EncryptionConstant is { } ec ? $"EC {ec:X8}" : "EC (not in this generation)", FontFamily = DsChrome.PixelFont, FontSize = 14, TextColor = UiTokens.Ink0 }, new Label { Text = $"IVs {string.Join("/", current.IVs)}", FontFamily = DsChrome.PixelFont, FontSize = 14, TextColor = UiTokens.Ink0 }, @@ -1848,9 +1850,9 @@ await PadMenu.ShowAsync(_hostGrid, "Legality check", ? await PadMenu.ShowAsync(_hostGrid, "Legality check", $"All {results.Count} Pokémon are legal.", "OK") : await PadMenu.ShowAsync(_hostGrid, "Legality check", Note($"{illegal.Count} illegal of {results.Count}. The red-dotted slots in this box fail PKHeX's checks."), - Menu(Allowed(SaveAction.EditMon, new("Legalize all illegal", IconPath: "fix", Detail: "Rewrite every illegal Pokémon in the save to its closest legal version.")), + Menu(Allowed(SaveAction.EditMon, new(AppStrings.LegalizeAll, IconPath: "fix", Detail: AppStrings.Fr ? "Réécrit chaque Pokémon illégal de la sauvegarde vers sa version légale la plus proche." : "Rewrite every illegal Pokémon in the save to its closest legal version.")), new PadOption("Close", IconPath: "close"))); - if (choice == "Legalize all illegal") + if (choice == AppStrings.LegalizeAll) await LegalizeAllIllegalAsync(illegal); } catch (OperationCanceledException) @@ -2118,7 +2120,7 @@ private async Task ShowBattlePrepAsync() if (Denied(SaveAction.BatchEdit)) return; var session = _sessionsFor(); if (session is null) return; - var choice = await PadMenu.ShowAsync(_hostGrid, "Battle prep", "Every action is one backed-up write.", + var choice = await PadMenu.ShowAsync(_hostGrid, AppStrings.Fr ? "Préparation de combat" : "Battle prep", "Every action is one backed-up write.", new PadOption("Heal party", IconPath: "heal"), new PadOption("Heal all (party + boxes)", IconPath: "heal"), new PadOption("PP Max all moves", IconPath: "moves", Detail: "Max PP Ups on every move, in the party and unlocked boxes."), @@ -2386,18 +2388,18 @@ private async Task ShowSaveDataAsync() var session = _sessionsFor(); if (session is null) return; var options = Menu( - new PadOption("Trainer card", IconPath: "trainer"), - new PadOption("Bag & items", IconPath: "bag"), + new PadOption(AppStrings.Fr ? "Carte de dresseur" : "Trainer card", IconPath: "trainer"), + new PadOption(AppStrings.Fr ? "Sacoche & objets" : "Bag & items", IconPath: "bag"), new PadOption("Pokédex", IconPath: "pokedex"), Allowed(SaveAction.EditTrainer, new("Fashion", IconPath: "box", Detail: "Unlock every outfit this save can legally own (X, Y, Sword and Shield).")), - new PadOption("Trainer records", IconPath: "records", Detail: "The game's own trainer records, view only."), + new PadOption(AppStrings.Fr ? "Records du dresseur" : "Trainer records", IconPath: "records", Detail: "The game's own trainer records, view only."), Allowed(SaveAction.WriteRawBytes, new("Byte manipulation", IconPath: "hex", Detail: "Edit the save file's raw bytes. Written once, when you close.")), // Viewable in Hardcore mode; the editor itself refuses writes (EditWorld). Engine.EventFlagService.IsSupported(session) ? new PadOption("Event flags", IconPath: "check", Detail: "Turn story and event flags on or off, and edit event values.") : null, new PadOption("Wonder cards", IconPath: "events", Detail: "Mystery Gift events and the gift cards stored in this save."), // Viewable in Hardcore mode; each world editor refuses writes itself. - WorldEventsMenu.HasAny(session) ? new PadOption("World & events", IconPath: "map", Detail: "Clocks, roaming Pokémon, one-time encounters and other world data.") : null, - new PadOption("Export modified save", IconPath: "export", Detail: "Share a copy of the edited save file."), + WorldEventsMenu.HasAny(session) ? new PadOption(AppStrings.Fr ? "Monde & événements" : "World & events", IconPath: "map", Detail: "Clocks, roaming Pokémon, one-time encounters and other world data.") : null, + new PadOption(AppStrings.Fr ? "Exporter la sauvegarde modifiée" : "Export modified save", IconPath: "export", Detail: "Share a copy of the edited save file."), new PadOption("Restore points", IconPath: "history", Detail: "Backups made before each change; go back to one."), new PadOption("Close save", IconPath: "quit")).ToList(); if (session.GetGrandUndergroundItems().Count != 0 && Guard.Allows(SaveAction.EditInventory)) @@ -2407,18 +2409,18 @@ private async Task ShowSaveDataAsync() var choice = await PadMenu.ShowAsync(_hostGrid, "Save data", Note(null), options.ToArray()); switch (choice) { - case "Trainer card": await ShowTrainerCardAsync(); return; - case "Export modified save": await ExportModifiedSaveAsync(session); return; + case var c when c == (AppStrings.Fr ? "Carte de dresseur" : "Trainer card"): await ShowTrainerCardAsync(); return; + case var c when c == (AppStrings.Fr ? "Exporter la sauvegarde modifiée" : "Export modified save"): await ExportModifiedSaveAsync(session); return; case "Close save": await Navigation.PopAsync(); return; - case "Bag & items": await ShowBagAsync(); return; + case var c when c == (AppStrings.Fr ? "Sacoche & objets" : "Bag & items"): await ShowBagAsync(); return; case "Grand Underground": await GrandUndergroundEditor.ShowAsync(_hostGrid, session, _viewModel); return; case "Compass settings": await ShowCompassSettingsAsync(session); return; case "Pokédex": await ShowDexMenuAsync(); return; case "Fashion": await ShowFashionAsync(); return; - case "Trainer records": await ShowTrainerRecordsAsync(); return; + case var c when c == (AppStrings.Fr ? "Records du dresseur" : "Trainer records"): await ShowTrainerRecordsAsync(); return; case "Byte manipulation": await HexEditorPage.ShowAsync(_hostGrid, _viewModel, session); return; case "Event flags": await EventFlagsEditor.ShowAsync(_hostGrid, session, _viewModel); return; - case "World & events": await WorldEventsMenu.ShowAsync(_hostGrid, session, _viewModel); return; + case var c when c == (AppStrings.Fr ? "Monde & événements" : "World & events"): await WorldEventsMenu.ShowAsync(_hostGrid, session, _viewModel); return; case "Wonder cards": { var wonderChoice = await PadMenu.ShowAsync(_hostGrid, "Wonder cards", Note(null), @@ -2467,7 +2469,7 @@ private async Task ExportModifiedSaveAsync(Domain.ISaveEngineSession session) await File.WriteAllBytesAsync(path, session.Serialize().ToArray()); await Share.Default.RequestAsync(new ShareFileRequest { - Title = "Export modified save", + Title = AppStrings.Fr ? "Exporter la sauvegarde modifiée" : "Export modified save", File = new ShareFile(path), }); _viewModel.Status = $"Exported {Path.GetFileName(path)}"; @@ -2669,7 +2671,7 @@ private async Task ShowBagAsync() var data = IPlatformApplication.Current!.Services.GetRequiredService(); if (session.GetPokeBeans().Count != 0) { - var choice = await PadMenu.ShowAsync(_hostGrid, "Bag & items", null, + var choice = await PadMenu.ShowAsync(_hostGrid, AppStrings.Fr ? "Sacoche & objets" : "Bag & items", null, new PadOption("Bag", IconPath: "bag"), new PadOption("Poké Beans", IconPath: "item")); if (choice is null) return; @@ -3800,35 +3802,35 @@ private async Task ShowMonActionsAsync(int slot) var options = new List(); if (verdict is { Valid: false }) { - options.Add(new("Explain legality", IconPath: "info", Detail: "Why this Pokémon is flagged, with fixes when there are any.")); + options.Add(new(AppStrings.Fr ? "Expliquer la légalité" : "Explain legality", IconPath: "info", Detail: "Why this Pokémon is flagged, with fixes when there are any.")); options.AddRange(Menu( - Allowed(SaveAction.EditMon, new("Legalize this one", IconPath: "fix", Detail: "Rewrite it to its closest legal version.")), - Allowed(SaveAction.EditMon, new("Legalize all illegal", IconPath: "fix", Detail: "Rewrite every illegal Pokémon in the save to its closest legal version.")))); + Allowed(SaveAction.EditMon, new(AppStrings.Fr ? "Légaliser celui-ci" : "Legalize this one", IconPath: "fix", Detail: "Rewrite it to its closest legal version.")), + Allowed(SaveAction.EditMon, new(AppStrings.LegalizeAll, IconPath: "fix", Detail: AppStrings.Fr ? "Réécrit chaque Pokémon illégal de la sauvegarde vers sa version légale la plus proche." : "Rewrite every illegal Pokémon in the save to its closest legal version.")))); } options.AddRange(Menu( - new PadOption("Summary", IconPath: "info"), - new PadOption("Edit", IconPath: "editor"), - Allowed(SaveAction.EditMon, new("Evolve…", IconPath: "evolve")), - new PadOption("Send to Poképark", IconPath: "park", Detail: "Adds it to the Poképark visitors. The save is not changed."), - new PadOption("Move", IconPath: "move", Detail: "Pick it up and carry it to another slot."), - Allowed(SaveAction.Duplicate, new("Duplicate", IconPath: "copy", Detail: "Clone it into the next empty slot here.")), - new PadOption("Send to Bank", IconPath: "bank", Detail: "It leaves this save and is stored in the Bank."), - Allowed(SaveAction.Duplicate, new("Copy to Bank", IconPath: "copy", Detail: "A copy goes to the Bank; the original stays here.")), - new PadOption("Send to another game…", IconPath: "send", Detail: "It leaves this save and joins another linked game."), + new PadOption(AppStrings.Fr ? "Résumé" : "Summary", IconPath: "info"), + new PadOption(AppStrings.Fr ? "Modifier" : "Edit", IconPath: "editor"), + Allowed(SaveAction.EditMon, new(AppStrings.Fr ? "Faire évoluer…" : "Evolve…", IconPath: "evolve")), + new PadOption(AppStrings.Fr ? "Envoyer au Poképark" : "Send to Poképark", IconPath: "park", Detail: "Adds it to the Poképark visitors. The save is not changed."), + new PadOption(AppStrings.Fr ? "Déplacer" : "Move", IconPath: "move", Detail: "Pick it up and carry it to another slot."), + Allowed(SaveAction.Duplicate, new(AppStrings.Fr ? "Dupliquer" : "Duplicate", IconPath: "copy", Detail: "Clone it into the next empty slot here.")), + new PadOption(AppStrings.SendToBank, IconPath: "bank", Detail: "It leaves this save and is stored in the Bank."), + Allowed(SaveAction.Duplicate, new(AppStrings.CopyToBank, IconPath: "copy", Detail: AppStrings.Fr ? "Une copie va dans la Banque ; l'original reste ici." : "A copy goes to the Bank; the original stays here.")), + new PadOption(AppStrings.Fr ? "Envoyer vers un autre jeu…" : "Send to another game…", IconPath: "send", Detail: "It leaves this save and joins another linked game."), Allowed(SaveAction.Duplicate, new("Copy to another game…", IconPath: "copy", Detail: "A copy joins another linked game; the original stays here.")), - new PadOption("Export .pk file", IconPath: "export", Detail: "Share this Pokémon as a file."), - new PadOption("Show as Showdown set", IconPath: "script", Detail: "Its set as Showdown text you can copy."), - new PadOption("Show as QR code", IconPath: "qr", Detail: "Its Showdown set as a QR code."), - new PadOption("Show as .pk QR", IconPath: "qr", Detail: "The whole Pokémon as a QR code that PKForge can scan into the Bank."), - new PadOption("RNG / IVs", IconPath: "dice", Detail: "See its PID and IVs, and change its nature without losing shininess."), - new PadOption("Lock / Unlock release", IconPath: "padlock", Detail: "A locked Pokémon cannot be released."), - new PadOption("Release", IconPath: "release"), - new PadOption("Release to the Wilds", IconPath: "release", Detail: "It leaves the save and lives on in the wild lands around the Poképark. It may come back to visit."))); + new PadOption(AppStrings.Fr ? "Exporter en fichier .pk" : "Export .pk file", IconPath: "export", Detail: "Share this Pokémon as a file."), + new PadOption(AppStrings.Fr ? "Afficher en set Showdown" : "Show as Showdown set", IconPath: "script", Detail: "Its set as Showdown text you can copy."), + new PadOption(AppStrings.Fr ? "Afficher en QR code" : "Show as QR code", IconPath: "qr", Detail: "Its Showdown set as a QR code."), + new PadOption(AppStrings.Fr ? "Afficher en .pk QR" : "Show as .pk QR", IconPath: "qr", Detail: "The whole Pokémon as a QR code that PKForge can scan into the Bank."), + new PadOption(AppStrings.RngIvs, IconPath: "dice", Detail: "See its PID and IVs, and change its nature without losing shininess."), + new PadOption(AppStrings.Fr ? "Verrouiller / déverrouiller le relâchement" : "Lock / Unlock release", IconPath: "padlock", Detail: "A locked Pokémon cannot be released."), + new PadOption(AppStrings.Release, IconPath: "release"), + new PadOption(AppStrings.Fr ? "Relâcher dans les terres sauvages" : "Release to the Wilds", IconPath: "release", Detail: "It leaves the save and lives on in the wild lands around the Poképark. It may come back to visit."))); var choice = await PadMenu.ShowAsync(_hostGrid, nickname, Note(verdict is { Valid: false } ? verdict.Problem : null), options.ToArray()); switch (choice) { - case "Explain legality": + case var c when c == (AppStrings.Fr ? "Expliquer la légalité" : "Explain legality"): { if (await TryShowLegalityFixesAsync()) return; var detail = string.IsNullOrWhiteSpace(_viewModel.LegalityText) @@ -3837,7 +3839,7 @@ private async Task ShowMonActionsAsync(int slot) await ShowLegalityReportAsync(detail); return; } - case "Legalize this one": + case var c when c == (AppStrings.Fr ? "Légaliser celui-ci" : "Legalize this one"): { var overlay = LoadingOverlay.Show(_hostGrid, "Legalizing…", "Finding the closest real, legal version of this Pokémon."); try @@ -3848,37 +3850,37 @@ private async Task ShowMonActionsAsync(int slot) finally { overlay.Close(); } return; } - case "Send to Poképark": + case var c when c == (AppStrings.Fr ? "Envoyer au Poképark" : "Send to Poképark"): _viewModel.Status = IPlatformApplication.Current!.Services.GetRequiredService().AddSaveVisitor(_viewModel.BoxIndex, slot); return; - case "Summary": + case var c when c == (AppStrings.Fr ? "Résumé" : "Summary"): await OpenSummaryAsync(slot); return; - case "Edit": + case var c when c == (AppStrings.Fr ? "Modifier" : "Edit"): EnterEditorFocusMode(); return; - case "Move": + case var c when c == (AppStrings.Fr ? "Déplacer" : "Move"): if (_viewModel.BeginCarry()) _canvas.InvalidateSurface(); return; - case "Duplicate": + case var c when c == (AppStrings.Fr ? "Dupliquer" : "Duplicate"): await DuplicateSlotAsync(slot); return; - case "Evolve…": + case var c when c == (AppStrings.Fr ? "Faire évoluer…" : "Evolve…"): await EvolveSlotAsync(slot); return; - case "Send to Bank": + case var c when c == AppStrings.SendToBank: await SendToBankAsync(slot, nickname); return; - case "Copy to Bank": + case var c when c == AppStrings.CopyToBank: await SendToBankAsync(slot, nickname, copyInsteadOfMove: true); return; - case "Send to another game…": + case var c when c == (AppStrings.Fr ? "Envoyer vers un autre jeu…" : "Send to another game…"): await SendSlotToAnotherGameAsync(slot, nickname); return; - case "Copy to another game…": + case var c when c == AppStrings.CopyToAnotherGame: await SendSlotToAnotherGameAsync(slot, nickname, copyInsteadOfMove: true); return; - case "Legalize all illegal": + case var c when c == AppStrings.LegalizeAll: { var overlay = default(LoadingOverlay); IReadOnlyList sweep; @@ -3897,7 +3899,7 @@ private async Task ShowMonActionsAsync(int slot) await LegalizeAllIllegalAsync(illegal); return; } - case "Show as .pk QR": + case var c when c == (AppStrings.Fr ? "Afficher en .pk QR" : "Show as .pk QR"): { var session = _sessionsFor(); if (session is null) return; @@ -3914,19 +3916,19 @@ await QrPopup.ShowBinaryAsync(_hostGrid, $"{detail.SpeciesName} · .PK QR", Services.QrEntityService.MakePayload(export.Data, session.Generation, detail.SpeciesName)); return; } - case "Show as Showdown set": + case var c when c == (AppStrings.Fr ? "Afficher en set Showdown" : "Show as Showdown set"): await ShowShowdownAsync(slot); return; - case "Export .pk file": + case var c when c == (AppStrings.Fr ? "Exporter en fichier .pk" : "Export .pk file"): await ExportSlotAsync(slot); return; - case "Show as QR code": + case var c when c == (AppStrings.Fr ? "Afficher en QR code" : "Show as QR code"): await ShowQrAsync(slot); return; - case "RNG / IVs": + case var c when c == AppStrings.RngIvs: await ShowRngAsync(slot); return; - case "Lock / Unlock release": + case var c when c == (AppStrings.Fr ? "Verrouiller / déverrouiller le relâchement" : "Lock / Unlock release"): { var docId = DocumentId; var session = _sessionsFor(); @@ -3938,10 +3940,10 @@ await QrPopup.ShowBinaryAsync(_hostGrid, $"{detail.SpeciesName} · .PK QR", _viewModel.Status = locked ? "Mon locked - release blocked" : "Mon unlocked"; return; } - case "Release": + case var c when c == AppStrings.Release: await ReleaseSlotAsync(slot, nickname); return; - case "Release to the Wilds": + case var c when c == (AppStrings.Fr ? "Relâcher dans les terres sauvages" : "Release to the Wilds"): await ReleaseSlotAsync(slot, nickname, toWilds: true); return; default: @@ -4411,7 +4413,7 @@ List AbilityItems() View? otRow = null; var species = FocusBorder(NamedPicker("SPECIES", nameof(BoxBrowserViewModel.EditSpecies), data.SpeciesNames, null, - openPokedex: true, shaded: true), "Species", async () => + openPokedex: true, shaded: true), AppStrings.Fr ? "Espèce" : "Species", async () => { var session = _sessionsFor(); if (session is null) return; @@ -4447,7 +4449,7 @@ List AbilityItems() var ability = FocusBorder(NamedPicker("ABILITY", nameof(BoxBrowserViewModel.EditAbility), data.AbilityNames, AbilityItems, shaded: false), "ABILITY", async () => await OpenNamedPickerAsync("ABILITY", nameof(BoxBrowserViewModel.EditAbility), AbilityItems)); var item = FocusBorder(NamedPicker("HELD ITEM", nameof(BoxBrowserViewModel.EditHeldItem), new LiveNames(() => SaveItemNames(data)), - () => ItemsWithIcons(SaveItemNames(data)), shaded: true, open: OpenItem), "Held item", OpenItem); + () => ItemsWithIcons(SaveItemNames(data)), shaded: true, open: OpenItem), AppStrings.Fr ? "Objet tenu" : "Held item", OpenItem); // Abilities start in Gen 3 and held items in Gen 2; Gen 1 stores neither. ability.IsVisible = (_sessionsFor()?.Generation ?? 3) >= 3; item.IsVisible = (_sessionsFor()?.Generation ?? 3) >= 2; @@ -4487,7 +4489,7 @@ async Task EditTraining(string stat) // Gender and shiny sit in one band, right under the nickname. var (genderShiny, genderHalf, shinyHalf) = GenderShinyRow(); FocusBorder(genderHalf, "GENDER", OpenGenderPickerAsync); - FocusBorder(shinyHalf, "Shiny", () => + FocusBorder(shinyHalf, AppStrings.Fr ? "Chromatique" : "Shiny", () => { _viewModel.EditShiny = !_viewModel.EditShiny; return Task.CompletedTask; @@ -4553,8 +4555,8 @@ async Task EditTraining(string stat) // The D-pad walks the stops in the order the panel shows them. string[] visualOrder = [ - "Species", "NICKNAME", "GENDER", "Shiny", "LEVEL", - "Nature", "ABILITY", "Held item", "FRIENDSHIP", "BALL", "OT", + AppStrings.Fr ? "Espèce" : "Species", "NICKNAME", "GENDER", AppStrings.Fr ? "Chromatique" : "Shiny", "LEVEL", + "Nature", "ABILITY", AppStrings.Fr ? "Objet tenu" : "Held item", "FRIENDSHIP", "BALL", "OT", "MOVE 1", "MOVE 2", "MOVE 3", "MOVE 4", .. StatNames, "LEGALITY", "SAVE CHANGES", @@ -4641,7 +4643,7 @@ async Task EditTraining(string stat) foreach (var line in statLines) panel.Children.Add(line); panel.Children.Add(EditorSection("Legality")); panel.Children.Add(legalityLine); - panel.Children.Add(EditorSection("Tools")); + panel.Children.Add(EditorSection(AppStrings.Fr ? "Outils" : "Tools")); panel.Children.Add(save); panel.Children.Add(monActions); return panel; @@ -4912,9 +4914,15 @@ private async Task OpenGenderPickerAsync() { var choice = await PadMenu.ShowAsync(_hostGrid, "Gender", null, new PadOption("Male", IconPath: "male"), - new PadOption("Female", IconPath: "female"), - new PadOption("Genderless", IconPath: "genderless")); - var gender = choice switch { "Male" => 0, "Female" => 1, "Genderless" => 2, _ => (int?)null }; + new PadOption(AppStrings.Fr ? "Femelle" : "Female", IconPath: "female"), + new PadOption(AppStrings.Fr ? "Asexué" : "Genderless", IconPath: "genderless")); + var gender = choice switch + { + var male when male == "Male" => 0, + var female when female == (AppStrings.Fr ? "Femelle" : "Female") => 1, + var genderless when genderless == (AppStrings.Fr ? "Asexué" : "Genderless") => 2, + _ => (int?)null, + }; if (gender is { } value) { SetVmString(nameof(BoxBrowserViewModel.EditGender), value.ToString()); @@ -4933,8 +4941,8 @@ private void UpdateGenderRow() label.Text = GetVmString(nameof(BoxBrowserViewModel.EditGender)) switch { "0" => "Male", - "1" => "Female", - _ => "Genderless", + "1" => AppStrings.Fr ? "Femelle" : "Female", + _ => AppStrings.Fr ? "Asexué" : "Genderless", }; return; } @@ -5123,7 +5131,7 @@ private async Task OpenHeldItemPickerAsync(IGameDataService data) var cached = System.IO.Path.Combine(directory, ItemArt.Slug(names[id]) + ".png"); return File.Exists(cached) ? cached : placeholder; } - var picked = await InfoPickers.ShowHeldItemsAsync(_hostGrid, "Held item", data, _sessionsFor(), + var picked = await InfoPickers.ShowHeldItemsAsync(_hostGrid, AppStrings.Fr ? "Objet tenu" : "Held item", data, _sessionsFor(), ParseInt(GetVmString(nameof(BoxBrowserViewModel.EditHeldItem))), Icon); if (picked is not null) SetVmString(nameof(BoxBrowserViewModel.EditHeldItem), picked.Id.ToString()); diff --git a/src/PKForge.App/Views/EmbeddedGamePage.cs b/src/PKForge.App/Views/EmbeddedGamePage.cs new file mode 100644 index 00000000..5cd2d31b --- /dev/null +++ b/src/PKForge.App/Views/EmbeddedGamePage.cs @@ -0,0 +1,127 @@ +using PKForge.App.Platforms.Android; +using PKForge.App.Services; +using SkiaSharp; +using SkiaSharp.Views.Maui; +using SkiaSharp.Views.Maui.Controls; + +namespace PKForge.App.Views; + +/// +/// The embedded game screen (option A): the mGBA core renders the game in-process +/// and this page blits it. Pixel-crisp: integer scaling only (a fractional scale +/// would smear the GBA's 240×160), letterboxed when the screen's ratio isn't 3:2. +/// +public sealed class EmbeddedGamePage : ContentPage, IPadHandler, IPadReleaseHandler +{ + private readonly SKCanvasView _canvas = new() { IgnorePixelScaling = true }; + private readonly EmbeddedGameSession _session; + private readonly Dictionary _held = []; + private readonly object _heldGate = new(); + + public EmbeddedGamePage(EmbeddedGameSession session) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + BackgroundColor = Colors.Black; + Content = _canvas; + _canvas.PaintSurface += OnPaint; + _session.FrameProduced += Invalidate; + } + + private void Invalidate() => MainThread.InvokeOnMainThreadAsync(_canvas.InvalidateSurface); + + /// The pad's button → GBA bit mapping (mGBA's own bit order). + private static uint BitOf(PadButton button) => button switch + { + PadButton.A => Domain.GbaKeys.A, + PadButton.B => Domain.GbaKeys.B, + PadButton.X => Domain.GbaKeys.B, // GBA has no X: both map to B + PadButton.Y => Domain.GbaKeys.A, // ditto Y → A + PadButton.Start => Domain.GbaKeys.Start, + PadButton.Select => Domain.GbaKeys.Select, + PadButton.L => Domain.GbaKeys.L, + PadButton.R => Domain.GbaKeys.R, + PadButton.Up => Domain.GbaKeys.Up, + PadButton.Down => Domain.GbaKeys.Down, + PadButton.Left => Domain.GbaKeys.Left, + PadButton.Right => Domain.GbaKeys.Right, + _ => 0, + }; + + /// IPadHandler: a press latches the bit into the held mask. + public bool OnPadButton(PadButton button) + { + var bit = BitOf(button); + if (bit == 0) return false; + lock (_heldGate) + { + _held[button] = bit; + _session.SetKeys(_held.Values.Aggregate(0u, (mask, value) => mask | value)); + } + return true; // the game screen consumes every button + } + + /// IPadReleaseHandler: a release clears the bit (hold gestures work). + public void OnPadReleased(PadButton button) + { + lock (_heldGate) + { + _held.Remove(button); + _session.SetKeys(_held.Values.Aggregate(0u, (mask, value) => mask | value)); + } + } + + private void OnPaint(object? sender, SKPaintSurfaceEventArgs args) + { + var canvas = args.Surface.Canvas; + canvas.Clear(SKColors.Black); + var (width, height) = _session.VideoSize; + if (width == 0 || height == 0) return; + + // Integer scale, centered: the GBA's pixels stay square and unsmeared. + var scale = Math.Min( + (float)Math.Floor((double)args.Info.Width / width), + (float)Math.Floor((double)args.Info.Height / height)); + if (scale < 1) scale = Math.Min((float)args.Info.Width / width, (float)args.Info.Height / height); + + var drawWidth = width * scale; + var drawHeight = height * scale; + var left = (args.Info.Width - drawWidth) / 2f; + var top = (args.Info.Height - drawHeight) / 2f; + _session.Draw(canvas, new SKRect(left, top, left + drawWidth, top + drawHeight)); + } + + private Timer? _liveTimer; + + protected override void OnAppearing() + { + base.OnAppearing(); + IPlatformApplication.Current?.Services.GetService()?.Push(this); + // PokéDaisy: while the game runs in-app, publish a live companion snapshot + // (dex/party/playtime read from the core's save RAM) every second. + var store = IPlatformApplication.Current?.Services.GetService(); + if (store is not null) + { + store.Start(new Domain.GameCompanion(_session.GameTitle, 3, 0, 0, 0, [])); + _liveTimer = new Timer(_ => + { + var snapshot = _session.ReadLiveCompanion(); + if (snapshot is null) return; + MainThread.BeginInvokeOnMainThread(() => store.UpdateLive(new Domain.LiveCompanionSnapshot( + snapshot.GameTitle, snapshot.GameCode, + snapshot.DexCaught, snapshot.DexSeen, snapshot.DexTotal, + snapshot.PartyCount, snapshot.PlaytimeHours, snapshot.PlaytimeMinutes))); + }, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); + } + } + + protected override void OnDisappearing() + { + base.OnDisappearing(); + _session.FrameProduced -= Invalidate; + IPlatformApplication.Current?.Services.GetService()?.Remove(this); + _liveTimer?.Dispose(); + _liveTimer = null; + IPlatformApplication.Current?.Services.GetService()?.ClearLive(); + _session.Dispose(); + } +} diff --git a/src/PKForge.App/Views/EmulatorsPage.cs b/src/PKForge.App/Views/EmulatorsPage.cs new file mode 100644 index 00000000..b3bdb481 --- /dev/null +++ b/src/PKForge.App/Views/EmulatorsPage.cs @@ -0,0 +1,429 @@ +using CommunityToolkit.Mvvm.Input; +using PKForge.App.Services; +using PKForge.App.Theme; +using PKForge.App.ViewModels; +using PKForge.Domain; + +namespace PKForge.App.Views; + +/// +/// The Emulators tab, Cocoon-style: one section per console with the emulator that +/// launches its ROMs (tap to choose among the installed ones), then the per-emulator +/// save-folder bindings below. A console bound to an emulator is all the library needs +/// to launch a ROM — no save required. +/// +public sealed class EmulatorsPage : ContentPage, IPadHandler +{ + private readonly SavePickerViewModel _viewModel; + private readonly IEmulatorLauncher _launcher; + private readonly IConsoleEmulatorStore _consoleEmulators; + private readonly DirectSaveScanner _saveScanner; + private readonly List _chips = []; + private VerticalStackLayout _rows = null!; + private Grid _hostGrid = null!; + private int _index = -1; + + /// The folder-grant command per emulator, the same ones Home's Link menu runs. + private IAsyncRelayCommand? AddCommand(EmulatorKind kind) => kind switch + { + EmulatorKind.RetroArch => _viewModel.AddRetroArchCommand, + EmulatorKind.MelonDS => _viewModel.AddMelonDsCommand, + EmulatorKind.Azahar => _viewModel.AddAzaharCommand, + EmulatorKind.CitraMmj => _viewModel.AddCitraMmjCommand, + EmulatorKind.Eden => _viewModel.AddEdenCommand, + EmulatorKind.Dolphin => _viewModel.AddDolphinCommand, + EmulatorKind.DraStic => _viewModel.AddDraSticCommand, + EmulatorKind.Linkboy => _viewModel.AddLinkboyCommand, + EmulatorKind.PizzaBoyGba => _viewModel.AddPizzaBoyGbaCommand, + EmulatorKind.PizzaBoyGbc => _viewModel.AddPizzaBoyGbcCommand, + _ => null, + }; + + public EmulatorsPage(SavePickerViewModel viewModel, IEmulatorLauncher launcher, IConsoleEmulatorStore consoleEmulators, Domain.ISaveEngine engine) + { + _viewModel = viewModel; + _launcher = launcher; + _consoleEmulators = consoleEmulators; + _saveScanner = new DirectSaveScanner(engine); + BindingContext = viewModel; + Title = AppStrings.EmulatorsTitle; + BackgroundColor = UiTokens.Housing; + NavigationPage.SetHasNavigationBar(this, false); + + var back = Kit.MiniCapsule("Back", UiTokens.Ink0); + back.WidthRequest = 72; + back.Clicked += async (_, _) => await Navigation.PopAsync(); + + var header = Kit.HeaderBar(AppStrings.EmulatorsTitle); + header.VerticalOptions = LayoutOptions.Center; + + var linkAll = Kit.Capsule(AppStrings.LinkAllInstalled, UiTokens.Cyan); + linkAll.Clicked += (_, _) => _ = LinkAllInstalledAsync(); + + var titleRow = new Grid + { + ColumnSpacing = 10, + ColumnDefinitions = [new(GridLength.Auto), new(GridLength.Star), new(GridLength.Auto)], + Children = { back, header, linkAll }, + }; + Grid.SetColumn(header, 1); + Grid.SetColumn(linkAll, 2); + + var readout = new Label + { + TextColor = UiTokens.InkSoft, + FontFamily = DsChrome.PixelFont, + FontSize = UiTokens.TextBody, + LineBreakMode = LineBreakMode.WordWrap, + }; + readout.SetBinding(Label.TextProperty, new Binding(nameof(SavePickerViewModel.Status), converter: Kit.TidyText)); + + _rows = new VerticalStackLayout { Spacing = 8 }; + + var scrollableRows = new ScrollView + { + Orientation = ScrollOrientation.Vertical, + VerticalScrollBarVisibility = ScrollBarVisibility.Never, + Content = _rows, + }; + var body = new Grid + { + RowSpacing = 10, + RowDefinitions = [new(GridLength.Auto), new(GridLength.Star)], + Children = { Kit.Well(readout, padding: 8), scrollableRows }, + }; + Grid.SetRow(scrollableRows, 1); + + var panel = Kit.DevicePanel(body); + var root = new Grid + { + Padding = new Thickness(14, 10), + RowSpacing = 10, + RowDefinitions = [new(GridLength.Auto), new(GridLength.Star)], + Children = { titleRow, panel }, + }; + Grid.SetRow(panel, 1); + _hostGrid = new Grid { Children = { DsChrome.GridBackground(), root } }; + Content = _hostGrid; + } + + /// One emulator row: identity, link/install state, and what a tap does. + private sealed class EmulatorChip(EmulatorKind kind, bool linked, bool installed) + { + public EmulatorKind Kind { get; } = kind; + public bool Linked { get; } = linked; + public bool Installed { get; } = installed; + public string Name => SaveDescriptions.EmulatorName(Kind); + public string Systems => EmulatorCatalog.SystemsOf(Kind); + public string State => Linked ? AppStrings.Linked : Installed ? AppStrings.NotLinked : AppStrings.NotInstalled; + public string Icon => Kind switch + { + EmulatorKind.RetroArch => "retroarch", + EmulatorKind.MelonDS => "melonds", + EmulatorKind.Linkboy => "linkboy", + EmulatorKind.Azahar or EmulatorKind.CitraMmj => "azahar", + EmulatorKind.Eden => "eden", + EmulatorKind.Dolphin => "dolphin", + EmulatorKind.DraStic => "drastic", + EmulatorKind.PizzaBoyGba or EmulatorKind.PizzaBoyGbc => "pizzaboy", + _ => "storage", + }; + } + + /// One console row: its bound emulator, or "choose" when unbound. + private sealed class ConsoleRow(GameConsole console, EmulatorKind? bound, bool anyInstalled) + { + public GameConsole Console { get; } = console; + public EmulatorKind? Bound { get; set; } = bound; + public bool AnyInstalled { get; } = anyInstalled; + public string Name => Console switch + { + GameConsole.GameBoy => "Game Boy", + GameConsole.GameBoyColor => "Game Boy Color", + GameConsole.GameBoyAdvance => "Game Boy Advance", + GameConsole.NintendoDs => "Nintendo DS", + GameConsole.Nintendo3Ds => "Nintendo 3DS", + GameConsole.GameCube => "GameCube", + GameConsole.NintendoSwitch => "Switch", + _ => Console.ToString(), + }; + public string Icon => Console switch + { + GameConsole.GameBoy or GameConsole.GameBoyColor => "platform-gb", + GameConsole.GameBoyAdvance => "platform-gba", + GameConsole.NintendoDs => "platform-ds", + GameConsole.Nintendo3Ds => "platform-ds", + GameConsole.GameCube => "platform-gc", + GameConsole.NintendoSwitch => "eden", + _ => "storage", + }; + } + + private View BuildConsoleRow(ConsoleRow row) + { + var icon = PksmIcons.Icon(row.Icon, 30, PksmIcons.White); + var name = new Label + { + Text = row.Name, + TextColor = UiTokens.Ink0, + FontFamily = DsChrome.PixelFont, + FontSize = 15, + VerticalOptions = LayoutOptions.Center, + }; + var binding = new Label + { + Text = row.Bound is { } bound + ? $"{AppStrings.ConsoleBound}: {SaveDescriptions.EmulatorName(bound)}" + : row.AnyInstalled ? AppStrings.BindConsole : AppStrings.NotInstalled, + TextColor = row.Bound is null ? UiTokens.InkSoft : UiTokens.Green, + FontFamily = DsChrome.PixelFont, + FontSize = 12, + VerticalOptions = LayoutOptions.Center, + HorizontalOptions = LayoutOptions.End, + }; + var line = new Grid + { + ColumnDefinitions = [new(GridLength.Auto), new(GridLength.Star), new(GridLength.Auto)], + ColumnSpacing = 10, + Children = { icon, name, binding }, + }; + Grid.SetColumn(name, 1); + Grid.SetColumn(binding, 2); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => _ = ChooseConsoleEmulatorAsync(row); + line.GestureRecognizers.Add(tap); + return Kit.Well(line, padding: 10); + } + + /// The Cocoon flow: the console's installed emulators, one tap binds. + /// Consoles PKForge runs in-app (GB/GBC/GBA) never reach here: no row, no binding. + private async Task ChooseConsoleEmulatorAsync(ConsoleRow row) + { + if (ConsoleRules.RunsInApp(row.Console)) + { + _viewModel.Status = $"{row.Name}: plays in PKForge itself."; + return; + } + var candidates = ConsoleRules.EmulatorsFor(row.Console) + .Where(kind => kind != EmulatorKind.Embedded && EmulatorCatalog.IsInstalled(kind, _launcher.IsInstalled)) + .ToList(); + if (candidates.Count == 0) + { + _viewModel.Status = $"{row.Name}: {AppStrings.NotInstalled}."; + return; + } + var options = new List(); + var current = _consoleEmulators.Find(row.Console); + foreach (var kind in candidates) + { + var name = SaveDescriptions.EmulatorName(kind); + options.Add(new PadOption(current == kind ? $"{name} ✓" : name, IconPath: EmulatorChipIcon(kind))); + } + options.Add(new PadOption(AppStrings.BindConsole, IconPath: "close")); + var choice = await PadMenu.ShowAsync(_hostGrid, row.Name, + AppStrings.BindConsole, [.. options]); + if (choice is null || choice == AppStrings.BindConsole) return; + var picked = candidates.FirstOrDefault(kind => choice.StartsWith(SaveDescriptions.EmulatorName(kind), StringComparison.Ordinal)); + if (picked is { } emulator) + { + _consoleEmulators.Set(row.Console, emulator); + RefreshChips(); + _viewModel.Status = $"{row.Name} → {SaveDescriptions.EmulatorName(emulator)}"; + } + } + + private static string EmulatorChipIcon(EmulatorKind kind) => kind switch + { + EmulatorKind.RetroArch => "retroarch", + EmulatorKind.MelonDS => "melonds", + EmulatorKind.Linkboy => "linkboy", + EmulatorKind.Azahar or EmulatorKind.CitraMmj => "azahar", + EmulatorKind.Eden => "eden", + EmulatorKind.Dolphin => "dolphin", + EmulatorKind.DraStic => "drastic", + EmulatorKind.PizzaBoyGba or EmulatorKind.PizzaBoyGbc => "pizzaboy", + _ => "storage", + }; + + private View BuildChipRow(EmulatorChip chip) + { + var icon = PksmIcons.Icon(chip.Icon, 30, PksmIcons.White); + + var name = new Label + { + Text = chip.Name, + TextColor = UiTokens.Ink0, + FontFamily = DsChrome.PixelFont, + FontSize = 15, + VerticalOptions = LayoutOptions.Center, + }; + var systems = new Label + { + Text = chip.Systems, + TextColor = UiTokens.InkSoft, + FontFamily = DsChrome.PixelFont, + FontSize = 11, + VerticalOptions = LayoutOptions.Center, + }; + var state = new Label + { + Text = chip.State, + TextColor = chip.Linked ? UiTokens.Green : chip.Installed ? UiTokens.InkSoft : UiTokens.Ink1, + FontFamily = DsChrome.PixelFont, + FontSize = 12, + VerticalOptions = LayoutOptions.Center, + HorizontalOptions = LayoutOptions.End, + }; + + var identity = new Grid + { + RowSpacing = 2, + RowDefinitions = [new(GridLength.Auto), new(GridLength.Auto)], + Children = { name, systems }, + }; + Grid.SetRow(systems, 1); + + var row = new Grid + { + ColumnDefinitions = [new(GridLength.Auto), new(GridLength.Star), new(GridLength.Auto)], + ColumnSpacing = 10, + Children = { icon, identity, state }, + }; + Grid.SetColumn(identity, 1); + Grid.SetColumn(state, 2); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => _ = LinkAsync(chip.Kind); + row.GestureRecognizers.Add(tap); + return Kit.Well(row, padding: 10); + } + + protected override void OnAppearing() + { + base.OnAppearing(); + RefreshChips(); + IPlatformApplication.Current?.Services.GetService()?.Push(this); + } + + protected override void OnDisappearing() + { + base.OnDisappearing(); + IPlatformApplication.Current?.Services.GetService()?.Remove(this); + } + + public void RefreshChips() + { + // Consoles first (the Cocoon binding), then the per-emulator save folders. + var roots = IPlatformApplication.Current!.Services.GetRequiredService().GetRoots(); + _chips.Clear(); + _rows.Children.Clear(); + var consoles = Enum.GetValues(); + foreach (var console in consoles) + { + // Consoles PKForge runs in-app (GB/GBC/GBA): no external binding row, ever. + if (ConsoleRules.RunsInApp(console)) + continue; + var bound = _consoleEmulators.Find(console); + var anyInstalled = ConsoleRules.AnyInstalled(console, _launcher.IsInstalled); + if (bound is null && !anyInstalled) continue; // a console nothing can run is not shown + var row = new ConsoleRow(console, bound, anyInstalled); + _rows.Children.Add(BuildConsoleRow(row)); + } + if (_rows.Children.Count > 0) + _rows.Children.Add(new BoxView { HeightRequest = 10, Color = UiTokens.Housing }); + foreach (var app in EmulatorCatalog.All) + { + var installed = EmulatorCatalog.IsInstalled(app.Kind, _launcher.IsInstalled); + var linked = roots.Any(r => r.Kind == app.Kind); + var chip = new EmulatorChip(app.Kind, linked, installed); + _chips.Add(chip); + _rows.Children.Add(BuildChipRow(chip)); + } + } + + private async Task LinkAllInstalledAsync() + { + // One folder picker per installed-and-unlinked emulator, in catalogue order: the + // same flow as Home's Link menu, without the platform detour. + var roots = IPlatformApplication.Current!.Services.GetRequiredService(); + var pending = EmulatorCatalog.All + .Where(app => EmulatorCatalog.IsInstalled(app.Kind, _launcher.IsInstalled)) + .Where(app => roots.GetRoots().Any(r => r.Kind == app.Kind) == false) + .ToList(); + if (pending.Count == 0) + { + _viewModel.Status = AppStrings.AlreadyAllLinked; + return; + } + foreach (var app in pending) + { + var command = AddCommand(app.Kind); + if (command is not null) await command.ExecuteAsync(null); + } + RefreshChips(); + } + + public bool OnPadButton(PadButton button) + { + switch (button) + { + case PadButton.Up: return MoveCursor(-1); + case PadButton.Down: return MoveCursor(1); + case PadButton.A: return ActivateChipAsync().GetAwaiter().GetResult(); + case PadButton.B: _ = Navigation.PopAsync(); return true; + default: return true; + } + } + + private async Task ActivateChipAsync() + { + if (_index < 0 || _index >= _chips.Count) return true; + var chip = _chips[_index]; + await LinkAsync(chip.Kind); + return true; + } + + private async Task LinkAsync(EmulatorKind kind) + { + // Cocoon-style: the standard save folders first, no folder picker at all. + // The SAF grant flow stays as the fallback for custom setups. + var folders = EmulatorSaveFolders.ExistingFor(kind); + if (folders.Count > 0) + { + var found = _saveScanner.Scan(kind); + if (found.Count > 0) + { + _viewModel.Status = AppStrings.Fr + ? $"{SaveDescriptions.EmulatorName(kind)} : {found.Count} sauvegarde(s) trouvée(s) dans {folders.Count} dossier(s) standard(s)." + : $"{SaveDescriptions.EmulatorName(kind)}: {found.Count} save(s) found in {folders.Count} standard folder(s)."; + return; + } + } + var command = AddCommand(kind); + if (command is null) + { + _viewModel.Status = $"{SaveDescriptions.EmulatorName(kind)}: {AppStrings.NotLinked}."; + return; + } + // Nothing in the standard folders: the SAF flow says which folder it wants. + var expected = EmulatorSaveFolders.For(kind); + _viewModel.Status = AppStrings.Fr + ? $"Dossiers standards introuvables pour {SaveDescriptions.EmulatorName(kind)}. Le sélecteur s'ouvre : choisissez {string.Join(" / ", expected.Take(3))} sous /storage/emulated/0." + : $"No standard folders for {SaveDescriptions.EmulatorName(kind)}. The picker opens: choose {string.Join(" / ", expected.Take(3))} under /storage/emulated/0."; + await command.ExecuteAsync(null); + RefreshChips(); + } + + private bool MoveCursor(int delta) + { + if (_rows.Children.Count == 0) return true; + var previous = _index >= 0 && _index < _rows.Children.Count ? _rows.Children[_index] as VisualElement : null; + _index = Math.Clamp(_index < 0 ? 0 : _index + delta, 0, _chips.Count - 1); + if (previous is not null) _ = previous.ScaleToAsync(1.0, 80); + if (_rows.Children[_index] is VisualElement current) + { + _ = current.ScaleToAsync(1.03, 90); + _ = current.ScaleToAsync(1.0, 80); + } + return true; + } +} diff --git a/src/PKForge.App/Views/GameCompanionView.cs b/src/PKForge.App/Views/GameCompanionView.cs new file mode 100644 index 00000000..514290dc --- /dev/null +++ b/src/PKForge.App/Views/GameCompanionView.cs @@ -0,0 +1,91 @@ +using PKForge.App.Services; +using PKForge.App.Theme; +using PKForge.Domain; + +namespace PKForge.App.Views; + +/// +/// The in-game companion on the lower screen: the running game's name and dex +/// progress, plus its restore points. Shown while an emulator owns the +/// foreground; every surface is read-only (the companion never edits a save the +/// emulator holds open). +/// +public sealed class GameCompanionView : VerticalStackLayout +{ + private readonly Label _title = new() + { + FontFamily = DsChrome.PixelFont, FontSize = 16, TextColor = UiTokens.Ink0, + HorizontalTextAlignment = TextAlignment.Center, + }; + private readonly Label _dex = new() + { + FontFamily = DsChrome.PixelFont, FontSize = 13, TextColor = UiTokens.Cyan, + HorizontalTextAlignment = TextAlignment.Center, + }; + private readonly Label _live = new() + { + FontFamily = DsChrome.PixelFont, FontSize = 11, TextColor = UiTokens.Green, + HorizontalTextAlignment = TextAlignment.Center, + IsVisible = false, + }; + private readonly VerticalStackLayout _points = new() { Spacing = 3 }; + + public GameCompanionView() + { + Spacing = 10; + Margin = new Thickness(14, 12); + Padding = new Thickness(10); + HorizontalOptions = LayoutOptions.Center; + VerticalOptions = LayoutOptions.Center; + Children.Add(_title); + Children.Add(_dex); + Children.Add(_live); + Children.Add(new Label + { + Text = AppStrings.Fr ? "Points de restauration" : "Restore points", + FontFamily = DsChrome.PixelFont, FontSize = 11, TextColor = UiTokens.Ink1, + }); + Children.Add(_points); + IsVisible = false; + } + + /// Replaces the companion wholesale: one immutable record per launch. + public void Show(GameCompanion companion) + { + _title.Text = companion.GameLabel; + _dex.Text = AppStrings.Fr + ? $"Pokédex : {companion.DexCaught}/{companion.DexTotal} capturés · {companion.DexPercent}%" + : $"Pokédex: {companion.DexCaught}/{companion.DexTotal} caught · {companion.DexPercent}%"; + _points.Children.Clear(); + _live.IsVisible = false; + if (companion.RestorePoints.Count == 0) + { + _points.Children.Add(new Label + { + Text = AppStrings.Fr ? "Aucun point pour cette sauvegarde." : "No points for this save yet.", + FontFamily = DsChrome.PixelFont, FontSize = 10, TextColor = UiTokens.InkSoft, + }); + } + else + { + foreach (var point in companion.RestorePoints.Take(5)) + { + _points.Children.Add(new Label + { + Text = $"· {point.CreatedUtc:yyyy-MM-dd HH:mm} UTC", + FontFamily = DsChrome.PixelFont, FontSize = 10, TextColor = UiTokens.InkSoft, + }); + } + } + IsVisible = true; + } + + /// Overlays a live read from the running embedded game (PokéDaisy). + public void ShowLive(LiveCompanionSnapshot live) + { + _live.Text = AppStrings.Fr + ? $"En direct : dex {live.DexCaught}/{live.DexTotal} · équipe {live.PartyCount}/6 · {live.PlaytimeHours}h{live.PlaytimeMinutes:D2}" + : $"Live: dex {live.DexCaught}/{live.DexTotal} · party {live.PartyCount}/6 · {live.PlaytimeHours}h{live.PlaytimeMinutes:D2}"; + _live.IsVisible = true; + } +} diff --git a/src/PKForge.App/Views/HomePage.cs b/src/PKForge.App/Views/HomePage.cs index a74f8ff3..d50cff21 100644 --- a/src/PKForge.App/Views/HomePage.cs +++ b/src/PKForge.App/Views/HomePage.cs @@ -33,7 +33,7 @@ public HomePage(SavePickerViewModel viewModel) // GAMES section: the cartridge shelf (reused), labelled DS-style. var gamesLabel = new Label { - Text = "Games", FontFamily = DsChrome.PixelFont, FontSize = UiTokens.TextTitle, + Text = AppStrings.Games, FontFamily = DsChrome.PixelFont, FontSize = UiTokens.TextTitle, TextColor = UiTokens.Ink0, VerticalOptions = LayoutOptions.Center, }; var filterCaption = new Label @@ -83,23 +83,31 @@ public HomePage(SavePickerViewModel viewModel) BlockNativeFocus(_shelf); // The three destinations as PKSM tiles with bundled pixel icons. - var bank = new DsCard("bank", "Bank") { Tapped = () => _ = PushAsync() }; - var autopilot = new DsCard("pokedex", "Dex Autopilot") { Tapped = () => _ = OpenAutopilotAsync() }; - var park = new DsCard("park", "Poképark") { Tapped = () => _ = PushParkAsync() }; - var events = new DsCard("events", "Events") { Tapped = () => _ = ShowEventsMenuAsync() }; - var settings = new DsCard("settings", "Settings") { Tapped = () => _ = ShowSettingsAsync() }; - _cards = [bank, autopilot, park, events, settings]; + var bank = new DsCard("bank", AppStrings.Bank) { Tapped = () => _ = PushAsync() }; + var autopilot = new DsCard("pokedex", AppStrings.DexAutopilot) { Tapped = () => _ = OpenAutopilotAsync() }; + var park = new DsCard("park", AppStrings.Pokepark) { Tapped = () => _ = PushParkAsync() }; + var events = new DsCard("events", AppStrings.Events) { Tapped = () => _ = ShowEventsMenuAsync() }; + var emulators = new DsCard("retroarch", AppStrings.EmulatorsTitle) { Tapped = () => _ = PushAsync() }; + var library = new DsCard("platform-gba", "Library") { Tapped = () => _ = PushAsync() }; + var trophies = new DsCard("ribbons", "Trophies") { Tapped = () => _ = PushAsync() }; + var awards = new DsCard("credits", "Awards") { Tapped = () => _ = PushAsync() }; + var settings = new DsCard("settings", AppStrings.Settings) { Tapped = () => _ = ShowSettingsAsync() }; + _cards = [bank, autopilot, park, events, emulators, library, trophies, awards, settings]; foreach (var card in _cards) BlockNativeFocus(card); var cards = new Grid { ColumnSpacing = 10, - ColumnDefinitions = [new(GridLength.Star), new(GridLength.Star), new(GridLength.Star), new(GridLength.Star), new(GridLength.Star)], - Children = { bank, autopilot, park, events, settings }, + ColumnDefinitions = [new(GridLength.Star), new(GridLength.Star), new(GridLength.Star), new(GridLength.Star), new(GridLength.Star), new(GridLength.Star), new(GridLength.Star), new(GridLength.Star), new(GridLength.Star)], + Children = { bank, autopilot, park, events, emulators, library, trophies, awards, settings }, }; Grid.SetColumn(autopilot, 1); Grid.SetColumn(park, 2); Grid.SetColumn(events, 3); - Grid.SetColumn(settings, 4); + Grid.SetColumn(emulators, 4); + Grid.SetColumn(library, 5); + Grid.SetColumn(trophies, 6); + Grid.SetColumn(awards, 7); + Grid.SetColumn(settings, 8); var body = new Grid { @@ -112,14 +120,14 @@ public HomePage(SavePickerViewModel viewModel) var bodyHost = new Grid { Children = { DsChrome.GridBackground(), body } }; var footer = DsChrome.Footer( - ("A", "Open", () => OnPadButton(PadButton.A)), + ("A", AppStrings.Open, () => OnPadButton(PadButton.A)), // The cartridge identity menu (rename, color, which game): B on the pad, a hold by touch. - ("B", "Edit game · hold", () => + ("B", AppStrings.EditGameHold, () => { if ((uint)_shelfIndex < (uint)_viewModel.Groups.Count) _ = ShowSaveMenuAsync(_viewModel.Groups[_shelfIndex]); }), - ("L", "Filter", () => _ = ShowFilterMenuAsync()), - ("Y", "Link", () => _ = ShowLinkMenuAsync()), + ("L", AppStrings.Filter, () => _ = ShowFilterMenuAsync()), + ("Y", AppStrings.Link, () => _ = ShowLinkMenuAsync()), ("X", "File", () => _ = LinkFileAsync()), ("+", "Settings", () => _ = ShowSettingsAsync())); @@ -289,21 +297,21 @@ protected override void OnAppearing() { // First run plays like a Pokémon intro: a professor-style dialogue, then the choices. await DialogueBox.ShowSequenceAsync(_hostGrid, - "Hello! Welcome to PKForge!", + AppStrings.Welcome, "This is a place where your Pokémon from every game can live together, safely.", "I can edit them, keep them in the Bank, and make sure each one is legal.", "And every change is backed up before it is written, so nothing is ever lost.", - "Now then... let's find your games!"); + AppStrings.FindYourGames); - var choice = await PadMenu.ShowAsync(_hostGrid, "Get started", null, - new PadOption("Link an emulator", IconPath: "link", Detail: "Pick the emulator folder where your games keep their saves."), - new PadOption("Open a single save file", IconPath: "file", Detail: "Pick one save file instead of a whole emulator folder."), + var choice = await PadMenu.ShowAsync(_hostGrid, AppStrings.GetStarted, null, + new PadOption(AppStrings.LinkEmulator, IconPath: "link", Detail: AppStrings.Fr ? "Choisissez le dossier où vos jeux gardent leurs sauvegardes." : "Pick the emulator folder where your games keep their saves."), + new PadOption(AppStrings.OpenSingleSave, IconPath: "file", Detail: AppStrings.Fr ? "Choisissez un seul fichier de sauvegarde plutôt qu'un dossier entier." : "Pick one save file instead of a whole emulator folder."), new PadOption($"Download the sprite pack ({SpritePackDownloader.SizeHint})", IconPath: "download", Detail: "Animated sprites, HOME pictures and item icons, to use offline."), new PadOption("Maybe later", IconPath: "close")); switch (choice) { - case "Link an emulator": await ShowLinkMenuAsync(); break; - case "Open a single save file": await LinkFileAsync(); break; + case var link when link == AppStrings.LinkEmulator: await ShowLinkMenuAsync(); break; + case var single when single == AppStrings.OpenSingleSave: await LinkFileAsync(); break; case var pack when pack?.StartsWith("Download the sprite pack", StringComparison.Ordinal) == true: await DownloadSpritePackAsync(); break; @@ -339,6 +347,10 @@ private void OnLinkReported(string title, string message) => /// Android settings do not trigger MAUI page appearing again; resume does. private void OnAppResumed() { + // The automatic trophy watcher: returning from an emulator re-walks the linked + // saves and announces anything the play session unlocked. Quiet by design. + _ = WatchTrophiesAfterResumeAsync(); + if (_pendingAuthorizedUpdate is not { } authorized || !AppUpdateService.CanInstallUpdates()) return; @@ -346,6 +358,33 @@ private void OnAppResumed() Dispatcher.DispatchAsync(async () => await InstallUpdateAsync(authorized)); } + private bool _trophyWatchQueued; + + /// One watcher pass per resume, never two in flight, never during a manual scan. + private async Task WatchTrophiesAfterResumeAsync() + { + if (_trophyWatchQueued) return; + _trophyWatchQueued = true; + try + { + var watcher = IPlatformApplication.Current?.Services.GetService(); + var companion = IPlatformApplication.Current?.Services.GetService(); + // Back from the emulator: the lower screen returns to PKForge's surfaces. + companion?.Stop(); + if (watcher is null) return; + await _viewModel.RescanCommand.ExecuteAsync(null); + await watcher.ScanAndAnnounceAsync(_viewModel.Saves.ToList()); + } + catch (Exception error) + { + Services.AppLog.Info("trophies", $"The resume scan failed: {error.Message}"); + } + finally + { + _trophyWatchQueued = false; + } + } + /// Every button on the home screen, in one place. public bool OnPadButton(PadButton button) { @@ -420,10 +459,10 @@ private bool OpenShelfSelection() /// or plain alphabetical. private async Task ShowFilterMenuAsync() { - var choice = await PadMenu.ShowAsync(_hostGrid, "Filter games", null, - new PadOption("All games", IconPath: "all"), - new PadOption("Release order", IconPath: "calendar", Detail: "Every game, from the oldest release to the newest."), - new PadOption("Alphabetical (A-Z)", IconPath: "alpha"), + var choice = await PadMenu.ShowAsync(_hostGrid, AppStrings.FilterGames, null, + new PadOption(AppStrings.AllGames, IconPath: "all"), + new PadOption(AppStrings.ReleaseOrder, IconPath: "calendar", Detail: AppStrings.Fr ? "Tous les jeux, de la plus ancienne à la plus récente sortie." : "Every game, from the oldest release to the newest."), + new PadOption(AppStrings.Alphabetical, IconPath: "alpha"), new PadOption("Game Boy (Gen I-II)", IconPath: "platform-gb"), new PadOption("GBA (Gen III)", IconPath: "platform-gba"), new PadOption("DS (Gen IV-V)", IconPath: "platform-ds"), @@ -440,9 +479,9 @@ private async Task ShowFilterMenuAsync() new PadOption("Gen IX", IconPath: "generation")); var (key, caption) = choice switch { - "All games" => ("all", "All"), - "Release order" => ("release", "Release"), - "Alphabetical (A-Z)" => ("az", "A-Z"), + var all when all == AppStrings.AllGames => ("all", AppStrings.All), + var release when release == AppStrings.ReleaseOrder => ("release", AppStrings.Fr ? "Sortie" : "Release"), + var az when az == AppStrings.Alphabetical => ("az", "A-Z"), "Game Boy (Gen I-II)" => ("gb", "Game Boy"), "GBA (Gen III)" => ("gba", "GBA"), "DS (Gen IV-V)" => ("ds", "DS"), @@ -460,7 +499,7 @@ private async Task ShowFilterMenuAsync() /// A cartridge with one save opens it; a shared cartridge asks which save first. private async Task OpenCardAsync(SaveCard card) { - var save = await ChooseSaveAsync(card, "Which save do you want to open?"); + var save = await ChooseSaveAsync(card, AppStrings.WhichSaveToOpen); if (save is not null) await OpenSaveAsync(save); } @@ -494,7 +533,7 @@ private async Task ChooseEditionOnceAsync(DetectedSave save) var editions = SaveIdentityRules.ChoicesFor(guess.Family, guess.Label).Where(c => !c.IsHack).ToArray(); var options = editions.Select(c => new PadOption(c.Label, IconPath: "game")) .Append(new PadOption(later, IconPath: "close")).ToArray(); - var choice = await PadMenu.ShowAsync(_hostGrid, "Which version is this?", + var choice = await PadMenu.ShowAsync(_hostGrid, AppStrings.Fr ? "Quelle version est-ce ?" : "Which version is this?", "Both versions write the same save file, so PKForge cannot tell them apart. " + "Pick yours once; you can change it later from the save's menu (long-press).", options); if (choice is null) return false; @@ -515,16 +554,16 @@ private async Task ShowLinkMenuAsync() { while (true) { - var platform = await PadMenu.ShowAsync(_hostGrid, "Link a storage unit", "Choose a platform to see its emulators.", + var platform = await PadMenu.ShowAsync(_hostGrid, AppStrings.LinkStorageUnit, AppStrings.ChoosePlatform, new PadOption("Game Boy / Game Boy Color", IconPath: "platform-gb"), new PadOption("Game Boy Advance", IconPath: "platform-gba"), new PadOption("Nintendo DS", IconPath: "platform-ds"), new PadOption("GameCube", IconPath: "platform-gc"), new PadOption("Nintendo 3DS", IconPath: "azahar"), new PadOption("Nintendo Switch", IconPath: "eden"), - new PadOption("Single save file", IconPath: "file")); + new PadOption(AppStrings.SingleSaveFile, IconPath: "file")); if (platform is null) return; - if (platform == "Single save file") { await LinkFileAsync(); return; } + if (platform == AppStrings.SingleSaveFile) { await LinkFileAsync(); return; } var options = platform switch { "Game Boy / Game Boy Color" => new[] @@ -590,12 +629,12 @@ private async Task ShowLinkMenuAsync() private async Task ShowSettingsAsync() { var hiddenCount = _viewModel.HiddenSaves.Count; - var hiddenOption = $"Show hidden saves ({hiddenCount})"; + var hiddenOption = AppStrings.ShowHiddenSaves(hiddenCount); var options = new List { - new("Link an emulator", IconPath: "link", Detail: "Pick the emulator folder where your games keep their saves."), - new("Manage linked storage", IconPath: "sdcard", Detail: "Unlink emulator folders. Your files stay on the device."), - new("Open a save file", IconPath: "file", Detail: "Pick one save file instead of a whole emulator folder."), + new(AppStrings.LinkEmulator, IconPath: "link", Detail: AppStrings.Fr ? "Choisissez le dossier où vos jeux gardent leurs sauvegardes." : "Pick the emulator folder where your games keep their saves."), + new(AppStrings.ManageLinkedStorage, IconPath: "sdcard", Detail: AppStrings.Fr ? "Déliez les dossiers d'émulateurs. Vos fichiers restent sur la console." : "Unlink emulator folders. Your files stay on the device."), + new(AppStrings.OpenSingleSave, IconPath: "file", Detail: AppStrings.Fr ? "Choisissez un seul fichier de sauvegarde plutôt qu'un dossier entier." : "Pick one save file instead of a whole emulator folder."), }; if (hiddenCount > 0) options.Add(new PadOption(hiddenOption, IconPath: "show", Detail: "Saves you hid from Home. Pick one to show it again.")); var scheme = $"Color scheme: {PKForge.Chrome.ColorTheme.Current.Name}"; @@ -604,12 +643,12 @@ private async Task ShowSettingsAsync() [ new PadOption(scheme, IconPath: "palette", Detail: "The app's colors: the default blues or a theme for each type."), new PadOption(background, IconPath: "box", Detail: "How each PC box shows the wallpaper the game gives it."), - new PadOption("Restore points", IconPath: "history", Detail: "The backups made before each write, to put a save back as it was."), - new PadOption("About PKForge", IconPath: "info"), - new PadOption("Check for update", IconPath: "update"), - new PadOption("Music", IconPath: "music", Detail: "Play your own audio files in the background."), - new PadOption("Misc", IconPath: "gears", Detail: "Sprite pack, rescan, logs, screen and editing modes."), - new PadOption("Quit PKForge", IconPath: "quit"), + new PadOption(AppStrings.RestorePoints, IconPath: "history", Detail: AppStrings.Fr ? "Les sauvegardes faites avant chaque écriture, pour remettre une sauvegarde comme avant." : "The backups made before each write, to put a save back as it was."), + new PadOption(AppStrings.AboutPkforge, IconPath: "info"), + new PadOption(AppStrings.CheckForUpdate, IconPath: "update"), + new PadOption(AppStrings.Music, IconPath: "music", Detail: AppStrings.Fr ? "Jouez vos propres fichiers audio en arrière-plan." : "Play your own audio files in the background."), + new PadOption(AppStrings.Misc, IconPath: "gears", Detail: AppStrings.Fr ? "Pack de sprites, rescan, logs, écran et modes d'édition." : "Sprite pack, rescan, logs, screen and editing modes."), + new PadOption(AppStrings.Quit, IconPath: "quit"), ]); var choice = await PadMenu.ShowAsync(_hostGrid, "Settings", null, [.. options]); if (choice == hiddenOption) { await ShowHiddenSavesAsync(); return; } @@ -617,16 +656,16 @@ private async Task ShowSettingsAsync() if (choice == background) { await ShowBoxBackgroundAsync(); return; } switch (choice) { - case "Link an emulator": await ShowLinkMenuAsync(); break; - case "Manage linked storage": await ShowManageLinkedStorageAsync(); break; - case "Open a save file": await LinkFileAsync(); break; - case "Restore points": await PushAsync(); break; - case "About PKForge": await AboutPopup.ShowAsync(_hostGrid); break; - case "Check for update": await CheckForUpdateAsync(automatic: false); break; - case "Music": await ShowMusicAsync(); break; - case "Misc": await ShowMiscAsync(); break; - case "Quit PKForge": - if (await PadMenu.ConfirmAsync(_hostGrid, "Quit PKForge?", "Unsaved edits in open menus are already backed up per write.", "Quit")) + case var link when link == AppStrings.LinkEmulator: await ShowLinkMenuAsync(); break; + case var manage when manage == AppStrings.ManageLinkedStorage: await ShowManageLinkedStorageAsync(); break; + case var open when open == AppStrings.OpenSingleSave: await LinkFileAsync(); break; + case var restore when restore == AppStrings.RestorePoints: await PushAsync(); break; + case var about when about == AppStrings.AboutPkforge: await AboutPopup.ShowAsync(_hostGrid); break; + case var update when update == AppStrings.CheckForUpdate: await CheckForUpdateAsync(automatic: false); break; + case var music when music == AppStrings.Music: await ShowMusicAsync(); break; + case var misc when misc == AppStrings.Misc: await ShowMiscAsync(); break; + case var quit when quit == AppStrings.Quit: + if (await PadMenu.ConfirmAsync(_hostGrid, AppStrings.QuitPkforge, AppStrings.Fr ? "Les modifications non enregistrées des menus ouverts sont déjà sauvegardées à chaque écriture." : "Unsaved edits in open menus are already backed up per write.", AppStrings.Quit)) Microsoft.Maui.Controls.Application.Current?.Quit(); break; } @@ -853,16 +892,16 @@ private async Task ShowColorSchemeAsync() private async Task ShowMiscAsync() { var trainerProfiles = IPlatformApplication.Current!.Services.GetRequiredService(); - var choice = await PadMenu.ShowAsync(_hostGrid, "Misc", null, + var choice = await PadMenu.ShowAsync(_hostGrid, AppStrings.Misc, null, new PadOption(trainerProfiles.UseCurrentTrainerForGeneration ? "Generated Pokémon obey trainer: ON" : "Generated Pokémon obey trainer: OFF", IconPath: "profile", Detail: "ON makes Pokémon you create belong to the open save's trainer."), new PadOption($"Download full sprite pack ({SpritePackDownloader.SizeHint})", IconPath: "download", Detail: "Animated sprites, HOME pictures and item icons, to use offline."), - new PadOption("Rescan games", IconPath: "refresh", Detail: "Look through your linked folders for saves again."), - new PadOption("Scan report", IconPath: "report", Detail: "What the last scan found in each folder, to copy and send us."), - new PadOption("Share logs", IconPath: "export", Detail: "Crash reports and recent activity, to send us when something goes wrong."), - new PadOption(SecondScreenMode.UserOff ? "Second screen: OFF" : "Second screen: ON", IconPath: "compact", + new PadOption(AppStrings.RescanGames, IconPath: "refresh", Detail: AppStrings.Fr ? "Parcourt de nouveau vos dossiers liés à la recherche de sauvegardes." : "Look through your linked folders for saves again."), + new PadOption(AppStrings.ScanReport, IconPath: "report", Detail: AppStrings.Fr ? "Ce que le dernier scan a trouvé dans chaque dossier, à copier et nous envoyer." : "What the last scan found in each folder, to copy and send us."), + new PadOption(AppStrings.ShareLogs, IconPath: "export", Detail: AppStrings.Fr ? "Rapports de crash et activité récente, à nous envoyer quand quelque chose cloche." : "Crash reports and recent activity, to send us when something goes wrong."), + new PadOption(SecondScreenMode.UserOff ? AppStrings.SecondScreenOff : AppStrings.SecondScreenOn, IconPath: "compact", Detail: "OFF keeps PKForge on one screen, so the other stays free (an emulator, say)."), new PadOption(Services.HaXMode.IsOn ? "HaX mode: ON" : "HaX mode: OFF", IconPath: "hax", Detail: "ON lets pickers offer any option, even illegal ones."), @@ -879,17 +918,17 @@ private async Task ShowMiscAsync() case var pack when pack?.StartsWith("Download full sprite pack", StringComparison.Ordinal) == true: await DownloadSpritePackAsync(); break; - case "Rescan games": + case var rescan when rescan == AppStrings.RescanGames: if (_viewModel.IsBusy && !_viewModel.ReleaseIfStuck()) { - await PadMenu.ShowAsync(_hostGrid, "Still busy", + await PadMenu.ShowAsync(_hostGrid, AppStrings.Fr ? "Toujours occupé" : "Still busy", $"PKForge is still busy with {_viewModel.BusyWith}. Try again in a moment; if it stays stuck, close and reopen PKForge.", "OK"); break; } // Called directly: the command refuses to start while an older run it owns is still pending. await _viewModel.RescanAsync(); break; - case "Share logs": + case var shareLogs when shareLogs == AppStrings.ShareLogs: await ShareLogsAsync(); break; case "Second screen: ON" or "Second screen: OFF": diff --git a/src/PKForge.App/Views/RetroAchievementsPage.cs b/src/PKForge.App/Views/RetroAchievementsPage.cs new file mode 100644 index 00000000..b0aeeed7 --- /dev/null +++ b/src/PKForge.App/Views/RetroAchievementsPage.cs @@ -0,0 +1,272 @@ +using PKForge.App.Services; +using PKForge.App.Theme; +using PKForge.App.ViewModels; +using PKForge.Domain; + +namespace PKForge.App.Views; + +/// +/// The RetroAchievements screen: a sign-in panel for username and API key, then one +/// card per linked Gen 1-5 game with its official achievement set and the player's +/// unlock state. Cached sets open instantly; Refresh re-queries the RA API. +/// +public sealed class RetroAchievementsPage : ContentPage, IPadHandler +{ + private readonly RetroAchievementsViewModel _viewModel; + private readonly SavePickerViewModel _saves; + private Grid _hostGrid = null!; + private CollectionView _list = null!; + private View _signInPanel = null!; + private int _index = -1; + + public RetroAchievementsPage(RetroAchievementsViewModel viewModel, SavePickerViewModel saves) + { + _viewModel = viewModel; + _saves = saves; + BindingContext = viewModel; + Title = "RetroAchievements"; + BackgroundColor = UiTokens.Housing; + NavigationPage.SetHasNavigationBar(this, false); + + var back = Kit.MiniCapsule(AppStrings.Back, UiTokens.Ink0); + back.WidthRequest = 72; + back.Clicked += async (_, _) => await Navigation.PopAsync(); + + var header = Kit.HeaderBar("RetroAchievements"); + header.VerticalOptions = LayoutOptions.Center; + var titleRow = new Grid + { + ColumnSpacing = 10, + ColumnDefinitions = [new(GridLength.Auto), new(GridLength.Star)], + Children = { back, header }, + }; + Grid.SetColumn(header, 1); + + var readout = new Label + { + TextColor = UiTokens.InkSoft, + FontFamily = DsChrome.PixelFont, + FontSize = UiTokens.TextBody, + LineBreakMode = LineBreakMode.WordWrap, + }; + readout.SetBinding(Label.TextProperty, new Binding(nameof(RetroAchievementsViewModel.Status), converter: Kit.TidyText)); + + var refresh = Kit.Capsule(AppStrings.Fr ? "Rafraîchir" : "Refresh", UiTokens.Cyan); + refresh.Clicked += (_, _) => _ = RefreshAsync(); + + _signInPanel = BuildSignInPanel(); + + _list = new CollectionView + { + SelectionMode = SelectionMode.Single, + ItemTemplate = new DataTemplate(() => BuildGameCard()), + }; + _list.SetBinding(ItemsView.ItemsSourceProperty, nameof(RetroAchievementsViewModel.Games)); + _list.SelectionChanged += (_, _) => { }; + + var body = new Grid + { + RowSpacing = 10, + RowDefinitions = [new(GridLength.Auto), new(GridLength.Auto), new(GridLength.Star)], + Children = { Kit.Well(readout, padding: 8), _signInPanel, _list }, + }; + Grid.SetRow(_signInPanel, 1); + Grid.SetRow(_list, 2); + + var panel = Kit.DevicePanel(body); + var root = new Grid + { + Padding = new Thickness(14, 10), + RowSpacing = 10, + RowDefinitions = [new(GridLength.Auto), new(GridLength.Star)], + Children = { titleRow, panel }, + }; + Grid.SetRow(panel, 1); + _hostGrid = new Grid { Children = { DsChrome.GridBackground(), root } }; + Content = _hostGrid; + } + + private View BuildSignInPanel() + { + var userEntry = new Entry + { + FontSize = 16, + FontFamily = DsChrome.PixelFont, + TextColor = UiTokens.Ink0, + BackgroundColor = UiTokens.ShellPress, + Placeholder = AppStrings.RaUsername, + PlaceholderColor = UiTokens.Ink1, + IsSpellCheckEnabled = false, + IsTextPredictionEnabled = false, + ReturnType = ReturnType.Next, + }; + var keyEntry = new Entry + { + FontSize = 16, + FontFamily = DsChrome.PixelFont, + TextColor = UiTokens.Ink0, + BackgroundColor = UiTokens.ShellPress, + Placeholder = AppStrings.RaApiKey, + PlaceholderColor = UiTokens.Ink1, + IsPassword = true, + IsSpellCheckEnabled = false, + IsTextPredictionEnabled = false, + ReturnType = ReturnType.Done, + }; + var signIn = Kit.Capsule(AppStrings.SignIn, UiTokens.Green); + signIn.Clicked += async (_, _) => + { + _viewModel.RememberUsername(userEntry.Text ?? ""); + await _viewModel.SignInCommand.ExecuteAsync(keyEntry.Text); + UpdateSignInState(); + await RefreshAsync(); + }; + var signOut = Kit.Capsule(AppStrings.SignOut, UiTokens.Ink1); + signOut.Clicked += (_, _) => + { + _viewModel.SignOutCommand.Execute(null); + UpdateSignInState(); + }; + var row = new HorizontalStackLayout { Spacing = 8, Children = { signIn, signOut } }; + return Kit.Well(new VerticalStackLayout + { + Spacing = 8, + Children = { userEntry, keyEntry, row }, + }, padding: 8); + } + + private void UpdateSignInState() + { + _signInPanel.IsVisible = !_viewModel.SignedIn; + if (_viewModel.SignedIn) _viewModel.Status = AppStrings.SignedInAs(_viewModel.CurrentUsername ?? ""); + } + + private static View BuildGameCard() + { + // A card per game: title, completion percent, and the achievement rows beneath. + var title = new Label { TextColor = UiTokens.Ink0, FontFamily = DsChrome.PixelFont, FontSize = 15 }; + title.SetBinding(Label.TextProperty, "Title"); + + var percent = new Label { TextColor = UiTokens.Cyan, FontFamily = DsChrome.PixelFont, FontSize = 13 }; + percent.SetBinding(Label.TextProperty, new Binding("Percent", stringFormat: "{0}%")); + + var achievements = new CollectionView + { + SelectionMode = SelectionMode.None, + ItemTemplate = new DataTemplate(BuildAchievementRow), + }; + achievements.SetBinding(ItemsView.ItemsSourceProperty, "Achievements"); + + return Kit.Well(new VerticalStackLayout + { + Spacing = 6, + Children = + { + new Grid + { + ColumnDefinitions = [new(GridLength.Star), new(GridLength.Auto)], + Children = { title, percent }, + }, + achievements, + }, + }, padding: 10); + } + + private static View BuildAchievementRow() + { + var glyph = new Label + { + Text = "\u25cb", + FontFamily = DsChrome.PixelFont, + FontSize = 14, + VerticalOptions = LayoutOptions.Center, + WidthRequest = 22, + }; + glyph.SetBinding(Label.TextProperty, new Binding(nameof(RetroAchievement.Earned), converter: EarnedGlyphConverter.Instance)); + glyph.SetBinding(Label.TextColorProperty, new Binding(nameof(RetroAchievement.Earned), converter: EarnedGlyphColorConverter.Instance)); + var title = new Label { TextColor = UiTokens.Ink0, FontFamily = DsChrome.PixelFont, FontSize = 13, LineBreakMode = LineBreakMode.TailTruncation }; + title.SetBinding(Label.TextProperty, nameof(RetroAchievement.Title)); + var detail = new Label { TextColor = UiTokens.InkSoft, FontFamily = DsChrome.PixelFont, FontSize = 11, LineBreakMode = LineBreakMode.TailTruncation, MaxLines = 2 }; + detail.SetBinding(Label.TextProperty, nameof(RetroAchievement.Description)); + var points = new Label { TextColor = UiTokens.Ink1, FontFamily = DsChrome.PixelFont, FontSize = 12, VerticalOptions = LayoutOptions.Center }; + points.SetBinding(Label.TextProperty, new Binding(nameof(RetroAchievement.Points), stringFormat: "+{0}")); + + return new Grid + { + ColumnDefinitions = [new(GridLength.Auto), new(GridLength.Star), new(GridLength.Auto)], + ColumnSpacing = 6, + Children = { glyph, title, detail, points }, + }; + } + + protected override void OnAppearing() + { + base.OnAppearing(); + _viewModel.LoadCached(); + UpdateSignInState(); + IPlatformApplication.Current?.Services.GetService()?.Push(this); + } + + protected override void OnDisappearing() + { + base.OnDisappearing(); + IPlatformApplication.Current?.Services.GetService()?.Remove(this); + } + + private async Task RefreshAsync() + { + try + { + await _viewModel.RefreshForSavesAsync(_saves.Saves.ToList()); + } + catch (Exception error) + { + _viewModel.Status = AppStrings.RaRefreshFailed(error.Message); + } + } + + public bool OnPadButton(PadButton button) + { + switch (button) + { + case PadButton.Up: return MoveCursor(-1); + case PadButton.Down: return MoveCursor(1); + case PadButton.A: _ = RefreshAsync(); return true; + case PadButton.B: _ = Navigation.PopAsync(); return true; + default: return true; + } + } + + private bool MoveCursor(int delta) + { + if (_viewModel.Games.Count == 0) return true; + _index = Math.Clamp(_index < 0 ? 0 : _index + delta, 0, _viewModel.Games.Count - 1); + _list.SelectedItem = _viewModel.Games[_index]; + _list.ScrollTo(_index, position: ScrollToPosition.MakeVisible, animate: false); + return true; + } +} + +/// Turns an achievement's earned flag into the Pokéball glyph row: caught or empty. +public sealed class EarnedGlyphConverter : IValueConverter +{ + public static readonly EarnedGlyphConverter Instance = new(); + + public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) => + value is true ? "\u2714" : "\u25cb"; + + public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) => + throw new NotSupportedException(); +} + +/// Green when the achievement is unlocked, muted ink while it is locked. +public sealed class EarnedGlyphColorConverter : IValueConverter +{ + public static readonly EarnedGlyphColorConverter Instance = new(); + + public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) => + value is true ? UiTokens.Green : UiTokens.Ink1; + + public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/src/PKForge.App/Views/RomLibraryPage.cs b/src/PKForge.App/Views/RomLibraryPage.cs new file mode 100644 index 00000000..06a6da23 --- /dev/null +++ b/src/PKForge.App/Views/RomLibraryPage.cs @@ -0,0 +1,416 @@ +using Microsoft.Maui.Controls.Shapes; +using PKForge.App.Services; +using PKForge.App.Theme; +using PKForge.App.ViewModels; +using PKForge.Domain; + +namespace PKForge.App.Views; + +/// +/// The game library, PlayHub-style: one card per discovered Pokémon ROM with its +/// artwork (cartridge mark as fallback), the generation · emulator subtitle, and a +/// colored status line — green when a save was found, amber when the ROM is linked, +/// muted otherwise. Tapping a playable row launches it in the console's bound +/// emulator. The emulator chips above link in one tap through the standard save +/// folders, never a folder picker. +/// +public sealed class RomLibraryPage : ContentPage, IPadHandler +{ + private readonly IRomScanner _scanner; + private readonly IRomLibraryStore _library; + private readonly IConsoleEmulatorStore _consoleEmulators; + private readonly IEmulatorLauncher _launcher; + private readonly DirectSaveScanner? _saveScanner; + private VerticalStackLayout _rows = null!; + private Grid _hostGrid = null!; + private Label _readout = null!; + private Label _countLabel = null!; + + public RomLibraryPage(IRomScanner scanner, IRomLibraryStore library, IConsoleEmulatorStore consoleEmulators, IEmulatorLauncher launcher, ISaveEngine? engine = null) + { + _scanner = scanner; + _library = library; + _consoleEmulators = consoleEmulators; + _launcher = launcher; + _saveScanner = engine is not null ? new DirectSaveScanner(engine) : null; + Title = AppStrings.LibraryTitle; + BackgroundColor = UiTokens.Housing; + NavigationPage.SetHasNavigationBar(this, false); + + var back = Kit.MiniCapsule(AppStrings.Back, UiTokens.Ink0); + back.WidthRequest = 72; + back.Clicked += async (_, _) => await Navigation.PopAsync(); + + var header = Kit.HeaderBar(AppStrings.LibraryTitle); + header.VerticalOptions = LayoutOptions.Center; + + _countLabel = new Label + { + FontFamily = DsChrome.PixelFont, + FontSize = UiTokens.TextLabel, + TextColor = UiTokens.Ink1, + VerticalOptions = LayoutOptions.Center, + }; + + var scan = Kit.Capsule(AppStrings.ScanRoms, UiTokens.Cyan); + scan.Clicked += (_, _) => _ = ScanAsync(); + + var titleRow = new Grid + { + ColumnSpacing = 10, + ColumnDefinitions = [new(GridLength.Auto), new(GridLength.Star), new(GridLength.Auto), new(GridLength.Auto)], + Children = { back, header, _countLabel, scan }, + }; + Grid.SetColumn(header, 1); + Grid.SetColumn(_countLabel, 2); + Grid.SetColumn(scan, 3); + + _readout = new Label + { + TextColor = UiTokens.InkSoft, + FontFamily = DsChrome.PixelFont, + FontSize = UiTokens.TextBody, + LineBreakMode = LineBreakMode.WordWrap, + }; + + _rows = new VerticalStackLayout { Spacing = 6 }; + var scrollable = new ScrollView + { + Orientation = ScrollOrientation.Vertical, + VerticalScrollBarVisibility = ScrollBarVisibility.Never, + Content = _rows, + }; + + var body = new Grid + { + RowSpacing = 10, + RowDefinitions = [new(GridLength.Auto), new(GridLength.Star)], + Children = { Kit.Well(_readout, padding: 8), scrollable }, + }; + Grid.SetRow(scrollable, 1); + + var panel = Kit.DevicePanel(body); + var root = new Grid + { + Padding = new Thickness(14, 10), + RowSpacing = 10, + RowDefinitions = [new(GridLength.Auto), new(GridLength.Star)], + Children = { titleRow, panel }, + }; + Grid.SetRow(panel, 1); + _hostGrid = new Grid { Children = { DsChrome.GridBackground(), root } }; + Content = _hostGrid; + } + + protected override void OnAppearing() + { + base.OnAppearing(); + Rebuild(_library.GetAll()); + IPlatformApplication.Current?.Services.GetService()?.Push(this); + } + + protected override void OnDisappearing() + { + base.OnDisappearing(); + IPlatformApplication.Current?.Services.GetService()?.Remove(this); + } + + private void Rebuild(IReadOnlyList roms) + { + _rows.Children.Clear(); + // The whole catalog shows: owned games first (with their ROM), the rest after, + // each with a colored owned/missing label. + var byName = roms.GroupBy(r => r.GameName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + // Catalog order (Gen 1 → 9, release order within a generation): owned games + // show their ROM, the rest show the colored missing label. + var ordered = TrophyCatalog.Games + .Select(game => (Game: game, Rom: byName.GetValueOrDefault(Normalise(game.Label)))) + .ToList(); + foreach (var (game, rom) in ordered) + _rows.Children.Add(rom is not null ? BuildRomCard(rom) : BuildMissingCard(game.Label, game.Generation)); + _countLabel.Text = AppStrings.RomCount(roms.Count); + _readout.Text = roms.Count > 0 + ? AppStrings.RomCount(roms.Count) + : _scanner.RomRoot is null ? AppStrings.NoRomRoot : AppStrings.Fr + ? "Appuyez sur Scanner pour chercher vos ROMs dans /storage/emulated/0/ROMs." + : "Tap Scan to look for your ROMs in /storage/emulated/0/ROMs."; + } + + private static string Normalise(string label) => + label.Replace("Pokémon ", "", StringComparison.OrdinalIgnoreCase) + .Replace("Pokemon ", "", StringComparison.OrdinalIgnoreCase) + .Replace(" Version", "", StringComparison.OrdinalIgnoreCase).Trim(); + + /// A catalog game the player has no ROM of: muted card, missing label. + private View BuildMissingCard(string label, int generation) + { + // The artwork loads for missing games too: the catalog always shows its face. + var art = new Image + { + Aspect = Aspect.AspectFit, + WidthRequest = 44, + HeightRequest = 44, + IsVisible = false, + }; + var mark = new GameCartridgeMark { WidthRequest = 40, HeightRequest = 40, Generation = generation }; + _ = LoadArtAsync(art, mark, Normalise(label)); + var title = new Label + { + Text = Normalise(label), + FontFamily = DsChrome.PixelFont, + FontSize = 16, + TextColor = UiTokens.Ink1, + VerticalTextAlignment = TextAlignment.Center, + }; + var subtitle = new Label + { + Text = $"Gen {generation}", + FontFamily = DsChrome.PixelFont, + FontSize = UiTokens.TextLabel, + TextColor = UiTokens.InkSoft, + VerticalTextAlignment = TextAlignment.Center, + }; + var missing = new Label + { + Text = AppStrings.Fr ? "Pas de ROM" : "No ROM", + FontFamily = DsChrome.PixelFont, + FontSize = UiTokens.TextLabel, + TextColor = UiTokens.Ink1, + VerticalTextAlignment = TextAlignment.Center, + HorizontalOptions = LayoutOptions.End, + }; + var text = new VerticalStackLayout { Spacing = 2, VerticalOptions = LayoutOptions.Center, Children = { title, subtitle } }; + var card = new Border + { + BackgroundColor = UiTokens.Housing, + Stroke = UiTokens.ShellEdge, + StrokeThickness = 1, + Opacity = 0.6, + StrokeShape = new RoundRectangle { CornerRadius = UiTokens.ControlRadius }, + Padding = new Thickness(10, 8), + Content = new Grid + { + ColumnDefinitions = [new(GridLength.Auto), new(GridLength.Star), new(GridLength.Auto)], + ColumnSpacing = 12, + Children = { new Grid { Children = { mark, art } }, text, missing }, + }, + }; + Grid.SetColumn(text, 1); + Grid.SetColumn(missing, 2); + return card; + } + + /// One game card: artwork (cartridge mark as fallback), title, generation· + /// emulator subtitle, and the colored status line. + private View BuildRomCard(LibraryRom rom) + { + // The artwork loads asynchronously at build time: the cartridge mark shows + // until the icon is ready, then takes its place (PlayHub's pattern). + var art = new Image + { + Aspect = Aspect.AspectFit, + WidthRequest = 44, + HeightRequest = 44, + IsVisible = false, + }; + var mark = new GameCartridgeMark { WidthRequest = 40, HeightRequest = 40, Generation = rom.Generation }; + _ = LoadArtAsync(art, mark, rom.GameName); + + var title = new Label + { + FontFamily = DsChrome.PixelFont, + FontSize = 16, + TextColor = UiTokens.Ink0, + VerticalTextAlignment = TextAlignment.Center, + }; + title.SetBinding(Label.TextProperty, "GameName"); + + var subtitle = new Label + { + FontFamily = DsChrome.PixelFont, + FontSize = UiTokens.TextLabel, + TextColor = UiTokens.Ink1, + VerticalTextAlignment = TextAlignment.Center, + }; + subtitle.SetBinding(Label.TextProperty, new Binding("Console", converter: ConsoleNameConverter.Instance)); + + var status = new Label + { + FontFamily = DsChrome.PixelFont, + FontSize = UiTokens.TextLabel, + VerticalTextAlignment = TextAlignment.Center, + HorizontalOptions = LayoutOptions.End, + TextColor = UiTokens.Cyan, + }; + status.SetBinding(Label.TextProperty, new Binding("FileName", converter: FileNameTruncateConverter.Instance)); + + var text = new VerticalStackLayout + { + Spacing = 2, + VerticalOptions = LayoutOptions.Center, + Children = { title, subtitle }, + }; + + var row = new Border + { + BackgroundColor = UiTokens.Paper, + Stroke = UiTokens.ShellEdge, + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = UiTokens.ControlRadius }, + Padding = new Thickness(10, 8), + Content = new Grid + { + ColumnDefinitions = [new(GridLength.Auto), new(GridLength.Star), new(GridLength.Auto)], + ColumnSpacing = 12, + Children = { new Grid { Children = { mark, art } }, text, status }, + }, + }; + Grid.SetColumn(text, 1); + Grid.SetColumn(status, 2); + + // Every binding in the card reads from the row's own context. + row.BindingContext = rom; + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => _ = LaunchAsync(rom); + row.GestureRecognizers.Add(tap); + return row; + } + + /// Loads the game's artwork; the cartridge mark stays until it lands. + private static async Task LoadArtAsync(Image art, GameCartridgeMark mark, string gameName) + { + var path = await GameArt.GetIconAsync($"Pokémon {gameName}"); + if (path is null) return; + art.Source = path; + art.IsVisible = true; + mark.IsVisible = false; + } + + /// A tap launches the game. GB/GBC/GBA always play in PKForge itself + /// (the embedded core); the other consoles go to their bound external emulator. + private async Task LaunchAsync(LibraryRom rom) + { + // Consoles PKForge runs in-app (GB/GBC/GBA): embedded first and only — + // a stale binding or an installed external emulator is never consulted. + if (ConsoleRules.RunsInApp(rom.Console)) + { + var save = FindSaveFor(rom.FilePath); + var session = await Task.Run(() => Platforms.Android.EmbeddedGameSession.TryStart(rom.FilePath, save)); + if (session is not null) + { + await MainThread.InvokeOnMainThreadAsync(() => Navigation.PushAsync(new EmbeddedGamePage(session))); + return; + } + await PadMenu.ShowAsync(_hostGrid, AppStrings.RomMissing, + $"{rom.GameName}: the embedded core is not packed in this build.", AppStrings.Ok); + return; + } + var bound = _consoleEmulators.Find(rom.Console); + var fallback = ConsoleRules.EmulatorsFor(rom.Console) + .FirstOrDefault(kind => EmulatorCatalog.IsInstalled(kind, _launcher.IsInstalled)); + if (bound is null && fallback == default) + { + await PadMenu.ShowAsync(_hostGrid, AppStrings.RomMissing, + $"{rom.GameName}: {AppStrings.NotInstalled}.", AppStrings.Ok); + return; + } + var kind = bound ?? fallback; + var link = new RomLink("library", rom.FilePath, rom.FileName, kind, + RomNameRules.GenerationOf(rom.GameName), $"Pokémon {rom.GameName}"); + if (await _launcher.LaunchAsync(link)) + { + // The game runs: the lower screen becomes its companion until the + // player comes back. + await (IPlatformApplication.Current?.Services.GetService()?.StartForGameAsync(link) ?? Task.CompletedTask); + return; + } + await PadMenu.ShowAsync(_hostGrid, AppStrings.RomMissing, + $"{SaveDescriptions.EmulatorName(kind)}: {rom.FileName}", AppStrings.Ok); + } + + /// The save file mGBA's convention names for a ROM, when it exists. + private static string? FindSaveFor(string romPath) + { + try + { + var save = System.IO.Path.ChangeExtension(romPath, ".sav"); + return File.Exists(save) ? save : null; + } + catch (ArgumentException) + { + return null; + } + } + + /// One pass over every console's ES-DE folder; the library is replaced wholesale. + private async Task ScanAsync() + { + _readout.Text = AppStrings.Scanning; + try + { + var roms = await Task.Run(() => + { + var found = new List(); + foreach (var console in Enum.GetValues()) + found.AddRange(_scanner.Scan(console)); + return found; + }).ConfigureAwait(true); + _library.ReplaceAll(roms); + Rebuild(roms); + } + catch (Exception error) + { + _readout.Text = error.Message; + } + } + + public bool OnPadButton(PadButton button) + { + switch (button) + { + case PadButton.A: _ = ScanAsync(); return true; + case PadButton.B: _ = Navigation.PopAsync(); return true; + default: return true; // own the pad while this page is up + } + } +} + +/// Console enum → display name. +public sealed class ConsoleNameConverter : IValueConverter +{ + public static readonly ConsoleNameConverter Instance = new(); + + public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) => + value is GameConsole console ? ConsoleLabel(console) : string.Empty; + + public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) => + throw new NotSupportedException(); + + public static string ConsoleLabel(GameConsole console) => console switch + { + GameConsole.GameBoy => "Game Boy", + GameConsole.GameBoyColor => "Game Boy Color", + GameConsole.GameBoyAdvance => "Game Boy Advance", + GameConsole.NintendoDs => "Nintendo DS", + GameConsole.Nintendo3Ds => "Nintendo 3DS", + GameConsole.GameCube => "GameCube", + GameConsole.NintendoSwitch => "Switch", + _ => console.ToString(), + }; +} + +/// File names get one truncated line. +public sealed class FileNameTruncateConverter : IValueConverter +{ + public static readonly FileNameTruncateConverter Instance = new(); + + public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) + { + if (value is not string name) return string.Empty; + return name.Length > 28 ? name[..25] + "…" : name; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/src/PKForge.App/Views/SaveIdentitySheet.cs b/src/PKForge.App/Views/SaveIdentitySheet.cs index 2d875341..fe67363c 100644 --- a/src/PKForge.App/Views/SaveIdentitySheet.cs +++ b/src/PKForge.App/Views/SaveIdentitySheet.cs @@ -12,13 +12,16 @@ namespace PKForge.App.Views; /// public static class SaveIdentitySheet { - public const string OpenOption = "Open save"; - private const string RenameOption = "Rename"; - private const string ColorOption = "Cartridge color"; - private const string GameOption = "Set game…"; - private const string HideOption = "Hide save"; - private const string ResetOption = "Reset to detected"; - private const string ReadOnlyOption = "Make read-only"; + public static string OpenOption => AppStrings.OpenSave; + public static string PlayOption => AppStrings.Play; + public static string LinkRomOption => AppStrings.LinkRom; + public static string UnlinkRomOption => AppStrings.UnlinkRom; + private static string RenameOption => AppStrings.Fr ? "Renommer" : "Rename"; + private static string ColorOption => AppStrings.Fr ? "Couleur de la cartouche" : "Cartridge color"; + private static string GameOption => AppStrings.Fr ? "Définir le jeu…" : "Set game…"; + private static string HideOption => AppStrings.Fr ? "Masquer la sauvegarde" : "Hide save"; + private static string ResetOption => AppStrings.Fr ? "Rétablir la détection" : "Reset to detected"; + private static string ReadOnlyOption => AppStrings.Fr ? "Rendre en lecture seule" : "Make read-only"; /// /// Shows the save menu. Returns true when the player picked "Open save" (the caller @@ -31,11 +34,25 @@ public static async Task ShowAsync(Grid host, SavePickerViewModel picker, var resolved = save.Identity; var options = new List(); + var romLink = RomLinks?.FindBySave(save.DocumentId); if (offerOpen) options.Add(new PadOption(OpenOption, IconPath: "play")); + if (romLink is { } linked) + { + var where = linked.Generation is >= 1 and <= 3 + ? (AppStrings.Fr ? "dans PKForge" : "in PKForge") + : $"in {SaveDescriptions.EmulatorName(linked.Emulator)}"; + options.Add(new PadOption(PlayOption, IconPath: "play", + Detail: AppStrings.Fr ? $"Lancer {linked.RomFileName} {where}" : $"Launch {linked.RomFileName} {where}")); + options.Add(new PadOption(UnlinkRomOption, IconPath: "unlink", Detail: AppStrings.Fr ? "Oublie la ROM de cette sauvegarde. Le fichier de sauvegarde reste." : "Forget this save's ROM. The save file stays.")); + } + options.Add(new PadOption(LinkRomOption, IconPath: "file", + Detail: romLink is null + ? (AppStrings.Fr ? "Choisissez la ROM de ce jeu une fois ; Jouer la lancera dans PKForge (GB/GBA) ou son émulateur." : "Pick this game's ROM once; Play then launches it in PKForge (GB/GBA) or its emulator.") + : (AppStrings.Fr ? $"Remplacer {romLink.RomFileName}." : $"Replace {romLink.RomFileName}."))); options.Add(new PadOption(RenameOption, IconPath: "rename")); options.Add(new PadOption(ColorOption, Glyph: "●", Accent: SaveColors.For(resolved?.ColorKey, save.Generation), - Detail: "The colour of this save's cartridge on the Home shelf.")); - if (guess is not null) options.Add(new PadOption(GameOption, IconPath: "game", Detail: "Say which game this is, from those that share its save format.")); + Detail: AppStrings.Fr ? "La couleur de la cartouche sur l'étagère d'accueil." : "The colour of this save's cartridge on the Home shelf.")); + if (guess is not null) options.Add(new PadOption(GameOption, IconPath: "game", Detail: AppStrings.Fr ? "Dites quel jeu c'est, parmi ceux qui partagent son format de sauvegarde." : "Say which game this is, from those that share its save format.")); // Only saves the box already warned about as a suspected ROM hack carry the choice. var flaggedHack = RomHackNotice.IsFlagged(save.DocumentId); var acceptedHack = store.Get(save.DocumentId)?.AcceptedHackRisk == true; @@ -43,37 +60,46 @@ public static async Task ShowAsync(Grid host, SavePickerViewModel picker, options.Add(new PadOption(ReadOnlyOption, IconPath: "box", Detail: $"{RomHackNotice.Marker} · editing allowed at your own risk")); else if (flaggedHack) options.Add(new PadOption(RomHackNotice.EditOption, Glyph: "!", Accent: UiTokens.Bad, Detail: $"{RomHackNotice.Marker} · read-only now")); - options.Add(new PadOption(HideOption, IconPath: "hide", Detail: "Remove from Home and pickers. Settings can show it again.")); - if (resolved is { IsCustomized: true }) options.Add(new PadOption(ResetOption, IconPath: "restore", Detail: "Forget your name, colour and game choices. The file is not touched.")); + options.Add(new PadOption(HideOption, IconPath: "hide", Detail: AppStrings.Fr ? "Retire de l'accueil et des sélecteurs. Les réglages peuvent l'afficher à nouveau." : "Remove from Home and pickers. Settings can show it again.")); + if (resolved is { IsCustomized: true }) options.Add(new PadOption(ResetOption, IconPath: "restore", Detail: AppStrings.Fr ? "Oublie vos choix de nom, couleur et jeu. Le fichier n'est pas touché." : "Forget your name, colour and game choices. The file is not touched.")); var choice = await PadMenu.ShowAsync(host, save.GameLabel, Describe(save), [.. options]); var current = store.Get(save.DocumentId) ?? new SaveIdentity(save.DocumentId); switch (choice) { - case OpenOption: + case var open when open == OpenOption: return true; - case RenameOption: + case var play when play == PlayOption && romLink is not null: + await LaunchAsync(host, romLink); + return false; + case var linkRom when linkRom == LinkRomOption: + await LinkRomAsync(host, save); + return false; + case var unlinkRom when unlinkRom == UnlinkRomOption && romLink is not null: + RomLinks?.Remove(save.DocumentId); + return false; + case var rename when rename == RenameOption: await RenameAsync(host, store, current, current.DisplayName ?? guess?.SuggestedName ?? save.GameLabel); return false; - case ColorOption: + case var color when color == ColorOption: await PickColorAsync(host, store, current, save.Generation); return false; - case GameOption when guess is not null: + case var game when game == GameOption && guess is not null: await PickGameAsync(host, store, current, guess); return false; - case RomHackNotice.EditOption when flaggedHack: + case var hackEdit when hackEdit == RomHackNotice.EditOption && flaggedHack: if (await PadMenu.ConfirmAsync(host, RomHackNotice.Title, RomHackNotice.Warning, RomHackNotice.EditOption)) Writer?.ConfirmLayoutRisk(save.DocumentId); return false; - case ReadOnlyOption: + case var readOnly when readOnly == ReadOnlyOption: Writer?.RevokeLayoutRisk(save.DocumentId); return false; - case HideOption: + case var hide when hide == HideOption: store.Set(current with { Hidden = true }); return false; - case ResetOption: - if (await PadMenu.ConfirmAsync(host, "Reset to detected", - "Forget this save's name, color, game choice and ROM-hack editing choice, and show it again if hidden? The save file itself is not touched.", "Reset")) + case var reset when reset == ResetOption: + if (await PadMenu.ConfirmAsync(host, ResetOption, + AppStrings.Fr ? "Oublier le nom, la couleur, le choix de jeu et le choix d'édition ROM-hack de cette sauvegarde, et l'afficher à nouveau si masquée ? Le fichier de sauvegarde n'est pas touché." : "Forget this save's name, color, game choice and ROM-hack editing choice, and show it again if hidden? The save file itself is not touched.", AppStrings.Fr ? "Rétablir" : "Reset")) store.Reset(save.DocumentId); return false; default: @@ -83,6 +109,130 @@ public static async Task ShowAsync(Grid host, SavePickerViewModel picker, private static ISafeSaveWriter? Writer => IPlatformApplication.Current?.Services.GetService(); + private static IRomLinkStore? RomLinks => IPlatformApplication.Current?.Services.GetService(); + + private static IEmulatorLauncher? Launcher => IPlatformApplication.Current?.Services.GetService(); + + private static IDocumentPicker? FilePicker => IPlatformApplication.Current?.Services.GetService(); + + /// Launches a linked ROM: GB/GBC/GBA (Gen 1-3) play in PKForge itself via the + /// embedded core; anything else is handed to its external emulator. + private static async Task LaunchAsync(Grid host, RomLink link) + { + if (link.Generation is >= 1 and <= 3) + { + await LaunchEmbeddedAsync(host, link); + return; + } + var launcher = Launcher; + if (launcher is null) return; + if (await launcher.LaunchAsync(link)) + { + // The game runs: the lower screen becomes its companion until the + // player comes back. + await (IPlatformApplication.Current?.Services.GetService()?.StartForGameAsync(link) ?? Task.CompletedTask); + return; + } + await PadMenu.ShowAsync(host, "Could not launch", + $"{SaveDescriptions.EmulatorName(link.Emulator)} did not accept {link.RomFileName}. Open the game from its library instead.", "OK"); + } + + /// Opens the linked GB/GBC/GBA ROM in the embedded game screen. + private static async Task LaunchEmbeddedAsync(Grid host, RomLink link) + { + var save = FindSaveFor(link.RomUri); + var session = await Task.Run(() => Platforms.Android.EmbeddedGameSession.TryStart(link.RomUri, save)); + if (session is null) + { + await PadMenu.ShowAsync(host, "Could not launch", + $"{link.RomFileName}: the embedded core is not packed in this build.", "OK"); + return; + } + await MainThread.InvokeOnMainThreadAsync(async () => + { + if (Microsoft.Maui.Controls.Application.Current?.Windows.FirstOrDefault()?.Page is { } page) + await page.Navigation.PushAsync(new EmbeddedGamePage(session)); + }); + } + + /// The save file mGBA's convention names for a ROM, when it exists. + private static string? FindSaveFor(string romPath) + { + try + { + var save = System.IO.Path.ChangeExtension(romPath, ".sav"); + return File.Exists(save) ? save : null; + } + catch (ArgumentException) + { + return null; + } + } + + /// Picks a ROM for this save: the library first (one tap), the file picker as the manual path. + private static async Task LinkRomAsync(Grid host, DetectedSave save) + { + var links = RomLinks; + if (links is null) return; + + // The library path: the scan already found this game's ROM, and the console's + // binding decides the emulator. One PadOption, no folder walking. + var library = IPlatformApplication.Current?.Services.GetService()?.GetAll() + ?? []; + var candidates = library.Where(r => TrophyCatalog.LabelMatches("Pokémon " + r.GameName, save.EngineHint)).ToList(); + var options = new List(); + foreach (var rom in candidates.Take(6)) + options.Add(new PadOption(rom.FileName, IconPath: "platform-gba", Detail: rom.SystemFolder)); + if (options.Count > 0) options.Add(new PadOption(AppStrings.Fr ? "Choisir un fichier plutôt" : "Pick a file instead", IconPath: "file")); + else options.Add(new PadOption(AppStrings.Fr ? "Choisir un fichier ROM" : "Pick a ROM file", IconPath: "file")); + + var choice = await PadMenu.ShowAsync(host, LinkRomOption, + AppStrings.Fr ? "Choisissez la ROM de ce jeu. La copie de la bibliothèque évite le sélecteur." : "Choose this game's ROM. The library's copy needs no picking.", [.. options]); + if (choice is null) return; + + if (candidates.FirstOrDefault(r => r.FileName == choice) is { } fromLibrary) + { + // Gen 1-3 (GB/GBC/GBA) always launch embedded in PKForge; the console binding + // only matters for the consoles that still need an external emulator. + var kind = ConsoleRules.RunsInApp(fromLibrary.Console) + ? EmulatorKind.Embedded + : IPlatformApplication.Current?.Services.GetService()?.Find(fromLibrary.Console) + ?? ConsoleRules.EmulatorsFor(fromLibrary.Console) + .FirstOrDefault(k => k != EmulatorKind.Embedded && EmulatorCatalog.IsInstalled(k, Launcher is { } l ? l.IsInstalled : _ => false)); + if (kind == default && !ConsoleRules.RunsInApp(fromLibrary.Console)) + { + await PadMenu.ShowAsync(host, AppStrings.RomMissing, + $"Bind an emulator for the {fromLibrary.Console} in the Emulators tab, or install one.", "OK"); + return; + } + links.Set(new RomLink(save.DocumentId, fromLibrary.FilePath, fromLibrary.FileName, kind, save.Generation, save.EngineHint)); + return; + } + + // The manual path: the SAF file picker, as before. + var picker = FilePicker; + if (picker is null) return; + var picked = await picker.PickSaveAsync(); + if (picked is null) return; + var name = picked.DisplayName; + if (string.IsNullOrWhiteSpace(name)) return; + var emulators = RomLinkRules.EmulatorsForRom(save.Generation, name); + if (emulators.Count == 0) + { + await PadMenu.ShowAsync(host, "ROM not linked", + $"PKForge cannot launch a Gen {save.Generation} game from {name}. Link a ROM for a Game Boy, GBA or Nintendo DS Pokémon game instead.", "OK"); + return; + } + var installed = emulators.Where(kind => EmulatorCatalog.IsInstalled(kind, Launcher is { } l ? l.IsInstalled : _ => false)).ToArray(); + if (installed.Length == 0) + { + await PadMenu.ShowAsync(host, AppStrings.RomMissing, + $"Install one of these to launch {name}: {string.Join(", ", emulators.Select(SaveDescriptions.EmulatorName))}.", "OK"); + return; + } + links.Set(new RomLink(save.DocumentId, picked.DocumentId, name, installed[0], save.Generation, save.EngineHint)); + } + private static string Describe(DetectedSave save) { var lines = new List(); @@ -96,7 +246,7 @@ private static string Describe(DetectedSave save) private static async Task RenameAsync(Grid host, ISaveIdentityStore store, SaveIdentity current, string shown) { - var text = await TextPopup.ShowLineAsync(host, "Name this save", "Leave empty to use the game's name", shown); + var text = await TextPopup.ShowLineAsync(host, AppStrings.Fr ? "Nommer cette sauvegarde" : "Name this save", AppStrings.Fr ? "Laissez vide pour le nom du jeu" : "Leave empty to use the game's name", shown); if (text is null) return; store.Set(current with { DisplayName = text.Length == 0 ? null : text[..Math.Min(text.Length, 32)] }); } diff --git a/src/PKForge.App/Views/SecondScreenBoxPage.cs b/src/PKForge.App/Views/SecondScreenBoxPage.cs index 7c3d2beb..101e8510 100644 --- a/src/PKForge.App/Views/SecondScreenBoxPage.cs +++ b/src/PKForge.App/Views/SecondScreenBoxPage.cs @@ -29,6 +29,7 @@ public sealed class SecondScreenBoxPage : ContentPage private readonly PokeparkJournalState? _journalState; private readonly PropertyChangedEventHandler? _secondScreenHandler; private readonly PropertyChangedEventHandler? _journalHandler; + private readonly PropertyChangedEventHandler? _companionHandler; private readonly MonSummaryView _inspector; private Func? _swapAsync; private bool _cleanedUp; @@ -80,6 +81,11 @@ public SecondScreenBoxPage(BoxBrowserViewModel viewModel, ISpriteService sprites // The Living Dex Autopilot's route map: cartridges and the Pokémon travelling between them. var routeMap = new LivingDexRouteMap(sprites, compact: false) { Margin = new Thickness(14, 12) }; routeMap.IsVisible = false; + // The Trophies page's ladder: the scanned game's milestones on the lower screen. + var trophyLadder = new TrophiesLadderView(); + // The in-game companion: the launched game's dex and restore points while + // an emulator owns the foreground. + var companion = new GameCompanionView(); // The box cursor's mon, decoded from the live session OFF the UI thread (latest // request wins) and cached per (session, edit generation, slot), so a cursor sweep @@ -155,6 +161,15 @@ async Task SwapCoreAsync() var showSummary = bankDriven || (owner == SecondScreenOwner.Box && detail is { IsEmpty: false }); var showHero = owner == SecondScreenOwner.Home && preview is not null; var showRoute = owner == SecondScreenOwner.Autopilot && state?.AutopilotRoute is not null; + var showTrophies = owner == SecondScreenOwner.Trophies && state?.TrophyLadder is not null; + var companionStore = services?.GetService(); + var active = companionStore?.Current; + var showCompanion = active is not null; + if (showCompanion) + { + // The emulator owns the foreground: every PKForge surface yields. + showJournal = showDex = showOverview = showSummary = showHero = showRoute = showTrophies = false; + } boxSequence++; // any in-flight box decode is now stale if (showDex) UpdateDex(species!.Value); @@ -167,6 +182,9 @@ async Task SwapCoreAsync() ShowBoxSummary(detail!); } if (showHero) hero.SetGame(preview!); + if (showTrophies) trophyLadder.Show(state!.TrophyLadder!); + if (active is { } shownCompanion) companion.Show(shownCompanion); + if (companionStore?.Live is { } liveSnapshot) companion.ShowLive(liveSnapshot); journal.IsVisible = showJournal; dex.IsVisible = showDex; @@ -175,7 +193,9 @@ async Task SwapCoreAsync() hero.IsVisible = showHero; if (showRoute) routeMap.Show(state!.AutopilotRoute); routeMap.IsVisible = showRoute; - idle.IsVisible = !showJournal && !showDex && !showOverview && !showSummary && !showHero && !showRoute; + trophyLadder.IsVisible = showTrophies; + companion.IsVisible = showCompanion; + idle.IsVisible = !showJournal && !showDex && !showOverview && !showSummary && !showHero && !showRoute && !showTrophies && !showCompanion; PerfTrace.Log("second.swap", swapWatch); } @@ -184,7 +204,7 @@ async Task SwapCoreAsync() SwapAsync(); return Task.CompletedTask; }; - Content = new Grid { Children = { DsChrome.GridBackground(), hero, summary, overview, journal, dex, routeMap, idle } }; + Content = new Grid { Children = { DsChrome.GridBackground(), hero, summary, overview, journal, dex, routeMap, trophyLadder, companion, idle } }; SwapAsync(); // Selected, LegalityBadge and friends change together on a cursor move: coalesce @@ -214,12 +234,24 @@ void QueueSwap() if (args.PropertyName == nameof(SecondScreenState.AutopilotRoute) && state.Owner == SecondScreenOwner.Autopilot && routeMap.IsVisible) // Every dry-run step publishes a route: feed the map directly, no full swap. MainThread.BeginInvokeOnMainThread(() => routeMap.Show(state.AutopilotRoute)); + else if (args.PropertyName == nameof(SecondScreenState.TrophyLadder) && state.Owner == SecondScreenOwner.Trophies && state.TrophyLadder is not null) + // The Trophies page publishes its ladder per scan: feed the view directly, no full swap. + MainThread.BeginInvokeOnMainThread(() => trophyLadder.Show(state.TrophyLadder)); else if (args.PropertyName == nameof(SecondScreenState.InspectorPage)) MainThread.BeginInvokeOnMainThread(() => _inspector.SetPage(state.InspectorPage)); else QueueSwap(); }; state.PropertyChanged += _secondScreenHandler; } + if (services?.GetService() is { } companionStore) + { + _companionHandler = (_, args) => + { + if (args.PropertyName is nameof(Services.GameCompanionStore.Live) or nameof(Services.GameCompanionStore.Current)) + QueueSwap(); + }; + companionStore.PropertyChanged += _companionHandler; + } } private static void LowerScreenFailed(string what, Exception error) @@ -263,6 +295,8 @@ public void Cleanup() _secondScreenState.PropertyChanged -= _secondScreenHandler; if (_journalState is not null && _journalHandler is not null) _journalState.PropertyChanged -= _journalHandler; + if (_companionHandler is not null && IPlatformApplication.Current?.Services.GetService() is { } store) + store.PropertyChanged -= _companionHandler; _journalView?.Detach(); _swapAsync = null; } diff --git a/src/PKForge.App/Views/TrophiesLadderView.cs b/src/PKForge.App/Views/TrophiesLadderView.cs new file mode 100644 index 00000000..2b61a65b --- /dev/null +++ b/src/PKForge.App/Views/TrophiesLadderView.cs @@ -0,0 +1,77 @@ +using PKForge.App.Services; +using PKForge.App.Theme; + +namespace PKForge.App.Views; + +/// +/// The lower screen's trophy surface: the game's name, its completion percent, and the +/// ladder rows (green unlocked, muted locked), drawn with the shared DS-era kit. +/// +public sealed class TrophiesLadderView : VerticalStackLayout +{ + private readonly Label _title = new() + { + FontFamily = DsChrome.PixelFont, FontSize = 16, TextColor = UiTokens.Ink0, + HorizontalTextAlignment = TextAlignment.Center, + }; + private readonly Label _percent = new() + { + FontFamily = DsChrome.PixelFont, FontSize = 13, TextColor = UiTokens.Cyan, + HorizontalTextAlignment = TextAlignment.Center, + }; + private readonly VerticalStackLayout _rows = new() { Spacing = 3 }; + + public TrophiesLadderView() + { + Spacing = 8; + Margin = new Thickness(14, 12); + Padding = new Thickness(10); + HorizontalOptions = LayoutOptions.Center; + VerticalOptions = LayoutOptions.Center; + Children.Add(_title); + Children.Add(_percent); + Children.Add(_rows); + IsVisible = false; + } + + /// Replaces the ladder wholesale: the payload is one immutable record per refresh. + public void Show(TrophyLadder ladder) + { + _title.Text = ladder.GameLabel; + _percent.Text = $"{ladder.Percent}% · {ladder.Unlocked}/{ladder.Total} TROPHIES"; + _rows.Children.Clear(); + foreach (var (title, glyph, unlocked, detail) in ladder.Rows) + { + var glyphLabel = new Label + { + Text = glyph, FontSize = 13, WidthRequest = 20, + TextColor = unlocked ? UiTokens.Gold : UiTokens.Ink1, Opacity = unlocked ? 1.0 : 0.55, + VerticalOptions = LayoutOptions.Center, + }; + var titleLabel = new Label + { + Text = title, FontFamily = DsChrome.PixelFont, FontSize = 12, + TextColor = unlocked ? UiTokens.Green : UiTokens.Ink1, LineBreakMode = LineBreakMode.TailTruncation, + VerticalOptions = LayoutOptions.Center, + }; + var detailLabel = new Label + { + Text = detail, FontFamily = DsChrome.PixelFont, FontSize = 10, + TextColor = UiTokens.InkSoft, LineBreakMode = LineBreakMode.TailTruncation, MaxLines = 1, + HorizontalTextAlignment = TextAlignment.End, VerticalOptions = LayoutOptions.Center, + }; + // The columns must be assigned: without them all three labels stack in + // column 0 (the one-column bug). + Grid.SetColumn(glyphLabel, 0); + Grid.SetColumn(titleLabel, 1); + Grid.SetColumn(detailLabel, 2); + _rows.Children.Add(new Grid + { + ColumnDefinitions = [new(GridLength.Auto), new(GridLength.Star), new(GridLength.Auto)], + ColumnSpacing = 8, + Children = { glyphLabel, titleLabel, detailLabel }, + }); + } + IsVisible = true; + } +} diff --git a/src/PKForge.App/Views/TrophiesPage.cs b/src/PKForge.App/Views/TrophiesPage.cs new file mode 100644 index 00000000..7cd259b0 --- /dev/null +++ b/src/PKForge.App/Views/TrophiesPage.cs @@ -0,0 +1,304 @@ +using System.Collections.ObjectModel; +using PKForge.App.Services; +using PKForge.App.Theme; +using PKForge.App.ViewModels; +using PKForge.Domain; + +namespace PKForge.App.Views; + +/// +/// The homemade trophies screen: one card per linked game with its completion percent +/// (tap or A for the detailed ladder), a Scan button that runs the same read path as the +/// Museum, and the full list per game — green when unlocked, muted while locked, with +/// the measured value at the last scan. +/// +public sealed class TrophiesPage : ContentPage, IPadHandler +{ + private readonly SavePickerViewModel _saves; + private readonly TrophyScanService _scanner; + private readonly ITrophyStore _trophies; + private readonly ObservableCollection _cards = []; + private VerticalStackLayout _rows = null!; + private Grid _hostGrid = null!; + private Label _readout = null!; + private SecondScreenClaim? _secondClaim; + + public TrophiesPage(SavePickerViewModel saves, TrophyScanService scanner, ITrophyStore trophies) + { + _saves = saves; + _scanner = scanner; + _trophies = trophies; + BindingContext = saves; + Title = AppStrings.TrophiesTitle; + BackgroundColor = UiTokens.Housing; + NavigationPage.SetHasNavigationBar(this, false); + + var back = Kit.MiniCapsule(AppStrings.Back, UiTokens.Ink0); + back.WidthRequest = 72; + back.Clicked += async (_, _) => await Navigation.PopAsync(); + + var header = Kit.HeaderBar("Trophies"); + header.VerticalOptions = LayoutOptions.Center; + + var scan = Kit.Capsule(AppStrings.ScanSaves, UiTokens.Cyan); + scan.Clicked += (_, _) => _ = ScanAsync(); + + var titleRow = new Grid + { + ColumnSpacing = 10, + ColumnDefinitions = [new(GridLength.Auto), new(GridLength.Star), new(GridLength.Auto)], + Children = { back, header, scan }, + }; + Grid.SetColumn(header, 1); + Grid.SetColumn(scan, 2); + + _readout = new Label + { + Text = AppStrings.Fr ? "L'analyse parcourt chaque sauvegarde liée, comme le Musée. Les déblocages sont permanents." : "Scan runs over every linked save, the same reads as the Museum. Unlocks are permanent.", + TextColor = UiTokens.InkSoft, + FontFamily = DsChrome.PixelFont, + FontSize = UiTokens.TextBody, + LineBreakMode = LineBreakMode.WordWrap, + }; + + _rows = new VerticalStackLayout { Spacing = 8 }; + var scrollable = new ScrollView + { + Orientation = ScrollOrientation.Vertical, + VerticalScrollBarVisibility = ScrollBarVisibility.Never, + Content = _rows, + }; + + var body = new Grid + { + RowSpacing = 10, + RowDefinitions = [new(GridLength.Auto), new(GridLength.Star)], + Children = { Kit.Well(_readout, padding: 8), scrollable }, + }; + Grid.SetRow(scrollable, 1); + + var panel = Kit.DevicePanel(body); + var root = new Grid + { + Padding = new Thickness(14, 10), + RowSpacing = 10, + RowDefinitions = [new(GridLength.Auto), new(GridLength.Star)], + Children = { titleRow, panel }, + }; + Grid.SetRow(panel, 1); + _hostGrid = new Grid { Children = { DsChrome.GridBackground(), root } }; + Content = _hostGrid; + } + + /// One game's overview card and its detailed ladder, built from the scan results. + private sealed class GameTrophyCard + { + public required string GameLabel { get; init; } + public required int Generation { get; init; } + public required IReadOnlyList Definitions { get; init; } + public required List States { get; init; } + public int Unlocked => States.Count(s => s.Unlocked); + public int Total => Definitions.Count; + public int Percent => Total == 0 ? 0 : (int)Math.Round(Unlocked * 100.0 / Total); + public bool IsOpen; + } + + private View BuildCard(GameTrophyCard card) + { + var title = new Label + { + Text = card.GameLabel, + TextColor = UiTokens.Ink0, + FontFamily = DsChrome.PixelFont, + FontSize = 15, + VerticalOptions = LayoutOptions.Center, + }; + var percent = new Label + { + Text = $"{card.Percent}% · {card.Unlocked}/{card.Total}", + TextColor = UiTokens.Cyan, + FontFamily = DsChrome.PixelFont, + FontSize = 13, + VerticalOptions = LayoutOptions.Center, + HorizontalOptions = LayoutOptions.End, + }; + var head = new Grid + { + ColumnDefinitions = [new(GridLength.Star), new(GridLength.Auto)], + Children = { title, percent }, + }; + Grid.SetColumn(percent, 1); + + var ladder = new VerticalStackLayout { Spacing = 4, IsVisible = card.IsOpen }; + foreach (var definition in card.Definitions) + ladder.Children.Add(BuildTrophyRow(definition, card.States.FirstOrDefault(s => s.TrophyId == definition.Id))); + + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => { card.IsOpen = !card.IsOpen; ladder.IsVisible = card.IsOpen; }; + var content = new VerticalStackLayout { Spacing = 6, Children = { head, ladder } }; + content.GestureRecognizers.Add(tap); + return Kit.Well(content, padding: 10); + } + + private View BuildTrophyRow(TrophyDefinition definition, TrophyState? state) + { + var unlocked = state?.Unlocked == true; + var glyph = new Label + { + Text = definition.Glyph, + FontSize = 14, + VerticalOptions = LayoutOptions.Center, + WidthRequest = 24, + TextColor = unlocked ? UiTokens.Gold : UiTokens.Ink1, + Opacity = unlocked ? 1.0 : 0.55, + }; + var name = new Label + { + Text = definition.Title, + TextColor = unlocked ? UiTokens.Ink0 : UiTokens.Ink1, + FontFamily = DsChrome.PixelFont, + FontSize = 13, + VerticalOptions = LayoutOptions.Center, + LineBreakMode = LineBreakMode.TailTruncation, + }; + var value = new Label + { + Text = unlocked ? AppStrings.Unlocked : $"{state?.Current ?? 0}/{definition.Threshold}", + TextColor = unlocked ? UiTokens.Green : UiTokens.InkSoft, + FontFamily = DsChrome.PixelFont, + FontSize = 12, + VerticalOptions = LayoutOptions.Center, + HorizontalOptions = LayoutOptions.End, + }; + // The columns must be assigned: without them all three labels stack in + // column 0 (the one-column bug). + Grid.SetColumn(name, 1); + Grid.SetColumn(value, 2); + var row = new Grid + { + ColumnDefinitions = [new(GridLength.Auto), new(GridLength.Star), new(GridLength.Auto)], + ColumnSpacing = 6, + Children = { glyph, name, value }, + }; + // Manual trophies unlock by the player's own say-so: the row confirms, never scans. + if (definition.Kind == TrophyKind.Manual) + { + var tapRow = new TapGestureRecognizer(); + tapRow.Tapped += (_, _) => _ = ConfirmManualAsync(definition, state); + row.GestureRecognizers.Add(tapRow); + } + return row; + } + + /// The manual trophy flow: confirm to unlock, confirm again to lock it back. + private async Task ConfirmManualAsync(TrophyDefinition definition, TrophyState? state) + { + if (state?.Unlocked == true) + { + var relock = await PadMenu.ConfirmAsync(_hostGrid, AppStrings.RelockQuestion(definition.Title), + AppStrings.ManualTrophyRelockDetail, AppStrings.Relock); + if (!relock) return; + _trophies.Remove(definition.Id); + } + else + { + var unlock = await PadMenu.ConfirmAsync(_hostGrid, definition.Title, definition.Description, AppStrings.Fr ? "Débloquer" : "Unlock"); + if (!unlock) return; + _trophies.Store(new TrophyState(definition.Id, true, 1)); + } + Rebuild(); + PublishToSecondScreen(); + } + + protected override void OnAppearing() + { + base.OnAppearing(); + Rebuild(); + _secondClaim ??= IPlatformApplication.Current?.Services.GetService()?.Routes.CreateClaim(SecondScreenOwner.Trophies); + _secondClaim?.Activate(); + PublishToSecondScreen(); + IPlatformApplication.Current?.Services.GetService()?.Push(this); + } + + protected override void OnDisappearing() + { + base.OnDisappearing(); + _secondClaim?.Release(); + IPlatformApplication.Current?.Services.GetService()?.Remove(this); + } + + /// Feeds the highlighted game's ladder to the lower screen (a no-op without one). + private void PublishToSecondScreen() + { + var state = IPlatformApplication.Current?.Services.GetService(); + if (state is null) return; + var card = _cards.Count > 0 ? _cards[_cards.Count - 1] : null; + if (card is null) + { + state.TrophyLadder = null; + return; + } + var rows = card.Definitions.Select(d => + { + var trophyState = card.States.FirstOrDefault(s => s.TrophyId == d.Id); + return (d.Title, d.Glyph, trophyState?.Unlocked == true, + trophyState?.Unlocked == true ? "Unlocked" : $"{trophyState?.Current ?? 0}/{d.Threshold}"); + }).ToList(); + state.TrophyLadder = new TrophyLadder(card.GameLabel, card.Generation, card.Percent, card.Unlocked, card.Total, rows); + } + + /// Rebuilds the overview from the store, keyed by the games the shelf shows. + private void Rebuild() + { + _cards.Clear(); + _rows.Children.Clear(); + var states = _trophies.GetAll(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var save in _saves.Saves.Where(s => s.Identity?.IsHidden != true)) + { + var label = save.EngineHint; + if (!seen.Add(label)) continue; + var definitions = new List(TrophyCatalog.ForGame(label)); + definitions.AddRange(TrophyManualCatalog.ForGame(label)); + if (definitions.Count == 0) continue; + var gameStates = states.Where(s => definitions.Any(d => d.Id == s.TrophyId)).ToList(); + var card = new GameTrophyCard + { + GameLabel = label, + Generation = save.Generation, + Definitions = definitions, + States = gameStates, + }; + _cards.Add(card); + _rows.Children.Add(BuildCard(card)); + } + } + + private async Task ScanAsync() + { + if (_saves.IsBusy) return; + _readout.Text = AppStrings.ScanningSaves; + try + { + var facts = await _scanner.ScanAllAsync(_saves.Saves.ToList()); + _readout.Text = facts.Count == 0 + ? AppStrings.NoSaveScanned + : AppStrings.ScannedSaves(facts.Count); + Rebuild(); + } + catch (Exception error) + { + _readout.Text = AppStrings.ScanFailed(error.Message); + } + } + + public bool OnPadButton(PadButton button) + { + switch (button) + { + case PadButton.A: _ = ScanAsync(); return true; + case PadButton.B: _ = Navigation.PopAsync(); return true; + default: return true; // own the pad while this page is up + } + } +} diff --git a/src/PKForge.Domain/AppUpdate.cs b/src/PKForge.Domain/AppUpdate.cs index 7d3942ff..1809fc1a 100644 --- a/src/PKForge.Domain/AppUpdate.cs +++ b/src/PKForge.Domain/AppUpdate.cs @@ -31,9 +31,12 @@ public static bool IsNewerVersion(string installed, string candidate) var core = next.Core.CompareTo(current.Core); if (core != 0) return core > 0; + // SemVer: a release outranks any prerelease of the same version; among two + // prereleases, the one with the more dot-separated identifiers wins when all + // shared identifiers tie (1.4.0-beta.2 > 1.4.0-beta.1 > 1.4.0-beta). if (next.Prerelease.Length == 0 || current.Prerelease.Length == 0) return next.Prerelease.Length == 0 && current.Prerelease.Length != 0; - return string.CompareOrdinal(next.Prerelease, current.Prerelease) > 0; + return ComparePrerelease(next.Prerelease, current.Prerelease) > 0; } /// Don't-remind-me applies to one release. A newer release always asks again. @@ -66,5 +69,29 @@ private static bool TryParse(string value, out ReleaseVersion version) return true; } + /// SemVer prerelease ordering: numeric identifiers compare numerically and + /// rank below alphanumeric ones; more identifiers win when the shared prefix ties. + private static int ComparePrerelease(string left, string right) + { + var a = left.Split('.'); + var b = right.Split('.'); + for (var i = 0; i < Math.Min(a.Length, b.Length); i++) + { + var aNumeric = int.TryParse(a[i], out var an); + var bNumeric = int.TryParse(b[i], out var bn); + int order; + if (aNumeric && bNumeric) + order = an.CompareTo(bn); + else if (aNumeric) + order = -1; // numeric identifiers rank lower than alphanumeric ones + else if (bNumeric) + order = 1; + else + order = string.CompareOrdinal(a[i], b[i]); + if (order != 0) return order; + } + return a.Length.CompareTo(b.Length); + } + private readonly record struct ReleaseVersion(Version Core, string Prerelease); } diff --git a/src/PKForge.Domain/EmbeddedCore.cs b/src/PKForge.Domain/EmbeddedCore.cs new file mode 100644 index 00000000..76dc9958 --- /dev/null +++ b/src/PKForge.Domain/EmbeddedCore.cs @@ -0,0 +1,75 @@ +namespace PKForge.Domain; + +/// +/// The embedded emulator core's contract: PKForge runs the game itself (the +/// PokeDaisy model — the core in-process, the companion reads its memory live) +/// instead of handing the ROM to an external emulator. The GBA/GB core (mGBA) +/// comes first; the same shape extends to DS (melonDS) later. +/// +public interface IEmbeddedCore : IDisposable +{ + /// True while a ROM runs. Read before any other member. + bool IsRunning { get; } + + /// The loaded ROM's file path. + string? RomPath { get; } + + /// + /// Loads a ROM and its save (both plain file paths). Returns false when the + /// file is not a ROM this core runs — nothing is left half-loaded. + /// + bool Load(string romPath, string? savePath = null); + + /// Advances exactly one emulated frame. Call ~60×/s from the emu thread. + void RunFrame(); + + /// The game's own title, from the ROM header. + string GameTitle { get; } + + /// The game code from the header ("BPRE" = FireRed, "BPEE" = Emerald). + string GameCode { get; } + + // ---- Video ---- + /// The framebuffer's dimensions (GBA: 240×160). + (int Width, int Height) VideoSize { get; } + + /// + /// Copies the current frame as 32-bit RGBA into . + /// The buffer must hold Width×Height pixels; the copy is one memcpy. + /// + void CopyFrame(Span buffer); + + // ---- Input ---- + /// Sets the held button bitmask (A=1<<0 .. Start=1<<3, GBA order). + void SetKeys(uint bitmask); + + // ---- Memory (the companion's data bridge) ---- + /// Reads bytes at a bus address into the + /// span. Returns false when the address range is unmapped (nothing copied). + bool ReadBytes(uint address, Span destination); + + /// Convenience 32-bit read at a bus address; null when unmapped. + uint? Read32(uint address); + + // ---- Save states (mGBA's own .ss format, desktop-compatible) ---- + /// Saves a state to the path (creates or overwrites). + bool SaveState(string path); + + /// Loads a state from the path. False when unreadable. + bool LoadState(string path); +} + +/// The GBA button bitmask, in mGBA's own bit order. +public static class GbaKeys +{ + public const uint A = 1 << 0; + public const uint B = 1 << 1; + public const uint Select = 1 << 2; + public const uint Start = 1 << 3; + public const uint Right = 1 << 4; + public const uint Left = 1 << 5; + public const uint Up = 1 << 6; + public const uint Down = 1 << 7; + public const uint R = 1 << 8; + public const uint L = 1 << 9; +} diff --git a/src/PKForge.Domain/EmulatorCatalog.cs b/src/PKForge.Domain/EmulatorCatalog.cs new file mode 100644 index 00000000..3e469e9c --- /dev/null +++ b/src/PKForge.Domain/EmulatorCatalog.cs @@ -0,0 +1,145 @@ +namespace PKForge.Domain; + +/// +/// The Android launch facts for one : which packages answer, and +/// how a ROM is handed over. Package ids are the public Play Store / release ids; alternates +/// cover fork builds that share the same storage layout. +/// +public sealed record EmulatorApp( + EmulatorKind Kind, + IReadOnlyList PackageIds, + /// How a ROM reaches the emulator (see ). + RomLaunchStyle LaunchStyle, + /// RetroArch cores, in preference order, for the systems this emulator runs. + IReadOnlyList? Cores = null) +{ + /// The id every APK of this emulator is known by (alternates follow). + public string PrimaryPackage => PackageIds[0]; +} + +/// How PKForge hands a ROM path to an installed emulator. +public enum RomLaunchStyle +{ + /// RetroArch's RetroActivityFuture: explicit ROM + LIBRETRO intent extras. + RetroArch = 0, + /// A content URI in the intent data, as melonDS and most modern frontends accept. + UriData = 1, + /// A plain file path in the PATH extra (melonDS's EmulatorActivity). + PathExtra = 2, + /// No documented launch intent: the emulator's own library screen opens instead. + OpenAppOnly = 3, +} + +/// The RetroArch cores PKForge can name when launching a ROM, per system. +public enum RetroArchCore +{ + Gambatte = 0, + MGba = 1, + MelonDs = 2, + DesMuMe = 3, +} + +/// +/// The static emulator catalogue: everything the Emulators tab, the ROM linker and the +/// launcher ask about, in one place. Package ids must stay stable: they identify installs. +/// +public static class EmulatorCatalog +{ + /// Every emulator PKForge can link and launch, in shelf order. + public static readonly IReadOnlyList All = + [ + new(EmulatorKind.RetroArch, + ["com.retroarch", "com.retroarch.aarch64", "com.retroarch.ra32"], + RomLaunchStyle.RetroArch, + [RetroArchCore.Gambatte, RetroArchCore.MGba, RetroArchCore.MelonDs, RetroArchCore.DesMuMe]), + new(EmulatorKind.MelonDS, + ["io.github.melonds", "me.magnum.melonds"], + RomLaunchStyle.UriData), + new(EmulatorKind.Azahar, + ["org.azahar_emu.azahar", "io.github.lime3ds.android", "org.citra.emu", "io.github.azahar"], + RomLaunchStyle.UriData), + new(EmulatorKind.CitraMmj, + ["org.citra.emu"], + RomLaunchStyle.UriData), + new(EmulatorKind.Eden, + ["dev.eden.eden_emulator", "org.sudachi.sudachi_emu", "org.yuzu.yuzu_emu", "org.ryujinx", "com.skyline.emu", "dev.eden.eden_emulator.dualscreen"], + RomLaunchStyle.UriData), + new(EmulatorKind.Dolphin, + ["org.dolphinemu.dolphin"], + RomLaunchStyle.OpenAppOnly), + new(EmulatorKind.DraStic, + ["com.dsemu.drastic"], + RomLaunchStyle.OpenAppOnly), + new(EmulatorKind.Linkboy, + ["com.gitlab.Misigno.linkboy"], + RomLaunchStyle.OpenAppOnly), + new(EmulatorKind.PizzaBoyGba, + ["it.dbtecno.pizzaboy"], + RomLaunchStyle.OpenAppOnly), + new(EmulatorKind.PizzaBoyGbc, + ["it.dbtecno.pizzaboygbc"], + RomLaunchStyle.OpenAppOnly), + ]; + + public static EmulatorApp App(EmulatorKind kind) => + All.FirstOrDefault(app => app.Kind == kind) + ?? new EmulatorApp(kind, [kind.ToString().ToLowerInvariant()], RomLaunchStyle.OpenAppOnly); + + /// The display name of an emulator ("melonDS", "Pizza Boy GBA", ...). + public static string Name(EmulatorKind kind) => kind switch + { + EmulatorKind.Embedded => "PKForge", + EmulatorKind.MelonDS => "melonDS", + EmulatorKind.DraStic => "DraStic", + EmulatorKind.PizzaBoyGba => "Pizza Boy GBA", + EmulatorKind.PizzaBoyGbc => "Pizza Boy GBC", + EmulatorKind.CitraMmj => "Citra MMJ", + _ => kind.ToString(), + }; + + /// True when any of the emulator's packages is installed. + /// The embedded core has no package: it is installed when the shim is packed in the APK. + public static bool IsInstalled(EmulatorKind kind, Func isPackageInstalled) => + kind == EmulatorKind.Embedded ? EmbeddedCoreAvailable : App(kind).PackageIds.Any(isPackageInstalled); + + /// Set by the app at startup: true when libpkforge_mgba.so is packed (the embedded core runs). + public static bool EmbeddedCoreAvailable { get; set; } + + /// The RetroArch core file name (without directories) for a core. + public static string CoreFileName(RetroArchCore core) => core switch + { + RetroArchCore.Gambatte => "gambatte_libretro_android.so", + RetroArchCore.MGba => "mgba_libretro_android.so", + RetroArchCore.MelonDs => "melonds_libretro_android.so", + RetroArchCore.DesMuMe => "desmume_libretro_android.so", + _ => throw new ArgumentOutOfRangeException(nameof(core)), + }; + + /// The core a Pokémon generation should run on, for GB/GBC/GBA/DS RetroArch launches. + public static RetroArchCore CoreForGeneration(int generation) => generation switch + { + 1 or 2 => RetroArchCore.Gambatte, + 3 => RetroArchCore.MGba, + 4 or 5 => RetroArchCore.MelonDs, + _ => throw new ArgumentOutOfRangeException(nameof(generation), $"RetroArch does not run Gen {generation} Pokémon."), + }; + + /// Every folder grant of this emulator kind, as the Link flow stores them. + public static bool SupportsSaveScan(EmulatorKind kind) => kind != EmulatorKind.Eden && kind != EmulatorKind.Dolphin && kind != EmulatorKind.Embedded; + + /// The systems whose ROMs this emulator runs, for the launcher's expectations. + public static string SystemsOf(EmulatorKind kind) => kind switch + { + EmulatorKind.Embedded => "GB, GBC, GBA", + EmulatorKind.RetroArch => "DS", + EmulatorKind.MelonDS => "Nintendo DS", + EmulatorKind.Azahar or EmulatorKind.CitraMmj => "Nintendo 3DS", + EmulatorKind.Eden => "Nintendo Switch", + EmulatorKind.Dolphin => "GameCube", + EmulatorKind.DraStic => "Nintendo DS", + EmulatorKind.Linkboy => "GB, GBC, GBA", + EmulatorKind.PizzaBoyGba => "Game Boy Advance", + EmulatorKind.PizzaBoyGbc => "GB, GBC", + _ => kind.ToString(), + }; +} diff --git a/src/PKForge.Domain/EmulatorDetection.cs b/src/PKForge.Domain/EmulatorDetection.cs index b6fca3ca..8c1dad54 100644 --- a/src/PKForge.Domain/EmulatorDetection.cs +++ b/src/PKForge.Domain/EmulatorDetection.cs @@ -14,6 +14,10 @@ public enum EmulatorKind PizzaBoyGbc = 8, /// Citra MMJ (weihuoya fork, org.citra.emu): same SDMC layout as Azahar, rooted at citra-emu/. CitraMmj = 9, + /// PKForge itself: the embedded mGBA core (libpkforge_mgba.so) runs GB/GBC/GBA + /// in-process. Not a package — IsInstalled is always false; it is "installed" when the + /// shim is packed in the APK. + Embedded = 10, } /// A persistable SAF folder grant, opaque to the domain layer. diff --git a/src/PKForge.Domain/FrenchEditionName.cs b/src/PKForge.Domain/FrenchEditionName.cs new file mode 100644 index 00000000..50edc76e --- /dev/null +++ b/src/PKForge.Domain/FrenchEditionName.cs @@ -0,0 +1,68 @@ +namespace PKForge.Domain; + +/// +/// Maps French Pokémon edition names to the English editions the catalog, art and +/// trophy lookups expect — French ROM dumps ("Version Émeraude", "Rouge Feu") and +/// the bundle's own alias table. Null when the name is unknown (the caller keeps it). +/// +public static class FrenchEditionName +{ + private static readonly Dictionary FrenchToEnglish = + new(StringComparer.OrdinalIgnoreCase) + { + // Gen 1 + ["rouge"] = "Red", ["bleu"] = "Blue", ["jaune"] = "Yellow", ["vert"] = "Green (JP)", + // Gen 2 + ["or"] = "Gold", ["argent"] = "Silver", ["cristal"] = "Crystal", + // Gen 3 + ["rubis"] = "Ruby", ["saphir"] = "Sapphire", ["émeraude"] = "Emerald", + ["emeraude"] = "Emerald", ["rouge feu"] = "FireRed", ["vert feuille"] = "LeafGreen", + // Gen 4 + ["diamant"] = "Diamond", ["perle"] = "Pearl", ["platine"] = "Platinum", + ["or heartgold"] = "HeartGold", ["argent soulsilver"] = "SoulSilver", + ["heartgold"] = "HeartGold", ["soulsilver"] = "SoulSilver", + // Gen 5 + ["noir"] = "Black", ["blanc"] = "White", ["noir 2"] = "Black 2", ["blanc 2"] = "White 2", + // Gen 6 + ["x"] = "X", ["y"] = "Y", ["rubis oméga"] = "Omega Ruby", ["saphir alpha"] = "Alpha Sapphire", + ["rubis omega"] = "Omega Ruby", ["saphir alpha"] = "Alpha Sapphire", + // Gen 7 + ["soleil"] = "Sun", ["lune"] = "Moon", ["ultra soleil"] = "Ultra Sun", ["ultra lune"] = "Ultra Moon", + ["let's go pikachu"] = "Let's Go Pikachu", ["let's go évoli"] = "Let's Go Eevee", + ["let's go evoli"] = "Let's Go Eevee", + // Gen 8 + ["épée"] = "Sword", ["epee"] = "Sword", ["bouclier"] = "Shield", + ["diamant étincelant"] = "Brilliant Diamond", ["diamant etincelant"] = "Brilliant Diamond", + ["perle scintillante"] = "Shining Pearl", + ["légendes arceus"] = "Legends: Arceus", ["legendes arceus"] = "Legends: Arceus", + // Gen 9 + ["écarlate"] = "Scarlet", ["ecarlate"] = "Scarlet", ["violet"] = "Violet", + ["légies z-a"] = "Legends: Z-A", + }; + + /// The catalog's English edition for a French (or already-English) name; + /// null when the name matches no known edition. English names pass through. + public static string? ToEnglish(string name) + { + if (string.IsNullOrWhiteSpace(name)) return null; + var clean = name.Trim(); + if (FrenchToEnglish.TryGetValue(clean, out var english)) return english; + if (!EnglishEditions.Contains(clean)) return null; + // A bare lower-case file name ("emerald.gba") still shows its catalog casing. + return char.ToUpperInvariant(clean[0]) + clean[1..]; + } + + private static readonly HashSet EnglishEditions = + new(StringComparer.OrdinalIgnoreCase) + { + "Red", "Blue", "Yellow", "Green (JP)", + "Gold", "Silver", "Crystal", + "Ruby", "Sapphire", "Emerald", "FireRed", "LeafGreen", + "Diamond", "Pearl", "Platinum", "HeartGold", "SoulSilver", + "Black", "White", "Black 2", "White 2", + "X", "Y", "Omega Ruby", "Alpha Sapphire", + "Sun", "Moon", "Ultra Sun", "Ultra Moon", "Let's Go Pikachu", "Let's Go Eevee", + "Sword", "Shield", "Brilliant Diamond", "Shining Pearl", "Legends: Arceus", + "Scarlet", "Violet", "Legends: Z-A", + }; +} diff --git a/src/PKForge.Domain/GameCompanion.cs b/src/PKForge.Domain/GameCompanion.cs new file mode 100644 index 00000000..da9e85ce --- /dev/null +++ b/src/PKForge.Domain/GameCompanion.cs @@ -0,0 +1,64 @@ +namespace PKForge.Domain; + +/// +/// The in-game companion's state: what the second screen shows while an emulator +/// runs the game. PKForge stays alive behind the emulator (the presentation +/// suspends on pause and resumes on focus), so the lower screen becomes the +/// game's companion — dex, restore points, play stats — for single-screen games. +/// +public sealed record GameCompanion( + string GameLabel, + int Generation, + /// The dex read at launch: caught/seen/total for the companion's progress bar. + int DexCaught, + int DexSeen, + int DexTotal, + /// Restore points that exist for this game's save, newest first. + IReadOnlyList RestorePoints) +{ + public int DexPercent => DexTotal == 0 ? 0 : (int)Math.Round(DexCaught * 100.0 / DexTotal); +} + +/// One restore point the companion can name. +public sealed record CompanionRestorePoint(string BackupId, DateTimeOffset CreatedUtc, string? Sha256 = null); + +/// +/// A live read from the running embedded game's memory (the PokéDaisy model): what +/// the second screen refreshes while the game plays natively in-app. +/// +public sealed record LiveCompanionSnapshot( + string GameLabel, + string GameCode, + int DexCaught, + int DexSeen, + int DexTotal, + int PartyCount, + int PlaytimeHours, + int PlaytimeMinutes) +{ + public int DexPercent => DexTotal == 0 ? 0 : (int)Math.Round(DexCaught * 100.0 / DexTotal); +} + +/// +/// Tracks the companion: set when Play launches a game, cleared when the player +/// returns to PKForge's own screens. The second screen's swap reads it. +/// +public interface IGameCompanionStore +{ + GameCompanion? Current { get; } + + /// A live snapshot from the running embedded game, when one is playing. + LiveCompanionSnapshot? Live { get; } + + /// Shows this game's companion on the lower screen for the whole emulation. + void Start(GameCompanion companion); + + /// Publishes a live snapshot (embedded game running in-app). + void UpdateLive(LiveCompanionSnapshot snapshot); + + /// Clears the live snapshot (the embedded game stopped). + void ClearLive(); + + /// Back to PKForge's own lower-screen surfaces. + void Stop(); +} diff --git a/src/PKForge.Domain/LiveGbaCompanion.cs b/src/PKForge.Domain/LiveGbaCompanion.cs new file mode 100644 index 00000000..debfb103 --- /dev/null +++ b/src/PKForge.Domain/LiveGbaCompanion.cs @@ -0,0 +1,120 @@ +namespace PKForge.Domain; + +/// +/// The live, in-emulator companion (the PokéDaisy model): the GBA's save RAM is read +/// straight out of the running core, so the second screen tracks the game as it +/// plays — dex progress, party count, playtime — without waiting for a save flush. +/// All offsets are relative to the SRAM/Flash bus base 0x0E000000, in the sector +/// layout PKHeX's SAV3 parses (4 KiB sectors, sector ID at 0xFF4, active slot first). +/// +public static class LiveGbaCompanion +{ + /// The GBA cartridge save region (SRAM 64 KiB, Flash 128 KiB, mirrored at 0x0F000000). + public const uint SaveBase = 0x0E000000; + + private const int SectorSize = 0x1000; + private const int MainSectors = 14; + private const int MainSize = MainSectors * SectorSize; + private const int SectorIdOffset = 0xFF4; + + // The "small" sector (ID 0) holds the trainer block; the dex flags live at + // PokeDex 0x18: caught at +0x10, seen at +0x44 (SAV3.GetCaught/GetSeen). + private const int CaughtOffset = 0x18 + 0x10; + private const int SeenOffset = 0x18 + 0x44; + + /// One live snapshot of the running game's companion data. + public sealed record LiveCompanionSnapshot( + string GameTitle, + string GameCode, + int DexCaught, + int DexSeen, + int DexTotal, + int PartyCount, + int PlaytimeHours, + int PlaytimeMinutes) + { + public int DexPercent => DexTotal == 0 ? 0 : (int)Math.Round(DexCaught * 100.0 / DexTotal); + } + + /// + /// Reads the active save slot out of the core's memory and measures it. + /// Returns null when the memory is unreadable or no small sector is found. + /// + public static LiveCompanionSnapshot? Read(IEmbeddedCore core, int maxSpeciesId) + { + ArgumentNullException.ThrowIfNull(core); + if (!core.IsRunning || maxSpeciesId <= 0) return null; + + // Read both 128 KiB slots when the cart has them; slot 0 always exists. + var both = new byte[MainSize * 2]; + if (!core.ReadBytes(SaveBase, both)) return null; + + var active = ActiveSlot(both); + var baseOffset = active * MainSize; + + var small = SectorOf(both, baseOffset, 0); + if (small is null) return null; + + var caught = 0; + var seen = 0; + for (var species = 1; species <= maxSpeciesId; species++) + { + var bit = species - 1; + if (Flag(small, CaughtOffset + (bit >> 3), bit & 7)) caught++; + if (Flag(small, SeenOffset + (bit >> 3), bit & 7)) seen++; + } + + var large = SectorOf(both, baseOffset, 1); + var partyCount = large is { Length: > 0x234 } ? Math.Min((int)large[0x234], 6) : 0; + + // Playtime: trainer block of the small sector, offset 0x0C = hours, 0x0D = minutes. + var hours = small.Length > 0x0C ? small[0x0C] : (byte)0; + var minutes = small.Length > 0x0D ? small[0x0D] : (byte)0; + + return new LiveCompanionSnapshot( + core.GameTitle, core.GameCode, caught, seen, maxSpeciesId, + partyCount, hours, minutes); + } + + /// SAV3.GetActiveSlot: with both slots present, the one with the newer footer wins. + private static int ActiveSlot(byte[] data) + { + var v0 = HasAllSectors(data, 0); + var v1 = data.Length >= MainSize * 2 && HasAllSectors(data, MainSize); + if (!v0) return v1 ? 1 : 0; + if (!v1) return 0; + // Footer: sector counter at 0xFFC of sector 0 (SAV3BlockDetection.CompareFooters). + var c0 = ReadUInt32LE(data, 0xFFC); + var c1 = ReadUInt32LE(data, MainSize + 0xFFC); + return c1 > c0 ? 1 : 0; + } + + private static bool HasAllSectors(byte[] data, int start) + { + if (start + MainSize > data.Length) return false; + for (var ofs = start; ofs < start + MainSize; ofs += SectorSize) + { + var id = data[ofs + SectorIdOffset] | (data[ofs + SectorIdOffset + 1] << 8); + if (id is < 0 or > 13) return false; + } + return true; + } + + private static byte[]? SectorOf(byte[] data, int slotBase, int id) + { + for (var ofs = slotBase; ofs + SectorSize <= Math.Min(data.Length, slotBase + MainSize); ofs += SectorSize) + { + var sectorId = data[ofs + SectorIdOffset] | (data[ofs + SectorIdOffset + 1] << 8); + if (sectorId == id) return data[ofs..(ofs + SectorSize)]; + } + return null; + } + + private static uint ReadUInt32LE(byte[] data, int offset) => + offset + 4 <= data.Length + ? (uint)(data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24)) + : 0; + + private static bool Flag(byte[] data, int offset, int bit) => + offset < data.Length && (data[offset] & (1 << bit)) != 0; +} diff --git a/src/PKForge.Domain/Localization.cs b/src/PKForge.Domain/Localization.cs new file mode 100644 index 00000000..f7ba5b57 --- /dev/null +++ b/src/PKForge.Domain/Localization.cs @@ -0,0 +1,36 @@ +namespace PKForge.Domain; + +/// +/// The app's two languages. French is the default (the project's own language); English +/// follows for everyone else. The choice persists and overrides the device locale. +/// +public enum AppLanguage +{ + French = 0, + English = 1, +} + +/// Reads and stores the player's language choice. +public interface ILanguageStore +{ + AppLanguage Current(); + + /// The persisted choice, or the default inferred from the device locale. + void Set(AppLanguage language); +} + +/// The language rules: the persisted choice wins, else the device locale decides, +/// else French. Pure, so the fallback chain is unit-testable. +public static class LanguageRules +{ + /// French is the app's home language. + public const AppLanguage Default = AppLanguage.French; + + /// True when a locale string names a French device ("fr", "fr-FR", "fr_CA"...). + public static bool IsFrenchLocale(string? locale) => + locale is not null && locale.StartsWith("fr", StringComparison.OrdinalIgnoreCase); + + /// The language for one launch: the choice when the player made one, else the locale. + public static AppLanguage Resolve(bool hasChoice, AppLanguage choice, string? deviceLocale) => + hasChoice ? choice : IsFrenchLocale(deviceLocale) ? AppLanguage.French : Default; +} diff --git a/src/PKForge.Domain/RetroAchievements.cs b/src/PKForge.Domain/RetroAchievements.cs new file mode 100644 index 00000000..7b32602e --- /dev/null +++ b/src/PKForge.Domain/RetroAchievements.cs @@ -0,0 +1,178 @@ +using System.Text.Json; + +namespace PKForge.Domain; + +/// The player's RetroAchievements credentials. The API key is a secret: it is +/// stored in the platform's secure storage, never in plain files. +public sealed record RetroCredentials(string Username, string ApiKey); + +/// Reads and stores RetroAchievements credentials securely. +public interface IRetroCredentialStore +{ + RetroCredentials? Load(); + + /// Stores the credentials, or clears them when null. + void Save(RetroCredentials? credentials); +} + +/// +/// Parses the RetroAchievements web API's JSON, in isolation from HTTP so the mapping +/// is unit tested. Follows API_GetGameInfoAndUserProgress: an achievement carries +/// DateEarned/DateEarnedHardcore only once unlocked, but older API responses returned +/// an empty string for a locked one - presence alone is never proof of an unlock. +/// +public static class RetroAchievementsJson +{ + public static bool LooksLikeGame(JsonElement root) => + root.ValueKind == JsonValueKind.Object && root.TryGetProperty("ID", out _); + + public static bool LooksLikeProfile(JsonElement root) => + root.ValueKind == JsonValueKind.Object && root.TryGetProperty("ID", out _); + + public static RetroGameProgress ParseGameProgress(JsonElement root, int requestedGameId) + { + var title = root.TryGetProperty("Title", out var titleElement) ? titleElement.GetString() ?? string.Empty : string.Empty; + var achievements = new List(); + if (root.TryGetProperty("Achievements", out var set) && set.ValueKind == JsonValueKind.Object) + { + foreach (var entry in set.EnumerateObject()) + { + var a = entry.Value; + var id = a.TryGetProperty("ID", out var idElement) && idElement.TryGetInt32(out var parsedId) ? parsedId : 0; + var earned = IsEarned(a, "DateEarned"); + var earnedHardcore = IsEarned(a, "DateEarnedHardcore"); + achievements.Add(new RetroAchievement( + id, + a.TryGetProperty("Title", out var name) ? name.GetString() ?? string.Empty : string.Empty, + a.TryGetProperty("Description", out var description) ? description.GetString() ?? string.Empty : string.Empty, + a.TryGetProperty("Points", out var points) && points.TryGetInt32(out var pointValue) ? pointValue : 0, + a.TryGetProperty("BadgeName", out var badge) ? badge.GetString() ?? string.Empty : string.Empty, + earned, + earned ? ParseTimestamp(a, "DateEarned") : null, + earnedHardcore)); + } + } + return new RetroGameProgress(requestedGameId, title, [.. achievements.OrderBy(a => a.Id)]); + } + + private static bool IsEarned(JsonElement achievement, string property) => + achievement.TryGetProperty(property, out var value) + && value.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(value.GetString()); + + /// RA's "YYYY-MM-DD HH:mm:ss" timestamps, treated as UTC (the site's own clock). + private static DateTimeOffset? ParseTimestamp(JsonElement achievement, string property) + { + if (!achievement.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.String) return null; + return DateTimeOffset.TryParse( + value.GetString(), + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, + out var parsed) ? parsed : null; + } +} + +/// One official RetroAchievements achievement, as PKForge shows it. +public sealed record RetroAchievement( + int Id, + string Title, + string Description, + int Points, + /// The badge URL on media.retroachievements.org; PKForge prefixes the host. + string BadgeName, + bool Earned, + /// When the player earned it (softcore or hardcore), null while locked. + DateTimeOffset? DateEarned, + /// True when the unlock is a hardcore unlock (worth double points). + bool EarnedHardcore); + +/// One game's RetroAchievements set and the player's progress in it. +public sealed record RetroGameProgress( + int GameId, + string Title, + IReadOnlyList Achievements) +{ + public int Unlocked => Achievements.Count(a => a.Earned); + + public int Total => Achievements.Count; + + /// Completion percentage, whole points, 100 when the set is empty (nothing to do). + public int Percent => Total == 0 ? 100 : (int)Math.Round(Unlocked * 100.0 / Total); + + public int PointsEarned => Achievements.Where(a => a.Earned).Sum(a => a.Points); + + public int PointsTotal => Achievements.Sum(a => a.Points); +} + +/// Talks to the RetroAchievements web API (API_GetGameInfoAndUserProgress). +public interface IRetroAchievementsClient +{ + /// Fetches one game's achievement set with the player's unlock state. + Task GetGameProgressAsync(int gameId, CancellationToken cancellationToken = default); + + /// Verifies the credentials with a cheap call; throws on bad credentials. + Task VerifyCredentialsAsync(CancellationToken cancellationToken = default); +} + +/// Persists the last fetched progress per game, so the awards view opens offline. +public interface IRetroProgressStore +{ + IReadOnlyList GetAll(); + + RetroGameProgress? Find(int gameId); + + void Store(RetroGameProgress progress); +} + +/// +/// Maps PKForge saves to RetroAchievements games. The RA consoles are GB (4), GBC (5), +/// GBA (6) and NDS (18); the resolution runs over the user's fetched game list, so the +/// table only names how PKForge labels map to RA titles, never hard-coded RA game ids. +/// +public static class RetroAchievementsRules +{ + /// RetroAchievements console ids for the generations PKForge supports. + public static readonly IReadOnlyDictionary ConsoleForGeneration = + new Dictionary { [1] = 4, [2] = 5, [3] = 6, [4] = 18, [5] = 18 }; + + /// True when a game's generation has an official RA set (Gen 1-5). + public static bool SupportsGeneration(int generation) => ConsoleForGeneration.ContainsKey(generation); + + /// Extracts the short game name ("Emerald", "Black 2") from a PKForge game label. + public static string ShortName(string gameLabel) => gameLabel + .Replace("Pokémon ", "", StringComparison.OrdinalIgnoreCase) + .Replace("Pokemon ", "", StringComparison.OrdinalIgnoreCase) + .Replace(" Version", "", StringComparison.OrdinalIgnoreCase) + .Trim(); + + /// + /// True when an RA game title is the set the player wants, for the console RA lists + /// it on: the edition must match ("Pokémon X" never answers to "Pokémon Y") and the + /// shared-set titles (HeartGold|SoulSilver) match either edition. + /// + public static bool TitleMatches(string raTitle, string consoleName, string shortName) + { + if (string.IsNullOrWhiteSpace(shortName)) return false; + var parts = raTitle.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return parts.Any(part => TitlesEqual(part, shortName)); + } + + private static bool TitlesEqual(string raTitle, string shortName) + { + // "Pokémon Emerald Version" vs "Emerald": RA titles keep the "Version" suffix, + // and Gen 1 titles are split into per-edition sets ("Red Version"). + var a = Normalise(raTitle); + var b = Normalise(shortName); + return a == b + || (a.StartsWith(b + " ", StringComparison.Ordinal) && a.EndsWith(" version", StringComparison.Ordinal)) + || (b.StartsWith(a + " ", StringComparison.Ordinal) && b.EndsWith(" version", StringComparison.Ordinal)); + + static string Normalise(string title) + { + var lower = title.ToLowerInvariant().Trim(); + foreach (var prefix in new[] { "pokemon ", "pokémon " }) + if (lower.StartsWith(prefix, StringComparison.Ordinal)) lower = lower[prefix.Length..].TrimStart(); + return lower.Replace(" version", "", StringComparison.Ordinal).Trim(); + } + } +} diff --git a/src/PKForge.Domain/RomLibrary.cs b/src/PKForge.Domain/RomLibrary.cs new file mode 100644 index 00000000..071e7d5d --- /dev/null +++ b/src/PKForge.Domain/RomLibrary.cs @@ -0,0 +1,214 @@ +namespace PKForge.Domain; + +/// One console generation of Pokémon games, as the library and launcher see it. +public enum GameConsole +{ + GameBoy = 0, + GameBoyColor = 1, + GameBoyAdvance = 2, + NintendoDs = 3, + Nintendo3Ds = 4, + GameCube = 5, + NintendoSwitch = 6, +} + +/// +/// The player's emulator of choice per console (the Cocoon-style binding): which app +/// launches the console's ROMs, so a ROM never asks again once its console is bound. +/// +public sealed record ConsoleEmulatorLink(GameConsole Console, EmulatorKind Emulator); + +/// Stores one emulator choice per console. +public interface IConsoleEmulatorStore +{ + EmulatorKind? Find(GameConsole console); + + /// Points at , or clears it when null. + void Set(GameConsole console, EmulatorKind? emulator); +} + +/// Which consoles a Pokémon generation runs on, for the library's folders. +public static class ConsoleRules +{ + /// The ES-DE folder name per console (retrogamecorps/ES-DE-Directories layout). + public static string FolderName(GameConsole console) => console switch + { + GameConsole.GameBoy => "gameboy", + GameConsole.GameBoyColor => "gameboyadvance/gbc", // ES-DE keeps GB and GBC apart under gameboy* + GameConsole.NintendoDs => "nds", + GameConsole.Nintendo3Ds => "3ds", + GameConsole.GameCube => "gamecube", + GameConsole.NintendoSwitch => "switch", + _ => "gameboyadvance", + }; + + /// The consoles a generation's ROMs run on. + public static IReadOnlyList ForGeneration(int generation) => generation switch + { + 1 => [GameConsole.GameBoy], + 2 => [GameConsole.GameBoyColor], + 3 => [GameConsole.GameBoyAdvance], + 4 or 5 => [GameConsole.NintendoDs], + 6 or 7 => [GameConsole.Nintendo3Ds], + 8 => [GameConsole.NintendoSwitch, GameConsole.GameCube], + 9 => [GameConsole.NintendoSwitch], + _ => [], + }; + + /// The ROM file extensions a console's games come in. + public static IReadOnlyList RomExtensions(GameConsole console) => console switch + { + GameConsole.GameBoy => [".gb", ".zip", ".7z"], + GameConsole.GameBoyColor => [".gbc", ".zip", ".7z"], + GameConsole.GameBoyAdvance => [".gba", ".zip", ".7z"], + GameConsole.NintendoDs => [".nds", ".dsi", ".zip", ".7z"], + GameConsole.Nintendo3Ds => [".cci", ".cxi", ".3ds", ".zip", ".7z"], + GameConsole.GameCube => [".iso", ".gcm", ".rvz"], + GameConsole.NintendoSwitch => [".xci", ".nsp", ".zip", ".7z"], + _ => [], + }; + + /// True when PKForge runs this console's games itself (the embedded core), + /// so no external emulator may be bound or chosen for it. + public static bool RunsInApp(GameConsole console) => + console is GameConsole.GameBoy or GameConsole.GameBoyColor or GameConsole.GameBoyAdvance; + + /// The emulators that can run a console's ROMs, best first — the Cocoon choice list. + /// GB/GBC/GBA run in PKForge itself (the embedded core); no external emulator is offered. + public static IReadOnlyList EmulatorsFor(GameConsole console) => console switch + { + GameConsole.GameBoy or GameConsole.GameBoyColor or GameConsole.GameBoyAdvance => + [EmulatorKind.Embedded], + GameConsole.NintendoDs => + [EmulatorKind.MelonDS, EmulatorKind.RetroArch, EmulatorKind.DraStic], + GameConsole.Nintendo3Ds => + [EmulatorKind.Azahar, EmulatorKind.CitraMmj], + GameConsole.GameCube => [EmulatorKind.Dolphin], + GameConsole.NintendoSwitch => [EmulatorKind.Eden], + _ => [], + }; + + /// True when one of the emulators that run this console is installed. + /// The embedded core counts as installed when the shim is packed (see the caller). + public static bool AnyInstalled(GameConsole console, Func isPackageInstalled) => + EmulatorsFor(console).Any(kind => kind == EmulatorKind.Embedded || EmulatorCatalog.IsInstalled(kind, isPackageInstalled)); +} + +/// +/// A ROM discovered by the library scan: its path, the console folder it came from, and +/// the game label parsed out of the file name ("Pokemon - Emerald Version.gba" → +/// "Emerald"). Paths are plain file paths under /storage/emulated/0 — the direct-filesystem +/// read the launcher needs. +/// +public sealed record LibraryRom( + string FilePath, + string FileName, + GameConsole Console, + /// The ES-DE folder it was found in ("gameboy", "gameboyadvance", ...). + string SystemFolder, + /// The parsed short game name ("Emerald"), never the whole file name. + string GameName, + /// The file's last-modified time, so rescans can skip unchanged files. + DateTimeOffset? LastModified = null) +{ + /// The Pokémon generation the parsed game name belongs to (0 when unknown). + public int Generation => RomNameRules.GenerationOf(GameName); + + /// The extension including the dot, lower-case. + public string Extension + { + get + { + var dot = FileName.LastIndexOf('.'); + return dot < 0 ? string.Empty : FileName[dot..].ToLowerInvariant(); + } + } +} + +/// The Pokémon game a library ROM is, resolved from the file name alone. +public static class RomNameRules +{ + /// + /// "Pokemon - Emerald Version (France).gba" → "Emerald". The release number + /// ("1636 - "), the franchise prefix, the "Version"/"Version" suffix (French or + /// English), revision/region tags and disc numbers are all stripped; what remains + /// is the edition name, normalised to the English edition the catalog expects. + /// + public static string GameName(string fileName) + { + var name = fileName; + var dot = name.LastIndexOf('.'); + if (dot > 0) name = name[..dot]; + // No-Intro release numbers: "1636 - Pokemon Emerald (Independent)". + var dash = name.IndexOf(" - ", StringComparison.Ordinal); + if (dash is > 0 and < 8 && int.TryParse(name[..dash].Trim(), out _)) + name = name[(dash + 3)..]; + foreach (var prefix in new[] { "Pokemon - ", "Pokémon - ", "Pokemon ", "Pokémon " }) + if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { name = name[prefix.Length..]; break; } + var cut = name.IndexOf(" (", StringComparison.Ordinal); + if (cut > 0) name = name[..cut]; + cut = name.IndexOf(" [", StringComparison.Ordinal); + if (cut > 0) name = name[..cut]; + name = name.Replace(" Version", "", StringComparison.OrdinalIgnoreCase).Trim(); + // French dumps lead with the word: "Version Émeraude" → "Émeraude". + if (name.StartsWith("Version ", StringComparison.OrdinalIgnoreCase)) + name = name["Version ".Length..].Trim(); + // A French dump ("Émeraude") becomes the English edition the catalog, + // the art and the trophies expect; an English name passes through unchanged. + return FrenchEditionName.ToEnglish(name) ?? name; + } + + /// True when a file name looks like a Pokémon ROM at all — including + /// No-Intro release numbers and bare edition names with a Pokémon console extension. + public static bool LooksLikePokemon(string fileName) + { + if (fileName.StartsWith("Pokemon - ", StringComparison.OrdinalIgnoreCase) + || fileName.StartsWith("Pokémon - ", StringComparison.OrdinalIgnoreCase) + || fileName.StartsWith("Pokemon ", StringComparison.OrdinalIgnoreCase) + || fileName.StartsWith("Pokémon ", StringComparison.OrdinalIgnoreCase)) + return true; + // "1636 - Pokemon Emerald (Independent).gba": the release-number prefix. + var dash = fileName.IndexOf(" - ", StringComparison.Ordinal); + if (dash is > 0 and < 8 && int.TryParse(fileName[..dash].Trim(), out _) + && fileName[(dash + 3)..].StartsWith("Pokemon", StringComparison.OrdinalIgnoreCase)) + return true; + // A bare edition name the catalog knows ("emerald.gba", "rouge feu.gba"). + var parsed = GameName(fileName); + return FrenchEditionName.ToEnglish(parsed) is not null; + } + + /// The Pokémon generation a game name belongs to (0 when unknown). + public static int GenerationOf(string gameName) => gameName switch + { + "Red" or "Blue" or "Yellow" or "Green" => 1, + "Gold" or "Silver" or "Crystal" => 2, + "Ruby" or "Sapphire" or "Emerald" or "FireRed" or "LeafGreen" => 3, + "Diamond" or "Pearl" or "Platinum" or "HeartGold" or "SoulSilver" => 4, + "Black" or "White" or "Black 2" or "White 2" => 5, + "X" or "Y" or "Omega Ruby" or "Alpha Sapphire" => 6, + "Sun" or "Moon" or "Ultra Sun" or "Ultra Moon" => 7, + "Let's Go Pikachu" or "Let's Go Eevee" => 7, + "Sword" or "Shield" or "Brilliant Diamond" or "Shining Pearl" or "Legends: Arceus" => 8, + "Scarlet" or "Violet" => 9, + _ => 0, + }; +} + +/// Walks the ROM folders and reads the installed emulator packages. +public interface IRomScanner +{ + /// Scans one console's ES-DE folder for Pokémon ROMs (paths, not SAF ids). + IReadOnlyList Scan(GameConsole console, CancellationToken cancellationToken = default); + + /// The ES-DE ROM root that was found ("/storage/emulated/0/ROMs"), or null. + string? RomRoot { get; } +} + +/// The ROM library: every discovered Pokémon ROM, persisted between scans. +public interface IRomLibraryStore +{ + IReadOnlyList GetAll(); + + /// Replaces the whole library with one scan's results. + void ReplaceAll(IReadOnlyList roms); +} diff --git a/src/PKForge.Domain/RomLinks.cs b/src/PKForge.Domain/RomLinks.cs new file mode 100644 index 00000000..0ac84371 --- /dev/null +++ b/src/PKForge.Domain/RomLinks.cs @@ -0,0 +1,89 @@ +namespace PKForge.Domain; + +/// +/// One ROM pinned to one save's game identity: the path the launcher hands to the +/// emulator, plus everything the shelf needs to say "Play" instead of "Open save". +/// +/// The save this ROM belongs to (a stable SAF document id). +/// The ROM's SAF/document URI, as the platform picker produced it. +/// The ROM file name ("Pokemon - Emerald Version.gba"). +/// The emulator the game launches in (from ). +/// The game's generation; decides the RetroArch core and save layout hints. +/// The engine's game label at link time, for shelf subtitles. +public sealed record RomLink( + string SaveDocumentId, + string RomUri, + string RomFileName, + EmulatorKind Emulator, + int Generation, + string GameLabel); + +/// Stores the player's ROM links, one per save. +public interface IRomLinkStore +{ + IReadOnlyList GetAll(); + + RomLink? FindBySave(string saveDocumentId); + + /// Points at a new ROM, replacing any previous link. + void Set(RomLink link); + + /// Removes the ROM link of a save (the save itself is untouched). + void Remove(string saveDocumentId); +} + +/// Which emulator a linked ROM should launch with, and whether PKForge can hand it over directly. +public static class RomLinkRules +{ + /// + /// The emulators that can run this ROM, best first, inferred from the generation and the + /// file extension. The player's linked choice always wins over this preference order. + /// + public static IReadOnlyList EmulatorsForRom(int generation, string romFileName) + { + if (!GenerationKnows(generation)) + return []; + var extension = ExtensionOf(romFileName); + if (generation <= 2 && extension is not (".gb" or ".gbc" or ".zip" or ".7z")) + return []; + if (generation == 3 && extension is not (".gba" or ".zip" or ".7z")) + return []; + if (generation is 4 or 5 && extension is not (".nds" or ".dsi" or ".zip" or ".7z")) + return []; + return generation switch + { + 1 or 2 or 3 => [EmulatorKind.Embedded], + 4 or 5 => [EmulatorKind.MelonDS, EmulatorKind.RetroArch, EmulatorKind.DraStic], + _ => [], + }; + } + + /// True when the emulator accepts a direct ROM handoff (otherwise it opens its library). + public static bool CanLaunchRom(EmulatorKind kind) => EmulatorCatalog.App(kind).LaunchStyle != RomLaunchStyle.OpenAppOnly; + + /// The link's subtitle, with a neutral emulator name the App can restyle. + public static string Describe(RomLink link) => + $"{link.RomFileName} · {EmulatorCatalog.Name(link.Emulator)}"; + + private static string ExtensionOf(string fileName) + { + var dot = fileName.LastIndexOf('.'); + return dot < 0 ? string.Empty : fileName[dot..].ToLowerInvariant(); + } + + private static bool GenerationKnows(int generation) => generation is >= 1 and <= 5; +} + +/// Launches a linked ROM in its emulator, and reports which emulators are installed. +public interface IEmulatorLauncher +{ + /// True when the app answering is installed + /// (the manifest's package queries make this visible on Android 11+). + bool IsInstalled(string packageId); + + /// Hands the ROM to the emulator. False when the emulator could not be started at all. + Task LaunchAsync(RomLink link, CancellationToken cancellationToken = default); + + /// Opens the emulator's own library screen, when no direct ROM handoff exists. + Task OpenAppAsync(EmulatorKind kind, CancellationToken cancellationToken = default); +} diff --git a/src/PKForge.Domain/SecondScreenRoutes.cs b/src/PKForge.Domain/SecondScreenRoutes.cs index 3bf0cfff..d6f38439 100644 --- a/src/PKForge.Domain/SecondScreenRoutes.cs +++ b/src/PKForge.Domain/SecondScreenRoutes.cs @@ -20,6 +20,10 @@ public enum SecondScreenOwner Summary, /// The Living Dex Autopilot: the route map of cartridges and travelling Pokémon. Autopilot, + /// The Trophies page: the scanned game's ladder on the lower screen. + Trophies, + /// An emulator owns the foreground: the launched game's companion shows. + Companion, } /// diff --git a/src/PKForge.Domain/Trophies.cs b/src/PKForge.Domain/Trophies.cs new file mode 100644 index 00000000..efc9636d --- /dev/null +++ b/src/PKForge.Domain/Trophies.cs @@ -0,0 +1,128 @@ +namespace PKForge.Domain; + +/// What a homemade trophy asks the save scan to prove. +public enum TrophyKind +{ + /// N dex cells caught in the game's own dex (DexProgress.Caught). + DexCaught = 0, + /// N dex cells seen. + DexSeen = 1, + /// N distinct species held shiny across boxes and party. + ShinyOwned = 2, + /// Completion percentage of the game's own dex reaches N. + DexPercent = 3, + /// The player's playtime reaches N hours. + PlaytimeHours = 4, + /// The save has N Pokémon in storage plus party. + PokemonOwned = 5, + /// The save's dex is complete (every catchable species caught). + DexComplete = 6, + /// A generation-wide slice: every game of the generation linked and scanned. + GenerationComplete = 7, + /// The player confirmed it by hand: bosses, events, feats the bytes cannot prove. + Manual = 8, +} + +/// +/// One homemade trophy: what it measures, its threshold, and the reward text. Definitions +/// are generated (see ) so the 39 games stay uniform. +/// +public sealed record TrophyDefinition( + string Id, + string GameLabel, + int Generation, + TrophyKind Kind, + int Threshold, + string Title, + string Description, + /// The emoji the trophy card shows. + string Glyph = "\U0001F3C6"); + +/// The outcome of evaluating one trophy against one save scan. +public sealed record TrophyState( + string TrophyId, + bool Unlocked, + /// The measured value at scan time (caught count, hours, percent...). + int Current) +{ + /// Progress in whole percent, 100 once unlocked. + public int Percent => Unlocked ? 100 : Current <= 0 ? 0 : Math.Min(99, Current); +} + +/// Everything one save scan can measure, in one flat record: the trophy engine's input. +public sealed record SaveScanFacts( + string SaveDocumentId, + string GameLabel, + int Generation, + int DexCaught, + int DexSeen, + int DexTotal, + int ShinyOwned, + int PokemonOwned, + /// Playtime in whole hours. + int PlaytimeHours, + string? TrainerName = null) +{ + public int DexPercent => DexTotal == 0 ? 0 : (int)Math.Round(DexCaught * 100.0 / DexTotal); +} + +/// Persists unlocked trophies and their last measured progress. +public interface ITrophyStore +{ + IReadOnlyList GetAll(); + + TrophyState? Find(string trophyId); + + void Store(TrophyState state); + + /// Forgets one trophy's state entirely (a manual trophy the player relocked). + void Remove(string trophyId); + + /// Removes every state of one save's games (the shelf's "remove game"). + void RemoveGame(string gameLabel); +} + +/// +/// The homemade trophy engine: turns save scans into trophy states. Rules are pure — +/// no I/O — so every threshold, percent and unlock can be unit-tested. Manual trophies +/// (bosses, events the bytes cannot prove) stay out: the player confirms those in the UI. +/// +public static class TrophyEngine +{ + /// Evaluates one trophy definition against one scan. + public static TrophyState Evaluate(TrophyDefinition trophy, SaveScanFacts scan) + { + ArgumentNullException.ThrowIfNull(trophy); + ArgumentNullException.ThrowIfNull(scan); + var current = Measure(trophy, scan); + return new TrophyState(trophy.Id, current >= trophy.Threshold, current); + } + + /// Evaluates a whole game's trophies against one scan, keeping any unlock. + public static IEnumerable EvaluateGame(IReadOnlyList trophies, SaveScanFacts scan) => + trophies.Select(trophy => Evaluate(trophy, scan)); + + /// The measured value for a trophy kind: the single place thresholds read from. + public static int Measure(TrophyDefinition trophy, SaveScanFacts scan) => trophy.Kind switch + { + TrophyKind.DexCaught => scan.DexCaught, + TrophyKind.DexSeen => scan.DexSeen, + TrophyKind.ShinyOwned => scan.ShinyOwned, + TrophyKind.DexPercent => scan.DexPercent, + TrophyKind.PlaytimeHours => scan.PlaytimeHours, + TrophyKind.PokemonOwned => scan.PokemonOwned, + TrophyKind.DexComplete => scan.DexTotal == 0 ? 0 : (int)Math.Round(scan.DexCaught * 100.0 / scan.DexTotal), + TrophyKind.GenerationComplete => scan.DexTotal == 0 ? 0 : (int)Math.Round(scan.DexCaught * 100.0 / scan.DexTotal), + // Manual trophies live outside the scans: only the player's own confirmation unlocks them. + TrophyKind.Manual => 0, + _ => 0, + }; + + /// One game's completion percent over its trophy set, for the overview card. + public static int GamePercent(IReadOnlyList trophies, IReadOnlyList states) + { + if (trophies.Count == 0) return 0; + var unlocked = trophies.Count(t => states.FirstOrDefault(s => s.TrophyId == t.Id)?.Unlocked == true); + return (int)Math.Round(unlocked * 100.0 / trophies.Count); + } +} diff --git a/src/PKForge.Domain/TrophyCatalog.cs b/src/PKForge.Domain/TrophyCatalog.cs new file mode 100644 index 00000000..0f33fcae --- /dev/null +++ b/src/PKForge.Domain/TrophyCatalog.cs @@ -0,0 +1,118 @@ +namespace PKForge.Domain; + +/// +/// The 39 mainline Pokémon games (Gen 1-9) with per-generation trophy definitions, in the +/// uniform ladders the spec describes: dex milestones (caught, seen, percent), shinies, +/// playtime, collection size, and completion. Manual trophies (bosses, events) are added +/// per game by the player in the UI and live in the same store. +/// +public static class TrophyCatalog +{ + /// One row per game: label, generation, and its catchable dex total. + public static readonly IReadOnlyList<(string Label, int Generation, int DexTotal)> Games = + [ + ("Pokémon Red", 1, 151), ("Pokémon Blue", 1, 151), ("Pokémon Yellow", 1, 151), + ("Pokémon Gold", 2, 251), ("Pokémon Silver", 2, 251), ("Pokémon Crystal", 2, 251), + ("Pokémon Ruby", 3, 386), ("Pokémon Sapphire", 3, 386), ("Pokémon Emerald", 3, 386), + ("Pokémon FireRed", 3, 386), ("Pokémon LeafGreen", 3, 386), + ("Pokémon Diamond", 4, 493), ("Pokémon Pearl", 4, 493), ("Pokémon Platinum", 4, 493), + ("Pokémon HeartGold", 4, 493), ("Pokémon SoulSilver", 4, 493), + ("Pokémon Black", 5, 649), ("Pokémon White", 5, 649), + ("Pokémon Black 2", 5, 649), ("Pokémon White 2", 5, 649), + ("Pokémon X", 6, 721), ("Pokémon Y", 6, 721), + ("Pokémon Omega Ruby", 6, 721), ("Pokémon Alpha Sapphire", 6, 721), + ("Pokémon Sun", 7, 809), ("Pokémon Moon", 7, 809), + ("Pokémon Ultra Sun", 7, 809), ("Pokémon Ultra Moon", 7, 809), + ("Pokémon Let's Go Pikachu", 7, 151), ("Pokémon Let's Go Eevee", 7, 151), + ("Pokémon Sword", 8, 905), ("Pokémon Shield", 8, 905), + ("Pokémon Brilliant Diamond", 8, 493), ("Pokémon Shining Pearl", 8, 493), + ("Pokémon Legends: Arceus", 8, 242), + ("Pokémon Scarlet", 9, 1025), ("Pokémon Violet", 9, 1025), + ]; + + /// The dex-caught milestone ladder per generation, in species. + private static readonly Dictionary CaughtLadders = + new() + { + [1] = [10, 30, 60, 90, 120, 151], + [2] = [10, 30, 60, 100, 151, 251], + [3] = [10, 30, 60, 100, 200, 386], + [4] = [10, 30, 60, 100, 250, 493], + [5] = [10, 30, 60, 100, 300, 649], + [6] = [10, 30, 60, 100, 300, 721], + [7] = [10, 30, 60, 100, 400, 809], + [8] = [10, 30, 60, 100, 400, 905], + [9] = [10, 30, 60, 100, 500, 1025], + }; + + /// The shiny ladder: distinct shiny species held at once. + private static readonly int[] ShinyLadder = [1, 5, 10, 25, 50, 100]; + + /// The playtime ladder, in hours. + private static readonly int[] PlaytimeLadder = [10, 25, 50, 100, 200, 500]; + + /// The collection ladder: Pokémon owned across boxes and party. + private static readonly int[] CollectionLadder = [30, 60, 120, 240, 360, 480]; + + /// Every trophy of one game, deterministic from the catalogue. + public static IReadOnlyList ForGame(string label) + { + var game = Games.FirstOrDefault(g => LabelMatches(g.Label, label)); + if (game.Label is null) return []; + var id = Slug(game.Label); + var list = new List(); + + foreach (var caught in CaughtLadders[game.Generation]) + list.Add(new TrophyDefinition($"{id}-dex{caught}", game.Label, game.Generation, TrophyKind.DexCaught, + caught, $"Catcher {TierName(caught)}", $"Catch {caught} species.", + caught >= game.DexTotal ? "\U0001F511" : "\U0001F3AF")); + + foreach (var seen in new[] { 10, 50, 100, game.DexTotal }) + list.Add(new TrophyDefinition($"{id}-seen{seen}", game.Label, game.Generation, TrophyKind.DexSeen, + seen, $"Spotter {TierName(seen)}", $"See {seen} species.", "\U0001F441")); + + foreach (var shiny in ShinyLadder) + list.Add(new TrophyDefinition($"{id}-shiny{shiny}", game.Label, game.Generation, TrophyKind.ShinyOwned, + shiny, $"Shiny Hunter {TierName(shiny)}", $"Hold {shiny} distinct shiny species.", "\u2728")); + + foreach (var hours in PlaytimeLadder) + list.Add(new TrophyDefinition($"{id}-time{hours}", game.Label, game.Generation, TrophyKind.PlaytimeHours, + hours, $"Trainer {TierName(hours)}", $"Play for {hours} hours.", "\u23F1")); + + foreach (var owned in CollectionLadder) + list.Add(new TrophyDefinition($"{id}-own{owned}", game.Label, game.Generation, TrophyKind.PokemonOwned, + owned, $"Collector {TierName(owned)}", $"Own {owned} Pokémon across boxes and party.", "\U0001F4E6")); + + list.Add(new TrophyDefinition($"{id}-complete", game.Label, game.Generation, TrophyKind.DexComplete, + 100, "Dex Master", $"Catch every species the {game.Label} dex registers.", "\U0001F3C6")); + + return list; + } + + /// Every trophy of every game (the full 39-game ladder set). + public static IReadOnlyList All() => [.. Games.SelectMany(g => ForGame(g.Label))]; + + /// True when the label is the same game (edition-exact, accent-tolerant). + public static bool LabelMatches(string catalogLabel, string label) + { + var a = Normalise(catalogLabel); + var b = Normalise(label); + return a == b; + + static string Normalise(string text) => + text.Trim().ToLowerInvariant().Replace("pokémon ", "pokemon ").Replace("é", "e").Replace(":", "").Replace("'", ""); + } + + private static string TierName(int threshold) => threshold switch + { + <= 10 => "I", + <= 30 => "II", + <= 60 => "III", + < 100 => "IV", + < 500 => "V", + _ => "VI", + }; + + private static string Slug(string label) => + label.ToLowerInvariant().Replace("é", "e").Replace(":", "").Replace(" ", "-").Replace("'", ""); +} diff --git a/src/PKForge.Domain/TrophyManualCatalog.cs b/src/PKForge.Domain/TrophyManualCatalog.cs new file mode 100644 index 00000000..1aba1538 --- /dev/null +++ b/src/PKForge.Domain/TrophyManualCatalog.cs @@ -0,0 +1,77 @@ +namespace PKForge.Domain; + +/// +/// The manual trophy ladder per game: bosses, league titles, event legendaries — feats the +/// save bytes cannot prove, so the player confirms them. Per generation, mirroring each +/// generation's own milestones; unknown games get none. +/// +public static class TrophyManualCatalog +{ + /// Every manual trophy of one game, keyed by the same label rules as the scans. + public static IReadOnlyList ForGame(string label) + { + if (!TrophyCatalog.Games.Any(g => TrophyCatalog.LabelMatches(g.Label, label))) return []; + var game = TrophyCatalog.Games.First(g => TrophyCatalog.LabelMatches(g.Label, label)); + var id = Slug(game.Label); + return [.. ManualLadder(game.Generation, game.Label, id)]; + } + + private static IEnumerable ManualLadder(int generation, string label, string id) => generation switch + { + 1 => + [ + new($"{id}-manual-champion", label, generation, TrophyKind.Manual, 1, "Champion", "Beat the Elite Four and enter the Hall of Fame.", "\U0001F3C6"), + new($"{id}-manual-mew", label, generation, TrophyKind.Manual, 1, "Mew Hunter", "Catch or receive Mew.", "\U0001F9E1"), + ], + 2 => + [ + new($"{id}-manual-champion", label, generation, TrophyKind.Manual, 1, "Champion", "Beat the Elite Four and enter the Hall of Fame.", "\U0001F3C6"), + new($"{id}-manual-redgyarados", label, generation, TrophyKind.Manual, 1, "Red Gyarados", "Catch the rampaging Red Gyarados at the Lake of Rage.", "\U0001F534"), + new($"{id}-manual-celebi", label, generation, TrophyKind.Manual, 1, "Celebi Hunter", "Receive Celebi.", "\U0001F343"), + ], + 3 => + [ + new($"{id}-manual-champion", label, generation, TrophyKind.Manual, 1, "Champion", "Beat the Elite Four and enter the Hall of Fame.", "\U0001F3C6"), + new($"{id}-manual-battlefrontier", label, generation, TrophyKind.Manual, 1, "Frontier Brain", "Beat a Battle Frontier Brain.", "\U0001F396"), + new($"{id}-manual-contest", label, generation, TrophyKind.Manual, 1, "Contest Master", "Win a Master Rank Pokémon Contest.", "\U0001F3AD"), + ], + 4 => + [ + new($"{id}-manual-champion", label, generation, TrophyKind.Manual, 1, "Champion", "Beat the Elite Four and enter the Hall of Fame.", "\U0001F3C6"), + new($"{id}-manual-pokelathon", label, generation, TrophyKind.Manual, 1, "Pokéathlon Ace", "Win all Pokéathlon events.", "\U0001F3AF"), + new($"{id}-manual-ribbon", label, generation, TrophyKind.Manual, 1, "Ribbon Collector", "Earn every ribbon on one Pokémon.", "\U0001F380"), + ], + 5 => + [ + new($"{id}-manual-champion", label, generation, TrophyKind.Manual, 1, "Champion", "Beat the Elite Four and enter the Hall of Fame.", "\U0001F3C6"), + new($"{id}-manual-subway", label, generation, TrophyKind.Manual, 1, "Subway Boss", "Beat a Battle Subway boss.", "\U0001F687"), + new($"{id}-manual-musical", label, generation, TrophyKind.Manual, 1, "Musical Star", "Star in a Pokémon Musical.", "\U0001F3B5"), + ], + 6 => + [ + new($"{id}-manual-champion", label, generation, TrophyKind.Manual, 1, "Champion", "Beat the Elite Four and enter the Hall of Fame.", "\U0001F3C6"), + new($"{id}-manual-maison", label, generation, TrophyKind.Manual, 1, "Maison Boss", "Beat a Battle Maison boss.", "\U0001F3E0"), + new($"{id}-manual-contest", label, generation, TrophyKind.Manual, 1, "Contest Master", "Win a Master Rank Pokémon Contest.", "\U0001F3AD"), + ], + 7 => + [ + new($"{id}-manual-champion", label, generation, TrophyKind.Manual, 1, "Champion", "Become the first Alola Champion.", "\U0001F3C6"), + new($"{id}-manual-royal", label, generation, TrophyKind.Manual, 1, "Royal Winner", "Win a Battle Royal.", "\U0001F451"), + new($"{id}-manual-magearna", label, generation, TrophyKind.Manual, 1, "Magearna Hunter", "Receive Magearna.", "\U0001F9E9"), + ], + 8 => + [ + new($"{id}-manual-champion", label, generation, TrophyKind.Manual, 1, "Champion", "Beat the Champion Cup.", "\U0001F3C6"), + new($"{id}-manual-tower", label, generation, TrophyKind.Manual, 1, "Tower Boss", "Beat a Battle Tower boss.", "\U0001F3ED"), + new($"{id}-manual-galarstar", label, generation, TrophyKind.Manual, 1, "Galar Star", "Complete the Galar Star Tournament.", "\u2B50"), + ], + _ => + [ + new($"{id}-manual-champion", label, generation, TrophyKind.Manual, 1, "Champion", "Beat the Champion Cup.", "\U0001F3C6"), + new($"{id}-manual-ace", label, generation, TrophyKind.Manual, 1, "Ace Trainer", "Beat the Ace Tournament.", "\U0001F3AF"), + ], + }; + + private static string Slug(string label) => + label.ToLowerInvariant().Replace("é", "e").Replace(":", "").Replace(" ", "-").Replace("'", ""); +} diff --git a/src/PKForge.Engine/LegalizerService.cs b/src/PKForge.Engine/LegalizerService.cs index 772b9dfd..0e7dd0ec 100644 --- a/src/PKForge.Engine/LegalizerService.cs +++ b/src/PKForge.Engine/LegalizerService.cs @@ -252,7 +252,7 @@ internal static PKM LegalizeKeepingOrigin(SaveFile save, PKM current, Shiny shin made = TryKeep(made, pk => pk.Ball = current.Ball); if (current.CurrentLevel > made.CurrentLevel) made = TryKeep(made, pk => { pk.CurrentLevel = current.CurrentLevel; pk.ResetPartyStats(); }); if (moves.Length > 0) made = TryKeep(made, pk => { pk.SetMoves(moves); pk.HealPP(); }); - if (current.IsNicknamed) made = TryKeep(made, pk => pk.SetNickname(current.Nickname)); + if (current.IsNicknamed) made = TryKeep(made, pk => pk.SetNickname(SaveEngineSession.TrimToNicknameLimit(current.Nickname, pk))); return made; } return null; diff --git a/src/PKForge.Engine/SaveEngine.cs b/src/PKForge.Engine/SaveEngine.cs index a4a21920..d7ee890b 100644 --- a/src/PKForge.Engine/SaveEngine.cs +++ b/src/PKForge.Engine/SaveEngine.cs @@ -279,13 +279,4 @@ public ISaveEngineSession OpenBlankSession(int generation, string? displayName = }; return save.Generation <= 3 && tag != "JA" ? null : tag; } - - /// - /// Unbound relocates the PC and extends FireRed's species table, so editing it with - /// the stock FireRed engine would corrupt the save. It is recognized (and labeled) - /// everywhere, but stays closed until the dedicated Unbound engine ships. - /// - private static InvalidOperationException UnboundNotEditable() => new( - "Pokémon Unbound is recognized, but its CFRU save layout needs the Unbound editor " + - "(coming soon). The save file was not touched."); } diff --git a/src/PKForge.Engine/SaveEngineSession.cs b/src/PKForge.Engine/SaveEngineSession.cs index 40e57f69..6687ae2a 100644 --- a/src/PKForge.Engine/SaveEngineSession.cs +++ b/src/PKForge.Engine/SaveEngineSession.cs @@ -165,7 +165,7 @@ public void ApplyEdit(int box, int slot, EntityEdit edit) } if (edit.Nickname is { } nickname && !string.Equals(entity.Nickname, nickname, StringComparison.Ordinal)) { - entity.SetNickname(nickname); + entity.SetNickname(TrimToNicknameLimit(nickname, entity)); changed = true; } if (edit.Level is { } level && entity.CurrentLevel != Math.Clamp(level, 1, 100)) @@ -268,7 +268,7 @@ public void ApplyEdit(int box, int slot, EntityEdit edit) } if (edit.OriginalTrainer is { } ot && !string.Equals(entity.OriginalTrainerName, ot, StringComparison.Ordinal)) { - SetOriginalTrainerName(entity, ot, _save); + SetOriginalTrainerName(entity, TrimToTrainerNameLimit(ot, entity), _save); changed = true; } if (edit.Gender is { } gender && entity.Gender != Math.Clamp(gender, 0, 2)) @@ -897,11 +897,11 @@ internal static bool ApplyInstructions(PKM entity, IReadOnlyList instruc break; } case "nickname": - entity.SetNickname(value); + entity.SetNickname(TrimToNicknameLimit(value, entity)); changed = true; break; case "ot" or "trainer": - SetOriginalTrainerName(entity, value); + SetOriginalTrainerName(entity, TrimToTrainerNameLimit(value, entity)); changed = true; break; } @@ -1639,7 +1639,16 @@ internal bool MakeOwned(PKM entity, TrainerProfile? profile, out string? failure /// (Japanese: 5 characters and a terminator, the alignment bytes stay zero). A plain /// overwrite keeps the old name's tail after the new terminator, which PKHeX rightly flags /// as "Final terminator missing". Other formats have no such buffer rule. + /// + /// Cuts a nickname to what the mon's format stores (PKHeX's own per-format buffer size: + /// 10 in Gen 1-5, 12 from Gen 6 on, 5 for Japanese Gen 1/2). A longer name would + /// overflow the buffer when the string is encoded into the save. + /// + internal static string TrimToNicknameLimit(string nickname, PKM entity) => + nickname.Length <= entity.MaxStringLengthNickname ? nickname : nickname[..entity.MaxStringLengthNickname]; + internal static void SetOriginalTrainerName(PKM entity, string name, SaveFile? owner = null) + { if (entity is PK3 pk3) { @@ -1651,9 +1660,13 @@ internal static void SetOriginalTrainerName(PKM entity, string name, SaveFile? o } PrefillTrainerName3(trash, pk3.Japanese); } - entity.OriginalTrainerName = name; + entity.OriginalTrainerName = TrimToTrainerNameLimit(name, entity); } + /// Cuts an OT name to the mon's trainer-name buffer (PKHeX's own per-format size). + internal static string TrimToTrainerNameLimit(string name, PKM entity) => + name.Length <= entity.MaxStringLengthTrainer ? name : name[..entity.MaxStringLengthTrainer]; + /// The Gen 3 naming screen's buffer before a name is written: terminators for /// every character the language allows plus one, zero beyond. private static void PrefillTrainerName3(Span buffer, bool japanese) diff --git a/src/PKForge.Infrastructure/JsonConsoleEmulatorStore.cs b/src/PKForge.Infrastructure/JsonConsoleEmulatorStore.cs new file mode 100644 index 00000000..09103ef2 --- /dev/null +++ b/src/PKForge.Infrastructure/JsonConsoleEmulatorStore.cs @@ -0,0 +1,116 @@ +using System.Text.Json; +using PKForge.Domain; + +namespace PKForge.Infrastructure; + +/// +/// as one small JSON file, written atomically. A null +/// path keeps the bindings in memory. Keyed by console name: the enum values are persisted +/// as their ints, stable across releases. +/// +public sealed class JsonConsoleEmulatorStore(string? path) : IConsoleEmulatorStore +{ + private readonly string? _path = path; + private readonly Dictionary _entries = Load(path); + private readonly Lock _gate = new(); + + public EmulatorKind? Find(GameConsole console) + { + // GetValueOrDefault would return RetroArch (the enum's 0) for a missing key; + // an unset binding must read as null. + lock (_gate) return _entries.TryGetValue(console, out var emulator) ? emulator : null; + } + + public void Set(GameConsole console, EmulatorKind? emulator) + { + lock (_gate) + { + if (emulator is null) + { + if (!_entries.Remove(console)) return; + } + else + { + _entries[console] = emulator.Value; + } + Persist(); + } + } + + private void Persist() + { + if (_path is not { } path) return; + try + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + var temp = path + ".tmp"; + File.WriteAllText(temp, JsonSerializer.Serialize( + _entries.Select(e => new ConsoleEmulatorLink(e.Key, e.Value)).ToList())); + File.Move(temp, path, overwrite: true); + } + catch (IOException) { } // a binding is a convenience; never fail a launch over it + catch (UnauthorizedAccessException) { } + } + + private static Dictionary Load(string? path) + { + var entries = new Dictionary(); + if (path is null || !File.Exists(path)) return entries; + try + { + foreach (var link in JsonSerializer.Deserialize>(File.ReadAllText(path)) ?? []) + entries[link.Console] = link.Emulator; + } + catch (JsonException) { } + catch (IOException) { } + return entries; + } +} + +/// +/// as one small JSON file, written atomically. The whole +/// library is replaced per scan (the scan is the source of truth), so partial writes +/// never mix old and new results. +/// +public sealed class JsonRomLibraryStore(string? path) : IRomLibraryStore +{ + private readonly string? _path = path; + private readonly Lock _gate = new(); + + public IReadOnlyList GetAll() + { + lock (_gate) return Load(_path); + } + + public void ReplaceAll(IReadOnlyList roms) + { + ArgumentNullException.ThrowIfNull(roms); + lock (_gate) + { + if (_path is not { } path) return; + try + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + var temp = path + ".tmp"; + File.WriteAllText(temp, JsonSerializer.Serialize(roms.ToList())); + File.Move(temp, path, overwrite: true); + } + catch (IOException) { } // the library is rebuilt per scan; a lost write is recovered next scan + catch (UnauthorizedAccessException) { } + } + } + + private static List Load(string? path) + { + if (path is null || !File.Exists(path)) return []; + try + { + return JsonSerializer.Deserialize>(File.ReadAllText(path)) ?? []; + } + catch (JsonException) { } + catch (IOException) { } + return []; + } +} diff --git a/src/PKForge.Infrastructure/JsonRetroProgressStore.cs b/src/PKForge.Infrastructure/JsonRetroProgressStore.cs new file mode 100644 index 00000000..3a1f03a8 --- /dev/null +++ b/src/PKForge.Infrastructure/JsonRetroProgressStore.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using PKForge.Domain; + +namespace PKForge.Infrastructure; + +/// +/// as one small JSON file, written atomically (temp file + +/// replace). The last fetched achievement sets survive offline; a null path stays in memory. +/// +public sealed class JsonRetroProgressStore(string? path) : IRetroProgressStore +{ + private readonly string? _path = path; + private readonly Dictionary _entries = Load(path); + private readonly Lock _gate = new(); + + public IReadOnlyList GetAll() + { + lock (_gate) return [.. _entries.Values]; + } + + public RetroGameProgress? Find(int gameId) + { + lock (_gate) return _entries.GetValueOrDefault(gameId); + } + + public void Store(RetroGameProgress progress) + { + ArgumentNullException.ThrowIfNull(progress); + if (progress.GameId <= 0) return; + lock (_gate) + { + _entries[progress.GameId] = progress; + Persist(); + } + } + + private void Persist() + { + if (_path is null) return; + try + { + var directory = Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + var temp = _path + ".tmp"; + File.WriteAllText(temp, JsonSerializer.Serialize(_entries.Values.ToList())); + File.Move(temp, _path, overwrite: true); + } + catch (IOException) { } // cached progress is a convenience, never a failure + catch (UnauthorizedAccessException) { } + } + + private static Dictionary Load(string? path) + { + var entries = new Dictionary(); + if (path is null || !File.Exists(path)) return entries; + try + { + foreach (var progress in JsonSerializer.Deserialize>(File.ReadAllText(path)) ?? []) + if (progress.GameId > 0) entries[progress.GameId] = progress; + } + catch (JsonException) { } + catch (IOException) { } + return entries; + } +} diff --git a/src/PKForge.Infrastructure/JsonRomLinkStore.cs b/src/PKForge.Infrastructure/JsonRomLinkStore.cs new file mode 100644 index 00000000..0633cadc --- /dev/null +++ b/src/PKForge.Infrastructure/JsonRomLinkStore.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using PKForge.Domain; + +namespace PKForge.Infrastructure; + +/// +/// as one small JSON file, written atomically (temp file + +/// replace) so a crash mid-write never loses every link. A null path keeps it in memory. +/// +public sealed class JsonRomLinkStore(string? path) : IRomLinkStore +{ + private readonly string? _path = path; + private readonly Dictionary _entries = Load(path); + private readonly Lock _gate = new(); + + public IReadOnlyList GetAll() + { + lock (_gate) return [.. _entries.Values]; + } + + public RomLink? FindBySave(string saveDocumentId) + { + lock (_gate) return _entries.GetValueOrDefault(saveDocumentId); + } + + public void Set(RomLink link) + { + ArgumentNullException.ThrowIfNull(link); + ArgumentException.ThrowIfNullOrWhiteSpace(link.SaveDocumentId); + ArgumentException.ThrowIfNullOrWhiteSpace(link.RomUri); + lock (_gate) + { + _entries[link.SaveDocumentId] = link; + Persist(); + } + } + + public void Remove(string saveDocumentId) + { + lock (_gate) + { + if (!_entries.Remove(saveDocumentId)) return; + Persist(); + } + } + + private void Persist() + { + if (_path is null) return; + try + { + var directory = Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + var temp = _path + ".tmp"; + File.WriteAllText(temp, JsonSerializer.Serialize(_entries.Values.ToList())); + File.Move(temp, _path, overwrite: true); + } + catch (IOException) { } // links are a convenience; never fail a launch over them + catch (UnauthorizedAccessException) { } + } + + private static Dictionary Load(string? path) + { + var entries = new Dictionary(StringComparer.Ordinal); + if (path is null || !File.Exists(path)) return entries; + try + { + foreach (var link in JsonSerializer.Deserialize>(File.ReadAllText(path)) ?? []) + if (!string.IsNullOrWhiteSpace(link.SaveDocumentId) && !string.IsNullOrWhiteSpace(link.RomUri)) + entries[link.SaveDocumentId] = link; + } + catch (JsonException) { } + catch (IOException) { } + return entries; + } +} diff --git a/src/PKForge.Infrastructure/JsonTrophyStore.cs b/src/PKForge.Infrastructure/JsonTrophyStore.cs new file mode 100644 index 00000000..346f077b --- /dev/null +++ b/src/PKForge.Infrastructure/JsonTrophyStore.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using PKForge.Domain; + +namespace PKForge.Infrastructure; + +/// +/// as one small JSON file, written atomically (temp file + +/// replace). A null path keeps everything in memory (tests, design-time). +/// +public sealed class JsonTrophyStore(string? path) : ITrophyStore +{ + private readonly string? _path = path; + private readonly Dictionary _entries = Load(path); + private readonly Lock _gate = new(); + + public IReadOnlyList GetAll() + { + lock (_gate) return [.. _entries.Values]; + } + + public TrophyState? Find(string trophyId) + { + lock (_gate) return _entries.GetValueOrDefault(trophyId); + } + + public void Store(TrophyState state) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentException.ThrowIfNullOrWhiteSpace(state.TrophyId); + lock (_gate) + { + // An unlock is permanent: a later scan with a lower value never relocks it. + if (_entries.TryGetValue(state.TrophyId, out var previous) && previous.Unlocked) + _entries[state.TrophyId] = previous with { Current = Math.Max(previous.Current, state.Current) }; + else + _entries[state.TrophyId] = state; + Persist(); + } + } + + public void Remove(string trophyId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(trophyId); + lock (_gate) + { + if (!_entries.Remove(trophyId)) return; + Persist(); + } + } + + public void RemoveGame(string gameLabel) + { + ArgumentException.ThrowIfNullOrWhiteSpace(gameLabel); + var prefix = $"{gameLabel.ToLowerInvariant().Replace(" ", "-").Replace("é", "e").Replace(":", "").Replace("'", "")}-"; + lock (_gate) + { + var doomed = _entries.Keys.Where(id => id.StartsWith(prefix, StringComparison.Ordinal)).ToList(); + if (doomed.Count == 0) return; + foreach (var id in doomed) _entries.Remove(id); + Persist(); + } + } + + private void Persist() + { + if (_path is null) return; + try + { + var directory = Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + var temp = _path + ".tmp"; + File.WriteAllText(temp, JsonSerializer.Serialize(_entries.Values.ToList())); + File.Move(temp, _path, overwrite: true); + } + catch (IOException) { } // trophy progress is a convenience, never a failure + catch (UnauthorizedAccessException) { } + } + + private static Dictionary Load(string? path) + { + var entries = new Dictionary(StringComparer.Ordinal); + if (path is null || !File.Exists(path)) return entries; + try + { + foreach (var state in JsonSerializer.Deserialize>(File.ReadAllText(path)) ?? []) + if (!string.IsNullOrWhiteSpace(state.TrophyId)) entries[state.TrophyId] = state; + } + catch (JsonException) { } + catch (IOException) { } + return entries; + } +} diff --git a/tests/PKForge.Domain.Tests/AppUpdateRulesTests.cs b/tests/PKForge.Domain.Tests/AppUpdateRulesTests.cs index beb32ac3..2c8b85cd 100644 --- a/tests/PKForge.Domain.Tests/AppUpdateRulesTests.cs +++ b/tests/PKForge.Domain.Tests/AppUpdateRulesTests.cs @@ -13,6 +13,14 @@ public sealed class AppUpdateRulesTests [InlineData("3.0.3", "v3.1.0", true)] // the 3.1 release, as a 3.0.3 install sees it [InlineData("3.1.0", "v3.0.3", false)] [InlineData("1.4.0", "v1.4.0-beta+build.1", false)] + [InlineData("1.4.0-beta", "v1.4.0", true)] // a release outranks its own prerelease + [InlineData("1.4.0", "v1.4.0-rc.1", false)] + [InlineData("1.4.0-beta.1", "v1.4.0-beta.2", true)] // numeric identifiers compare numerically + [InlineData("1.4.0-beta.2", "v1.4.0-beta.10", true)] // 10 > 2, not an ordinal "1" < "2" read + [InlineData("1.4.0-beta", "v1.4.0-beta.1", true)] // more identifiers win a shared-prefix tie + [InlineData("1.4.0-beta.1", "v1.4.0-beta", false)] + [InlineData("1.4.0-alpha", "v1.4.0-beta", true)] // ordinal among alphanumeric identifiers + [InlineData("1.4.0-beta.1", "v1.4.0-2", false)] // numeric ranks below alphanumeric [InlineData("garbage", "v1.4.0", false)] public void ComparesStableReleaseTags(string installed, string candidate, bool expected) => Assert.Equal(expected, AppUpdateRules.IsNewerVersion(installed, candidate)); diff --git a/tests/PKForge.Domain.Tests/LiveGbaCompanionTests.cs b/tests/PKForge.Domain.Tests/LiveGbaCompanionTests.cs new file mode 100644 index 00000000..579be84c --- /dev/null +++ b/tests/PKForge.Domain.Tests/LiveGbaCompanionTests.cs @@ -0,0 +1,145 @@ +using PKForge.Domain; +using Xunit; + +namespace PKForge.Domain.Tests; + +public sealed class LiveGbaCompanionTests +{ + /// Minimal in-memory IEmbeddedCore holding a synthetic GBA save image. + private sealed class FakeCore(byte[] sram, string title = "POKEMON EMER", string code = "BPEE") : IEmbeddedCore + { + public bool IsRunning => true; + public string? RomPath => "/roms/emerald.gba"; + public string GameTitle => title; + public string GameCode => code; + public (int Width, int Height) VideoSize => (240, 160); + + public bool Load(string romPath, string? savePath = null) => true; + public void RunFrame() { } + public void CopyFrame(Span buffer) { } + public void SetKeys(uint bitmask) { } + public bool ReadBytes(uint address, Span destination) + { + if (address != LiveGbaCompanion.SaveBase) return false; + if (destination.Length > sram.Length) return false; + sram.AsSpan(0, destination.Length).CopyTo(destination); + return true; + } + public uint? Read32(uint address) => 0; + public bool SaveState(string path) => true; + public bool LoadState(string path) => true; + public void Dispose() { } + } + + private const int Sector = 0x1000; + private const int Main = 14 * Sector; + + /// Builds a 128 KiB SRAM image: sector 0 of slot 0 holds the dex flags + trainer block. + private static byte[] SramWithDex(int caughtBits, int seenBits, byte partyCount = 3, byte hours = 12, byte minutes = 34) + { + var sram = new byte[Main * 2]; + // Sector ID at 0xFF4 of each sector. + for (var s = 0; s < 14; s++) + { + sram[s * Sector + 0xFF4] = (byte)s; + sram[Main + s * Sector + 0xFF4] = (byte)s; + } + // Sector 0 footer counter (slot comparison) — slot 0 newer than slot 1 by default. + sram[0xFFC] = 0x02; // counter low byte = 2 + sram[Main + 0xFFC] = 0x01; + // Dex flags: caught at 0x28, seen at 0x5C of the small sector. + sram[0x28] = (byte)caughtBits; + sram[0x5C] = (byte)seenBits; + // Trainer block: playtime at 0x0C (hours) / 0x0D (minutes). + sram[0x0C] = hours; + sram[0x0D] = minutes; + // Party count at 0x234 of sector 1 (large block). + sram[Sector + 0x234] = partyCount; + return sram; + } + + [Fact] + public void ReadsDexCaughtSeenPartyAndPlaytimeFromTheActiveSlot() + { + var sram = SramWithDex(caughtBits: 0b0000_0111, seenBits: 0b0000_1111, partyCount: 4, hours: 7, minutes: 55); + var core = new FakeCore(sram); + + var snapshot = LiveGbaCompanion.Read(core, maxSpeciesId: 386); + + Assert.NotNull(snapshot); + Assert.Equal(3, snapshot.DexCaught); // bits 0,1,2 set + Assert.Equal(4, snapshot.DexSeen); // bits 0..3 set + Assert.Equal(386, snapshot.DexTotal); + Assert.Equal(4, snapshot.PartyCount); + Assert.Equal(7, snapshot.PlaytimeHours); + Assert.Equal(55, snapshot.PlaytimeMinutes); + Assert.Equal("POKEMON EMER", snapshot.GameTitle); + Assert.Equal("BPEE", snapshot.GameCode); + } + + [Fact] + public void PartyCountIsCappedAtSix() + { + var sram = SramWithDex(0, 0, partyCount: 200); + var snapshot = LiveGbaCompanion.Read(new FakeCore(sram), 386); + Assert.Equal(6, snapshot!.PartyCount); + } + + [Fact] + public void ReturnsNullWhenTheSaveRegionIsUnreadable() + { + // The bus read fails: nothing is copied, no snapshot. + Assert.Null(LiveGbaCompanion.Read(new UnreadableCore(), 386)); + } + + private sealed class UnreadableCore : IEmbeddedCore + { + public bool IsRunning => true; + public string? RomPath => null; + public string GameTitle => ""; + public string GameCode => "BPEE"; + public (int Width, int Height) VideoSize => (240, 160); + public bool Load(string romPath, string? savePath = null) => false; + public void RunFrame() { } + public void CopyFrame(Span buffer) { } + public void SetKeys(uint bitmask) { } + public bool ReadBytes(uint address, Span destination) => false; + public uint? Read32(uint address) => null; + public bool SaveState(string path) => false; + public bool LoadState(string path) => false; + public void Dispose() { } + } + + [Fact] + public void DexPercentIsCaughtOverTotal() + { + var sram = SramWithDex(caughtBits: 0xFF, seenBits: 0xFF); + var snapshot = LiveGbaCompanion.Read(new FakeCore(sram), 386); + Assert.Equal(8, snapshot!.DexCaught); + Assert.True(snapshot.DexPercent >= 2 && snapshot.DexPercent <= 3); // 8/386 ≈ 2.07% + } + + [Fact] + public void ReturnsNullWhenNotRunning() + { + Assert.Null(LiveGbaCompanion.Read(new StoppedCore(), 386)); + } + + private sealed class StoppedCore : IEmbeddedCore + { + public bool IsRunning => false; + public string? RomPath => null; + public string GameTitle => ""; + public string GameCode => "BPEE"; + public (int Width, int Height) VideoSize => (0, 0); + public bool Load(string romPath, string? savePath = null) => false; + public void RunFrame() { } + public void CopyFrame(Span buffer) { } + public void SetKeys(uint bitmask) { } + public bool ReadBytes(uint address, Span destination) => false; + public uint? Read32(uint address) => null; + public bool SaveState(string path) => false; + public bool LoadState(string path) => false; + public void Dispose() { } + } +} diff --git a/tests/PKForge.Domain.Tests/RetroAchievementsJsonTests.cs b/tests/PKForge.Domain.Tests/RetroAchievementsJsonTests.cs new file mode 100644 index 00000000..aa2a4d59 --- /dev/null +++ b/tests/PKForge.Domain.Tests/RetroAchievementsJsonTests.cs @@ -0,0 +1,149 @@ +using System.Text.Json; +using PKForge.Domain; +using Xunit; + +namespace PKForge.Domain.Tests; + +/// +/// The RA web API adds DateEarned/DateEarnedHardcore to an achievement only once the +/// player unlocked it, but older responses returned an empty string for a locked one: +/// the property's presence alone must never count as an unlock. +/// +public sealed class RetroAchievementsJsonTests +{ + private static JsonElement Parse(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + [Fact] + public void EarnedAchievementKeepsItsTimestamp() + { + var root = Parse(""" + { + "ID": 1, "Title": "Sonic the Hedgehog", "NumAchievements": 1, + "Achievements": { + "9": { "ID": 9, "Title": "That Was Easy", "Description": "Complete act 1", + "Points": 3, "BadgeName": "250336", + "DateEarned": "2016-03-12 17:47:29", "DateEarnedHardcore": "2016-03-12 17:47:29" } + } + } + """); + var progress = RetroAchievementsJson.ParseGameProgress(root, 1); + var achievement = Assert.Single(progress.Achievements); + Assert.True(achievement.Earned); + Assert.True(achievement.EarnedHardcore); + Assert.Equal(new DateTimeOffset(2016, 3, 12, 17, 47, 29, TimeSpan.Zero), achievement.DateEarned); + Assert.Equal(1, progress.Unlocked); + Assert.Equal(3, progress.PointsEarned); + } + + [Fact] + public void AchievementWithoutEarnedFieldsIsLocked() + { + var root = Parse(""" + { + "ID": 1, "Title": "Sonic the Hedgehog", + "Achievements": { + "9": { "ID": 9, "Title": "That Was Easy", "Description": "Complete act 1", + "Points": 3, "BadgeName": "250336" } + } + } + """); + var achievement = Assert.Single(RetroAchievementsJson.ParseGameProgress(root, 1).Achievements); + Assert.False(achievement.Earned); + Assert.False(achievement.EarnedHardcore); + Assert.Null(achievement.DateEarned); + } + + [Theory] + [InlineData("\"DateEarned\": \"\"")] + [InlineData("\"DateEarned\": \" \"")] + [InlineData("\"DateEarned\": null")] + public void EmptyStringDateEarnedIsNotAnUnlock(string dateField) + { + var root = Parse($$""" + { + "ID": 1, "Title": "Sonic the Hedgehog", + "Achievements": { + "9": { "ID": 9, "Title": "That Was Easy", "Description": "Complete act 1", + "Points": 3, "BadgeName": "250336", {{dateField}} } + } + } + """); + var achievement = Assert.Single(RetroAchievementsJson.ParseGameProgress(root, 1).Achievements); + Assert.False(achievement.Earned); + Assert.Null(achievement.DateEarned); + } + + [Fact] + public void SoftcoreOnlyUnlockIsNotHardcore() + { + var root = Parse(""" + { + "ID": 1, "Title": "Sonic the Hedgehog", + "Achievements": { + "9": { "ID": 9, "Title": "That Was Easy", "Description": "Complete act 1", + "Points": 3, "BadgeName": "250336", "DateEarned": "2016-03-12 17:47:29" } + } + } + """); + var achievement = Assert.Single(RetroAchievementsJson.ParseGameProgress(root, 1).Achievements); + Assert.True(achievement.Earned); + Assert.False(achievement.EarnedHardcore); + } + + [Fact] + public void MissingFieldsDefaultToEmptyAndZero() + { + var root = Parse("""{ "ID": 7, "Achievements": { "3": { "ID": 3 } } }"""); + var progress = RetroAchievementsJson.ParseGameProgress(root, 7); + Assert.Equal(7, progress.GameId); + Assert.Equal(string.Empty, progress.Title); + var achievement = Assert.Single(progress.Achievements); + Assert.Equal(3, achievement.Id); + Assert.Equal(string.Empty, achievement.Title); + Assert.Equal(0, achievement.Points); + Assert.False(achievement.Earned); + } + + [Fact] + public void EmptyAchievementSetSerializesAsAnObject() + { + // issue #484 on RAWeb: a game with no published set returns {} - never an array. + var root = Parse("""{ "ID": 7, "Title": "Empty", "Achievements": {} }"""); + var progress = RetroAchievementsJson.ParseGameProgress(root, 7); + Assert.Empty(progress.Achievements); + Assert.Equal(100, progress.Percent); // nothing to do counts as complete + } + + [Fact] + public void AchievementsAreSortedById() + { + var root = Parse(""" + { + "ID": 1, + "Achievements": { + "30": { "ID": 30, "Title": "C" }, + "2": { "ID": 2, "Title": "A" }, + "17": { "ID": 17, "Title": "B" } + } + } + """); + var progress = RetroAchievementsJson.ParseGameProgress(root, 1); + Assert.Equal([2, 17, 30], progress.Achievements.Select(a => a.Id).ToArray()); + } + + [Theory] + [InlineData("""{"ID": 1, "Title": "Sonic"}""", true)] + [InlineData("""{"ID": "not a number"}""", true)] // presence is what the client checks + [InlineData("""[]""", false)] // an empty array is the error shape + [InlineData("""{"error": "Unknown game"}""", false)] + public void LooksLikeGameOnlyAcceptsAnObjectWithAnId(string json, bool expected) => + Assert.Equal(expected, RetroAchievementsJson.LooksLikeGame(Parse(json))); + + [Fact] + public void LooksLikeProfileRejectsTheErrorShape() => + Assert.False(RetroAchievementsJson.LooksLikeProfile(Parse("""{"error": "Unknown user"}"""))); +} diff --git a/tests/PKForge.Domain.Tests/RomLinkTests.cs b/tests/PKForge.Domain.Tests/RomLinkTests.cs new file mode 100644 index 00000000..9e9e8ba7 --- /dev/null +++ b/tests/PKForge.Domain.Tests/RomLinkTests.cs @@ -0,0 +1,211 @@ +using PKForge.Domain; +using Xunit; + +namespace PKForge.Domain.Tests; + +public sealed class RomLinkRulesTests +{ + [Theory] + [InlineData(1, "Pokemon - Red Version.gb", EmulatorKind.Embedded)] + [InlineData(2, "Pokemon - Crystal Version.gbc", EmulatorKind.Embedded)] + [InlineData(2, "Pokemon - Crystal Version.zip", EmulatorKind.Embedded)] + [InlineData(3, "Pokemon - Emerald Version.gba", EmulatorKind.Embedded)] + [InlineData(4, "Pokemon - Diamond Version.nds", EmulatorKind.MelonDS)] + [InlineData(5, "Pokemon - Black Version 2.nds", EmulatorKind.MelonDS)] + public void PrefersTheEmulatorThatRunsTheGeneration(int generation, string rom, EmulatorKind first) + { + var emulators = RomLinkRules.EmulatorsForRom(generation, rom); + Assert.NotEmpty(emulators); + Assert.Equal(first, emulators[0]); + } + + [Theory] + [InlineData(1, "Pokemon - Red Version.gb")] + [InlineData(2, "Pokemon - Crystal Version.gbc")] + [InlineData(3, "Pokemon - Emerald Version.gba")] + public void GenOneToThreeRomsHaveEmbeddedAsTheirOnlyRoute(int generation, string rom) + { + // Gen 1-3 ROMs can never be handed to an external emulator. + Assert.Equal([EmulatorKind.Embedded], RomLinkRules.EmulatorsForRom(generation, rom)); + } + + [Theory] + [InlineData(3, "Pokemon - Emerald Version.nds")] // a GBA game on a DS file + [InlineData(4, "Pokemon - Diamond Version.gba")] // a DS game on a GBA file + [InlineData(1, "Pokemon - Red Version.3ds")] // nothing runnable + [InlineData(6, "Pokemon - X.3ds")] // Gen 6 has no launch route yet + [InlineData(0, "Whatever.gb")] // unknown generation + public void RefusesExtensionsAndGenerationsItCannotRun(int generation, string rom) => + Assert.Empty(RomLinkRules.EmulatorsForRom(generation, rom)); + + [Theory] + [InlineData(EmulatorKind.RetroArch, true)] // package-scoped MAIN + content extras + [InlineData(EmulatorKind.MelonDS, true)] // package-scoped VIEW with the ROM URI + [InlineData(EmulatorKind.Azahar, true)] // VIEW handoff: the game loads + [InlineData(EmulatorKind.Eden, true)] + [InlineData(EmulatorKind.Dolphin, false)] // no documented handoff: opens its library + public void DirectHandoffOnlyForEmulatorsThatDocumentOne(EmulatorKind kind, bool canLaunch) => + Assert.Equal(canLaunch, RomLinkRules.CanLaunchRom(kind)); + + [Fact] + public void RetroArchCoresFollowTheGeneration() + { + Assert.Equal(RetroArchCore.Gambatte, EmulatorCatalog.CoreForGeneration(1)); + Assert.Equal(RetroArchCore.Gambatte, EmulatorCatalog.CoreForGeneration(2)); + Assert.Equal(RetroArchCore.MGba, EmulatorCatalog.CoreForGeneration(3)); + Assert.Equal(RetroArchCore.MelonDs, EmulatorCatalog.CoreForGeneration(4)); + Assert.Equal(RetroArchCore.MelonDs, EmulatorCatalog.CoreForGeneration(5)); + } + + [Fact] + public void CatalogNamesStablePackages() + { + Assert.Equal("com.retroarch", EmulatorCatalog.App(EmulatorKind.RetroArch).PrimaryPackage); + Assert.Equal("io.github.melonds", EmulatorCatalog.App(EmulatorKind.MelonDS).PrimaryPackage); + Assert.Contains("me.magnum.melonds", EmulatorCatalog.App(EmulatorKind.MelonDS).PackageIds); + Assert.Contains("com.retroarch.aarch64", EmulatorCatalog.App(EmulatorKind.RetroArch).PackageIds); + } + + [Fact] + public void InstallationDetectsAnyKnownPackage() + { + Assert.True(EmulatorCatalog.IsInstalled(EmulatorKind.RetroArch, id => id == "com.retroarch.aarch64")); + Assert.False(EmulatorCatalog.IsInstalled(EmulatorKind.MelonDS, id => id == "com.retroarch")); + } + + [Fact] + public void DisplayNamesAreStable() + { + Assert.Equal("melonDS", EmulatorCatalog.Name(EmulatorKind.MelonDS)); + Assert.Equal("DraStic", EmulatorCatalog.Name(EmulatorKind.DraStic)); + Assert.Equal("Pizza Boy GBA", EmulatorCatalog.Name(EmulatorKind.PizzaBoyGba)); + Assert.Equal("Pizza Boy GBC", EmulatorCatalog.Name(EmulatorKind.PizzaBoyGbc)); + Assert.Equal("Citra MMJ", EmulatorCatalog.Name(EmulatorKind.CitraMmj)); + Assert.Equal("RetroArch", EmulatorCatalog.Name(EmulatorKind.RetroArch)); + Assert.Equal("Eden", EmulatorCatalog.Name(EmulatorKind.Eden)); + Assert.Equal("Linkboy", EmulatorCatalog.Name(EmulatorKind.Linkboy)); + Assert.Equal("Azahar", EmulatorCatalog.Name(EmulatorKind.Azahar)); + Assert.Equal("Dolphin", EmulatorCatalog.Name(EmulatorKind.Dolphin)); + } + + [Fact] + public void DescribeNamesTheRomAndItsEmulator() + { + var link = new RomLink("save1", "file:///roms/emerald.gba", "emerald.gba", EmulatorKind.MelonDS, 3, "Pokémon Emerald"); + Assert.Equal("emerald.gba · melonDS", RomLinkRules.Describe(link)); + } +} + +public sealed class RomLinkStoreTests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), $"rom-links-{Guid.NewGuid():N}.json"); + + [Fact] + public void SetReplacesAndRemoveClears() + { + var store = new Infrastructure.JsonRomLinkStore(_path); + Assert.Null(store.FindBySave("save1")); + store.Set(new RomLink("save1", "file:///roms/emerald.gba", "emerald.gba", EmulatorKind.RetroArch, 3, "Pokémon Emerald")); + var link = store.FindBySave("save1"); + Assert.NotNull(link); + Assert.Equal("emerald.gba", link.RomFileName); + store.Set(new RomLink("save1", "file:///roms/emerald2.gba", "emerald2.gba", EmulatorKind.RetroArch, 3, "Pokémon Emerald")); + Assert.Equal("emerald2.gba", store.FindBySave("save1")!.RomFileName); + store.Remove("save1"); + Assert.Null(store.FindBySave("save1")); + } + + [Fact] + public void SurvivesAReopenLikeARelaunch() + { + var store = new Infrastructure.JsonRomLinkStore(_path); + store.Set(new RomLink("save1", "file:///roms/gold.gbc", "gold.gbc", EmulatorKind.RetroArch, 2, "Pokémon Gold")); + var reopened = new Infrastructure.JsonRomLinkStore(_path); + Assert.Equal("gold.gbc", reopened.FindBySave("save1")!.RomFileName); + } + + public void Dispose() + { + if (File.Exists(_path)) File.Delete(_path); + } +} + +public sealed class RetroAchievementsRulesTests +{ + [Theory] + [InlineData(1, true)] + [InlineData(2, true)] + [InlineData(3, true)] + [InlineData(4, true)] + [InlineData(5, true)] + [InlineData(6, false)] // 3DS: no official set + [InlineData(7, false)] + [InlineData(8, false)] // Switch: house awards instead + public void OfficialSetsExistForGen1To5Only(int generation, bool supported) => + Assert.Equal(supported, RetroAchievementsRules.SupportsGeneration(generation)); + + [Fact] + public void ConsolesMapToGenerations() + { + Assert.Equal(4, RetroAchievementsRules.ConsoleForGeneration[1]); + Assert.Equal(5, RetroAchievementsRules.ConsoleForGeneration[2]); + Assert.Equal(6, RetroAchievementsRules.ConsoleForGeneration[3]); + Assert.Equal(18, RetroAchievementsRules.ConsoleForGeneration[4]); + Assert.Equal(18, RetroAchievementsRules.ConsoleForGeneration[5]); + } + + [Theory] + [InlineData("Pokémon Emerald Version", "Emerald")] + [InlineData("Pokemon Emerald Version", "Emerald")] + [InlineData("Pokémon Red Version", "Red")] + [InlineData("Pokémon Blue Version", "Blue")] + [InlineData("Pokémon Crystal Version", "Crystal")] + [InlineData("Pokémon HeartGold Version | Pokémon SoulSilver Version", "HeartGold")] + [InlineData("Pokémon HeartGold Version | Pokémon SoulSilver Version", "SoulSilver")] + [InlineData("Pokémon Black Version 2", "Black 2")] + public void TitleMatchingAnswersTheExactEdition(string raTitle, string shortName) => + Assert.True(RetroAchievementsRules.TitleMatches(raTitle, "", shortName)); + + [Theory] + [InlineData("Pokémon Red Version", "Blue")] + [InlineData("Pokémon Emerald Version", "Ruby")] + [InlineData("Pokémon HeartGold Version | Pokémon SoulSilver Version", "Diamond")] + [InlineData("Pokémon Black Version 2", "Black")] // the sequel is its own set + public void TitleMatchingRefusesOtherEditions(string raTitle, string shortName) => + Assert.False(RetroAchievementsRules.TitleMatches(raTitle, "", shortName)); + + [Fact] + public void ShortNameStripsTheFranchise() + { + Assert.Equal("Emerald", RetroAchievementsRules.ShortName("Pokémon Emerald")); + Assert.Equal("Black 2", RetroAchievementsRules.ShortName("Pokémon Black Version 2")); + } +} + +public sealed class RetroGameProgressTests +{ + private static RetroAchievement Achievement(int id, int points, bool earned, bool hardcore = false) => + new(id, $"A{id}", $"Achievement {id}", points, "00000", earned, earned ? DateTimeOffset.UtcNow : null, hardcore); + + [Fact] + public void CountsUnlocksPointsAndPercent() + { + var progress = new RetroGameProgress(1, "Pokémon Red", + [ + Achievement(1, 5, true), Achievement(2, 10, true), Achievement(3, 10, false), Achievement(4, 25, false), + ]); + Assert.Equal(2, progress.Unlocked); + Assert.Equal(4, progress.Total); + Assert.Equal(50, progress.Percent); + Assert.Equal(15, progress.PointsEarned); + Assert.Equal(50, progress.PointsTotal); + } + + [Fact] + public void AnEmptySetIsComplete() + { + var progress = new RetroGameProgress(1, "Unset game", []); + Assert.Equal(100, progress.Percent); + Assert.Equal(0, progress.PointsTotal); + } +} diff --git a/tests/PKForge.Domain.Tests/TrophyTests.cs b/tests/PKForge.Domain.Tests/TrophyTests.cs new file mode 100644 index 00000000..fbba5d3f --- /dev/null +++ b/tests/PKForge.Domain.Tests/TrophyTests.cs @@ -0,0 +1,433 @@ +using PKForge.Domain; +using Xunit; + +namespace PKForge.Domain.Tests; + +public sealed class TrophyEngineTests +{ + private static SaveScanFacts Facts( + int caught = 0, int seen = 0, int total = 151, int shiny = 0, int owned = 0, int hours = 0) => + new("save1", "Pokémon Red", 1, caught, seen, total, shiny, owned, hours); + + [Theory] + [InlineData(TrophyKind.DexCaught, 60, 60)] + [InlineData(TrophyKind.DexCaught, 30, 60)] + [InlineData(TrophyKind.DexSeen, 100, 100)] + [InlineData(TrophyKind.ShinyOwned, 5, 5)] + [InlineData(TrophyKind.PlaytimeHours, 50, 50)] + [InlineData(TrophyKind.PokemonOwned, 120, 120)] + public void MeasuresTheFactTheKindReads(TrophyKind kind, int threshold, int expectedCurrent) + { + // One scan, fixed facts: each kind reads its own number, whatever the threshold. + var scan = Facts(caught: 60, seen: 100, shiny: 5, owned: 120, hours: 50); + var trophy = new TrophyDefinition("t", "Pokémon Red", 1, kind, threshold + 1, "T", "D"); + var state = TrophyEngine.Evaluate(trophy, scan); + Assert.Equal(expectedCurrent, state.Current); + } + + [Fact] + public void UnlocksAtTheThresholdAndNotBefore() + { + var trophy = new TrophyDefinition("t", "Pokémon Red", 1, TrophyKind.DexCaught, 60, "Catcher III", "Catch 60."); + Assert.False(TrophyEngine.Evaluate(trophy, Facts(caught: 59)).Unlocked); + Assert.True(TrophyEngine.Evaluate(trophy, Facts(caught: 60)).Unlocked); + Assert.True(TrophyEngine.Evaluate(trophy, Facts(caught: 151)).Unlocked); + } + + [Fact] + public void DexPercentRoundsTheCaughtShare() + { + var scan = Facts(caught: 75, total: 151); // 49.67 → 50 + var trophy = new TrophyDefinition("t", "Pokémon Red", 1, TrophyKind.DexPercent, 50, "Half", "50%."); + Assert.True(TrophyEngine.Evaluate(trophy, scan).Unlocked); + } + + [Fact] + public void DexCompleteUnlocksOnlyAtEverySpecies() + { + var trophy = new TrophyDefinition("t", "Pokémon Red", 1, TrophyKind.DexComplete, 100, "Dex Master", "All."); + Assert.False(TrophyEngine.Evaluate(trophy, Facts(caught: 150)).Unlocked); + Assert.True(TrophyEngine.Evaluate(trophy, Facts(caught: 151)).Unlocked); + } + + [Fact] + public void GamePercentCountsUnlockedTrophies() + { + var trophies = TrophyCatalog.ForGame("Pokémon Red"); + Assert.NotEmpty(trophies); + var states = trophies.Take(3).Select(t => new TrophyState(t.Id, true, t.Threshold)).ToList(); + Assert.True(TrophyEngine.GamePercent(trophies, states) > 0); + Assert.Equal(100, TrophyEngine.GamePercent(trophies, trophies.Select(t => new TrophyState(t.Id, true, t.Threshold)).ToList())); + Assert.Equal(0, TrophyEngine.GamePercent(trophies, [])); + } +} + +public sealed class TrophyCatalogTests +{ + [Fact] + public void CoversTheMainlineGames() + { + Assert.True(TrophyCatalog.Games.Count >= 37); + Assert.Equal(9, TrophyCatalog.Games.Select(g => g.Generation).Distinct().Count()); + Assert.Equal(151, TrophyCatalog.Games.First(g => g.Generation == 1).DexTotal); + Assert.Equal(1025, TrophyCatalog.Games.First(g => g.Generation == 9).DexTotal); + } + + [Fact] + public void EveryGameHasItsLadder() + { + foreach (var game in TrophyCatalog.Games) + { + var trophies = TrophyCatalog.ForGame(game.Label); + Assert.NotEmpty(trophies); + Assert.Equal(game.Label, trophies[0].GameLabel); + Assert.Equal(game.Generation, trophies[0].Generation); + Assert.Contains(trophies, t => t.Kind == TrophyKind.DexComplete); + Assert.Contains(trophies, t => t.Kind == TrophyKind.ShinyOwned); + Assert.Contains(trophies, t => t.Kind == TrophyKind.PlaytimeHours); + } + } + + [Theory] + [InlineData("Pokémon Emerald")] + [InlineData("Pokemon Emerald")] + [InlineData("Pokémon Let's Go Pikachu")] + [InlineData("Pokémon Legends: Arceus")] + public void LabelMatchingIsAccentAndPunctuationTolerant(string label) + { + Assert.NotEmpty(TrophyCatalog.ForGame(label)); + Assert.True(TrophyCatalog.LabelMatches("Pokémon Emerald", "Pokemon Emerald")); + } + + [Theory] + [InlineData("Pokémon Radical Red")] // ROM hack: no ladder, the house system shows nothing + [InlineData("Unknown Game")] + [InlineData("")] + public void GamesOutsideTheCatalogHaveNoLadder(string label) => + Assert.Empty(TrophyCatalog.ForGame(label)); + + [Fact] + public void TrophyIdsAreUniqueAcrossTheCatalog() + { + var all = TrophyCatalog.All(); + Assert.Equal(all.Count, all.Select(t => t.Id).Distinct().Count()); + } +} + +public sealed class TrophyStoreTests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), $"trophies-{Guid.NewGuid():N}.json"); + + [Fact] + public void AnUnlockIsPermanent() + { + var store = new Infrastructure.JsonTrophyStore(_path); + store.Store(new TrophyState("red-dex60", true, 60)); + store.Store(new TrophyState("red-dex60", false, 10)); + var state = store.Find("red-dex60"); + Assert.NotNull(state); + Assert.True(state.Unlocked); + Assert.Equal(60, state.Current); + } + + [Fact] + public void LockedProgressTracksTheBestValue() + { + var store = new Infrastructure.JsonTrophyStore(_path); + store.Store(new TrophyState("red-dex10", false, 4)); + store.Store(new TrophyState("red-dex10", false, 7)); + Assert.Equal(7, store.Find("red-dex10")!.Current); + } + + [Fact] + public void SurvivesAReopenLikeARelaunch() + { + var store = new Infrastructure.JsonTrophyStore(_path); + store.Store(new TrophyState("red-dex60", true, 60)); + var reopened = new Infrastructure.JsonTrophyStore(_path); + Assert.True(reopened.Find("red-dex60")!.Unlocked); + } + + [Fact] + public void RemoveGameClearsItsTrophiesOnly() + { + var store = new Infrastructure.JsonTrophyStore(_path); + store.Store(new TrophyState("pokemon-red-dex60", true, 60)); + store.Store(new TrophyState("pokemon-emerald-dex60", true, 60)); + store.RemoveGame("Pokémon Red"); + Assert.Null(store.Find("pokemon-red-dex60")); + Assert.NotNull(store.Find("pokemon-emerald-dex60")); + } + + public void Dispose() + { + if (File.Exists(_path)) File.Delete(_path); + } +} + +public sealed class TrophyManualCatalogTests +{ + [Theory] + [InlineData("Pokémon Emerald", 3)] + [InlineData("Pokémon Red", 2)] + [InlineData("Pokémon Scarlet", 2)] + public void MainlineGamesHaveTheirManualLadder(string label, int expected) + { + var manual = TrophyManualCatalog.ForGame(label); + Assert.Equal(expected, manual.Count); + Assert.All(manual, t => Assert.Equal(TrophyKind.Manual, t.Kind)); + Assert.All(manual, t => Assert.Equal(1, t.Threshold)); + Assert.Contains(manual, t => t.Title == "Champion"); + } + + [Theory] + [InlineData("Pokémon Radical Red")] + [InlineData("Unknown Game")] + public void GamesOutsideTheCatalogHaveNoManualLadder(string label) => + Assert.Empty(TrophyManualCatalog.ForGame(label)); + + [Fact] + public void ManualTrophiesNeverUnlockFromScans() + { + var manual = TrophyManualCatalog.ForGame("Pokémon Emerald")[0]; + // Even a perfect save measures zero: only the player's confirmation unlocks it. + var scan = new SaveScanFacts("s", "Pokémon Emerald", 3, 386, 386, 386, 386, 600, 500); + Assert.False(TrophyEngine.Evaluate(manual, scan).Unlocked); + } + + [Fact] + public void ManualAndScanTrophiesShareOneIdSpace() + { + var scanTrophies = TrophyCatalog.ForGame("Pokémon Emerald"); + var manualTrophies = TrophyManualCatalog.ForGame("Pokémon Emerald"); + var ids = scanTrophies.Select(t => t.Id).Concat(manualTrophies.Select(t => t.Id)).ToList(); + Assert.Equal(ids.Count, ids.Distinct().Count()); + } + + [Fact] + public void EveryCatalogGameHasAManualChampionTrophy() + { + foreach (var game in TrophyCatalog.Games) + Assert.Contains(TrophyManualCatalog.ForGame(game.Label), t => t.Title == "Champion"); + } +} + +public sealed class TrophyStoreManualTests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), $"trophies-manual-{Guid.NewGuid():N}.json"); + + [Fact] + public void ARelockedManualTrophyIsForgotten() + { + var store = new Infrastructure.JsonTrophyStore(_path); + store.Store(new TrophyState("pokemon-red-manual-champion", true, 1)); + store.Remove("pokemon-red-manual-champion"); + Assert.Null(store.Find("pokemon-red-manual-champion")); + } + + [Fact] + public void RemoveKeepsTheOtherTrophies() + { + var store = new Infrastructure.JsonTrophyStore(_path); + store.Store(new TrophyState("pokemon-red-manual-champion", true, 1)); + store.Store(new TrophyState("pokemon-red-dex60", true, 60)); + store.Remove("pokemon-red-manual-champion"); + Assert.NotNull(store.Find("pokemon-red-dex60")); + } + + public void Dispose() + { + if (File.Exists(_path)) File.Delete(_path); + } +} + +public sealed class RomNameRulesTests +{ + // GameName and LooksLikePokemon cases live in FrenchEditionNameTests, which + // covers English, French, No-Intro and bare-name files in one place. + + [Theory] + [InlineData("Emerald", 3)] + [InlineData("Red", 1)] + [InlineData("Black 2", 5)] + [InlineData("Scarlet", 9)] + [InlineData("Unknown Hack", 0)] + public void MapsEditionsToGenerations(string gameName, int expected) => + Assert.Equal(expected, RomNameRules.GenerationOf(gameName)); +} + +public sealed class ConsoleRulesTests +{ + [Fact] + public void GenerationsMapToTheirConsoles() + { + Assert.Equal(GameConsole.GameBoy, ConsoleRules.ForGeneration(1)[0]); + Assert.Equal(GameConsole.GameBoyColor, ConsoleRules.ForGeneration(2)[0]); + Assert.Equal(GameConsole.GameBoyAdvance, ConsoleRules.ForGeneration(3)[0]); + Assert.Equal(GameConsole.NintendoDs, ConsoleRules.ForGeneration(4)[0]); + Assert.Equal(GameConsole.NintendoSwitch, ConsoleRules.ForGeneration(9)[0]); + } + + [Fact] + public void EachConsoleNamesItsEmulatorsInPreferenceOrder() + { + // GB/GBC/GBA play in PKForge itself — the embedded core, no external emulator. + Assert.Equal([EmulatorKind.Embedded], ConsoleRules.EmulatorsFor(GameConsole.GameBoy)); + Assert.Equal([EmulatorKind.Embedded], ConsoleRules.EmulatorsFor(GameConsole.GameBoyColor)); + Assert.Equal([EmulatorKind.Embedded], ConsoleRules.EmulatorsFor(GameConsole.GameBoyAdvance)); + Assert.Equal(EmulatorKind.MelonDS, ConsoleRules.EmulatorsFor(GameConsole.NintendoDs)[0]); + Assert.Equal(EmulatorKind.Azahar, ConsoleRules.EmulatorsFor(GameConsole.Nintendo3Ds)[0]); + Assert.Equal(EmulatorKind.Eden, ConsoleRules.EmulatorsFor(GameConsole.NintendoSwitch)[0]); + } + + [Theory] + [InlineData(GameConsole.GameBoy)] + [InlineData(GameConsole.GameBoyColor)] + [InlineData(GameConsole.GameBoyAdvance)] + public void InAppConsolesNeverOfferAnExternalEmulator(GameConsole console) + { + // The single source of truth for "this console runs in PKForge": every candidate + // list for it is exactly [Embedded] — no RetroArch/Linkboy/PizzaBoy can leak in. + Assert.True(ConsoleRules.RunsInApp(console)); + Assert.Equal([EmulatorKind.Embedded], ConsoleRules.EmulatorsFor(console)); + Assert.DoesNotContain(ConsoleRules.EmulatorsFor(console), + kind => kind != EmulatorKind.Embedded); + } + + [Theory] + [InlineData(GameConsole.NintendoDs)] + [InlineData(GameConsole.Nintendo3Ds)] + [InlineData(GameConsole.GameCube)] + [InlineData(GameConsole.NintendoSwitch)] + public void ExternalConsolesAreNotMarkedInApp(GameConsole console) + { + Assert.False(ConsoleRules.RunsInApp(console)); + Assert.DoesNotContain(EmulatorKind.Embedded, ConsoleRules.EmulatorsFor(console)); + } + + [Fact] + public void InstallChecksFollowThePreferenceList() + { + // The embedded core needs no package: GB is "installed" regardless of packages. + Assert.True(ConsoleRules.AnyInstalled(GameConsole.GameBoy, _ => false)); + Assert.True(ConsoleRules.AnyInstalled(GameConsole.NintendoDs, id => id == "io.github.melonds")); + Assert.False(ConsoleRules.AnyInstalled(GameConsole.Nintendo3Ds, id => id == "com.retroarch")); + } +} + +public sealed class ConsoleEmulatorStoreTests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), $"consoles-{Guid.NewGuid():N}.json"); + + [Fact] + public void BindsAndClearsPerConsole() + { + var store = new Infrastructure.JsonConsoleEmulatorStore(_path); + Assert.Null(store.Find(GameConsole.GameBoyAdvance)); + store.Set(GameConsole.GameBoyAdvance, EmulatorKind.RetroArch); + Assert.Equal(EmulatorKind.RetroArch, store.Find(GameConsole.GameBoyAdvance)); + store.Set(GameConsole.GameBoyAdvance, null); + Assert.Null(store.Find(GameConsole.GameBoyAdvance)); + } + + [Fact] + public void TwoConsolesBindIndependently() + { + var store = new Infrastructure.JsonConsoleEmulatorStore(_path); + store.Set(GameConsole.NintendoDs, EmulatorKind.MelonDS); + store.Set(GameConsole.GameBoy, EmulatorKind.RetroArch); + Assert.Equal(EmulatorKind.MelonDS, store.Find(GameConsole.NintendoDs)); + Assert.Equal(EmulatorKind.RetroArch, store.Find(GameConsole.GameBoy)); + } + + [Fact] + public void SurvivesAReopenLikeARelaunch() + { + var store = new Infrastructure.JsonConsoleEmulatorStore(_path); + store.Set(GameConsole.NintendoDs, EmulatorKind.MelonDS); + Assert.Equal(EmulatorKind.MelonDS, new Infrastructure.JsonConsoleEmulatorStore(_path).Find(GameConsole.NintendoDs)); + } + + public void Dispose() + { + if (File.Exists(_path)) File.Delete(_path); + } +} + +public sealed class LanguageRulesTests +{ + [Fact] + public void FrenchIsTheDefault() + { + Assert.Equal(AppLanguage.French, LanguageRules.Default); + Assert.Equal(AppLanguage.French, LanguageRules.Resolve(hasChoice: false, AppLanguage.English, deviceLocale: "de")); + Assert.Equal(AppLanguage.French, LanguageRules.Resolve(hasChoice: false, AppLanguage.English, deviceLocale: null)); + } + + [Theory] + [InlineData("fr", true)] + [InlineData("fr-FR", true)] + [InlineData("fr_CA", true)] + [InlineData("en-US", false)] + [InlineData(null, false)] + public void RecognisesFrenchLocales(string? locale, bool expected) => + Assert.Equal(expected, LanguageRules.IsFrenchLocale(locale)); + + [Fact] + public void ThePlayersChoiceWins() + { + Assert.Equal(AppLanguage.English, LanguageRules.Resolve(hasChoice: true, AppLanguage.English, deviceLocale: "fr-FR")); + Assert.Equal(AppLanguage.French, LanguageRules.Resolve(hasChoice: true, AppLanguage.French, deviceLocale: "en-US")); + } +} + +public sealed class FrenchEditionNameTests +{ + [Theory] + [InlineData("Pokemon - Emerald Version (France).gba", "Emerald")] + [InlineData("1636 - Pokemon Emerald (Independent).gba", "Emerald")] + [InlineData("Pokemon - Version Émeraude (Fr).gba", "Emerald")] + [InlineData("Pokémon Emeraude.gba", "Emerald")] + [InlineData("emerald.gba", "Emerald")] + [InlineData("Pokemon - Rouge Feu (France).gba", "FireRed")] + [InlineData("Pokemon - Or.gbc", "Gold")] + [InlineData("Pokemon - Version Écarlate.nsz", "Scarlet")] + [InlineData("Pokemon - Épée.nsp", "Sword")] + public void ParsesNoIntroFrenchAndBareNames(string fileName, string expected) => + Assert.Equal(expected, RomNameRules.GameName(fileName)); + + [Theory] + [InlineData("rouge", "Red")] + [InlineData("rouge feu", "FireRed")] + [InlineData("émeraude", "Emerald")] + [InlineData("emeraude", "Emerald")] + [InlineData("diamant étincelant", "Brilliant Diamond")] + [InlineData("perle scintillante", "Shining Pearl")] + [InlineData("écarlate", "Scarlet")] + [InlineData("épée", "Sword")] + public void MapsFrenchEditionsToEnglish(string french, string expected) => + Assert.Equal(expected, FrenchEditionName.ToEnglish(french)); + + [Theory] + [InlineData("Emerald")] + [InlineData("FireRed")] + [InlineData("Scarlet")] + public void EnglishEditionsPassThrough(string english) => + Assert.Equal(english, FrenchEditionName.ToEnglish(english)); + + [Theory] + [InlineData("Radical Red")] + [InlineData("Unbound")] + [InlineData("Something Else")] + public void UnknownNamesReturnNull(string unknown) => + Assert.Null(FrenchEditionName.ToEnglish(unknown)); + + [Theory] + [InlineData("1636 - Pokemon Emerald (Independent).gba", true)] + [InlineData("emerald.gba", true)] + [InlineData("rouge feu.gba", true)] + [InlineData("Mario Kart DS.nds", false)] + [InlineData("tetris.gb", false)] + public void TheScannerRecognisesPokemonFilesOnly(string fileName, bool expected) => + Assert.Equal(expected, RomNameRules.LooksLikePokemon(fileName)); +} diff --git a/tests/PKForge.Engine.Tests/NicknameLimitTests.cs b/tests/PKForge.Engine.Tests/NicknameLimitTests.cs new file mode 100644 index 00000000..29ee001f --- /dev/null +++ b/tests/PKForge.Engine.Tests/NicknameLimitTests.cs @@ -0,0 +1,108 @@ +using PKForge.Domain; +using PKForge.Engine; +using PKHeX.Core; +using Xunit; + +namespace PKForge.Engine.Tests; + +/// +/// Nicknames and OT names must fit the mon's string buffer: a name longer than the format +/// stores would overflow it when encoded into the save. The box slot starts empty, so a +/// freshly built mon is placed first (the same write the generator performs). +/// +public sealed class NicknameLimitTests +{ + private static SaveEngineSession BlankSession(int generation) => + (SaveEngineSession)new SaveEngine().OpenBlankSession(generation); + + private static (int Box, int Slot) SeedMon(SaveEngineSession session) + { + var mon = session.SaveFile.BlankPKM; + mon.Species = (ushort)Species.Bulbasaur; + mon.CurrentLevel = 5; + mon.RefreshChecksum(); + var bytes = new byte[mon.SIZE_PARTY]; + mon.WriteDecryptedDataParty(bytes); + // Gen 1/2 blank saves hold the mon in the party (their box layout is a list the + // blank save does not initialise); every other generation takes a box slot. + var (box, slot) = mon.Format <= 2 ? (-1, 0) : (0, 0); + Assert.True(session.ImportSlot(box, slot, bytes), $"gen {mon.Format}: could not seed a mon"); + return (box, slot); + } + + // A blank Gen 1 session is Japanese (SaveEngine.VersionFor(1) => GameVersion.BU), so its + // mons carry 6-byte string buffers (limits 5) and the Gen 1 JP table has no Latin letters: + // the filler must be a kana. A blank Gen 2 session is English (limits 10/7, 'A' encodes). + [Theory] + [InlineData(1, 5, 'ア')] + [InlineData(2, 10, 'A')] + [InlineData(3, 10, 'A')] + [InlineData(4, 10, 'A')] + [InlineData(5, 10, 'A')] + [InlineData(6, 12, 'A')] + [InlineData(7, 12, 'A')] + [InlineData(8, 12, 'A')] + [InlineData(9, 12, 'A')] + public void EditTruncatesOverlongNicknames(int generation, int limit, char filler) + { + using var session = BlankSession(generation); + var (box, slot) = SeedMon(session); + session.ApplyEdit(box, slot, new EntityEdit(Nickname: new string(filler, limit + 5))); + Assert.Equal(limit, session.ReadEntity(box, slot).Nickname.Length); + } + + [Theory] + [InlineData(1, 5, 'ア')] + [InlineData(2, 7, 'A')] + [InlineData(3, 7, 'A')] + [InlineData(4, 7, 'A')] + [InlineData(5, 7, 'A')] + [InlineData(6, 12, 'A')] + [InlineData(7, 12, 'A')] + [InlineData(8, 12, 'A')] + [InlineData(9, 12, 'A')] + public void EditTruncatesOverlongOriginalTrainerNames(int generation, int limit, char filler) + { + using var session = BlankSession(generation); + var (box, slot) = SeedMon(session); + session.ApplyEdit(box, slot, new EntityEdit(OriginalTrainer: new string(filler, limit + 5))); + Assert.Equal(limit, session.ReadEntity(box, slot).OriginalTrainer.Length); + } + + [Fact] + public void ShortNamesPassThroughUnchanged() + { + using var session = BlankSession(3); + var (box, slot) = SeedMon(session); + session.ApplyEdit(box, slot, new EntityEdit(Nickname: "Sparky")); + Assert.Equal("Sparky", session.ReadEntity(box, slot).Nickname); + } + + [Theory] + [InlineData(1, LanguageID.Japanese, 5)] + [InlineData(1, LanguageID.English, 10)] + [InlineData(2, LanguageID.Japanese, 5)] + [InlineData(2, LanguageID.English, 10)] + [InlineData(3, LanguageID.English, 10)] + [InlineData(5, LanguageID.English, 10)] + [InlineData(9, LanguageID.English, 12)] + public void LimitFollowsTheLanguage(int generation, LanguageID language, int expected) + { + PKM mon; + if (generation is 1 or 2) + { + // GBPKML.Japanese derives from the OT buffer length, not the Language property: + // a Gen 1/2 mon is Japanese exactly when its buffers are 6 bytes long. + var jp = language == LanguageID.Japanese; + mon = generation == 1 ? new PK1(jp) : new PK2(jp); + } + else + { + using var session = BlankSession(generation); + mon = session.SaveFile.BlankPKM; + mon.Language = (int)language; + } + var trimmed = SaveEngineSession.TrimToNicknameLimit(new string('A', 20), mon); + Assert.Equal(expected, trimmed.Length); + } +} diff --git a/tools/build-mgba/build.sh b/tools/build-mgba/build.sh new file mode 100755 index 00000000..9256d2e9 --- /dev/null +++ b/tools/build-mgba/build.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Builds libpkforge_mgba.so for arm64-v8a: libmgba 0.10.5 static + PKForge's flat +# JNI shim. Run once per checkout (and after any mGBA bump): +# +# NDK_HOME=/path/to/android-ndk tools/build-mgba/build.sh +# +# The output lands in src/PKForge.App/Libraries/arm64-v8a/libpkforge_mgba.so, +# which PKForge.App.csproj packs into the APK (AndroidNativeLibrary). +set -euo pipefail + +MGBA_VERSION="0.10.5" +MGBA_TAR="https://github.com/mgba-emu/mgba/archive/refs/tags/${MGBA_VERSION}.tar.gz" +HERE="$(cd "$(dirname "$0")" && pwd)" +WORK="${HERE}/build" +OUT="${HERE}/../../src/PKForge.App/Libraries/arm64-v8a" + +: "${NDK_HOME:?Set NDK_HOME to your Android NDK path (e.g. %LOCALAPPDATA%\\Android\\Sdk\\ndk\\27.x on Windows)}" + +API=26 +TRIPLE="aarch64-linux-android" + +mkdir -p "${WORK}" "${OUT}" +cd "${WORK}" + +# 1. mGBA source (pinned tag) +if [ ! -d "mgba-${MGBA_VERSION}" ]; then + curl -L "${MGBA_TAR}" | tar xz +fi +cd "mgba-${MGBA_VERSION}" + +# 2. libmgba static for arm64 — the flags PokeDaisy uses (no frontends, no extras). +cmake -B build-android \ + -DCMAKE_TOOLCHAIN_FILE="${NDK_HOME}/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI=arm64-v8a \ + -DANDROID_PLATFORM=android-${API} \ + -DBUILD_QT=OFF -DBUILD_SDL=OFF -DBUILD_LIBRETRO=OFF \ + -DBUILD_STATIC=ON -DBUILD_SHARED=OFF \ + -DUSE_FFMPEG=OFF -DUSE_DISCORD_RPC=OFF -DUSE_GDB_STUB=OFF \ + -DUSE_SQLITE3=OFF -DUSE_MINIZIP=OFF -DUSE_PNG=OFF -DUSE_LIBZIP=OFF +# BUILD_STATIC=ON produces libmgba.a (the standard add_library output name). +cmake --build build-android -j"$(nproc)" + +# 3. The shim links against the static lib. +HOST_TAG="$(uname | tr '[:upper:]' '[:lower:]')-x86_64" +if [ "${HOST_TAG}" = "darwin-x86_64" ]; then HOST_TAG="darwin-x86_64"; fi +CLANG="${NDK_HOME}/toolchains/llvm/prebuilt/${HOST_TAG}/bin/clang" +if [ ! -x "${CLANG}" ]; then + # Some NDK releases name it aarch64-linux-...; fall back to any prebuilt dir. + CLANG="$(find "${NDK_HOME}/toolchains/llvm/prebuilt" -name clang -type f | head -1)" +fi +"${CLANG}" \ + --target="${TRIPLE}${API}" \ + -shared -fPIC -O2 \ + -I"${WORK}/mgba-${MGBA_VERSION}/include" \ + "${HERE}/jni_bridge.c" \ + "${WORK}/mgba-${MGBA_VERSION}/build-android/libmgba.a" \ + -o "${OUT}/libpkforge_mgba.so" \ + -llog -landroid + +echo "Built ${OUT}/libpkforge_mgba.so" diff --git a/tools/build-mgba/jni_bridge.c b/tools/build-mgba/jni_bridge.c new file mode 100644 index 00000000..6c552fa2 --- /dev/null +++ b/tools/build-mgba/jni_bridge.c @@ -0,0 +1,263 @@ +/* PKForge's flat C shim over libmgba 0.10.5 (mgba/core/core.h), the layer + * MgbaNative.cs P/Invokes. Every function returns int (1 = success, 0 = failure) + * and every string is UTF-8: no C bools to marshal, no struct layouts on the + * managed side. One core handle per ROM; the caller owns the threading. + * + * Built by tools/build-mgba/build.sh (NDK, arm64-v8a) into libpkforge_mgba.so, + * linking libmgba static — the same flags PokeDaisy uses: + * -DBUILD_QT=OFF -DBUILD_SDL=OFF -DBUILD_LIBRETRO=OFF -DBUILD_STATIC=ON + * -DBUILD_SHARED=OFF -DUSE_FFMPEG=OFF -DUSE_DISCORD_RPC=OFF + */ + +#include +#include +#include +#include +#include +#include + +/* SAVESTATE_SCREENSHOT | SAVESTATE_SAVEDATA | SAVESTATE_RTC | SAVESTATE_METADATA */ +#define PKFORGE_STATE_FLAGS 0xF + +/* The handle carries the core plus the video buffer PKForge allocated, so + * destroy frees both (mGBA does not own the pinned buffer). */ +struct PkforgeCore { + struct mCore* core; + uint32_t* frame; +}; + +int pkforg_core_create(const char* romPath) { + struct mCore* core = mCoreFind(romPath); + if (!core) { + return 0; + } + if (!core->init(core)) { + core->deinit(core); + return 0; + } + /* The port name picks mGBA's config section; NULL keeps the default ("None"), + * which is fine: PKForge sets no per-port options through mCoreConfig. */ + mCoreInitConfig(core, NULL); + struct PkforgeCore* handle = malloc(sizeof(struct PkforgeCore)); + if (!handle) { + core->deinit(core); + return 0; + } + handle->core = core; + handle->frame = NULL; + return (int)(intptr_t)handle; +} + +void pkforg_core_destroy(intptr_t coreHandle) { + struct PkforgeCore* handle = (struct PkforgeCore*)(void*)coreHandle; + if (!handle) { + return; + } + if (handle->frame) { + free(handle->frame); + handle->frame = NULL; + } + if (handle->core) { + handle->core->deinit(handle->core); + } + free(handle); +} + +static struct mCore* asCore(intptr_t coreHandle) { + struct PkforgeCore* handle = (struct PkforgeCore*)(void*)coreHandle; + return handle ? handle->core : NULL; +} + +int pkforg_core_load(intptr_t coreHandle, const char* romPath, const char* savePath) { + struct PkforgeCore* handle = (struct PkforgeCore*)(void*)coreHandle; + struct mCore* core = handle ? handle->core : NULL; + if (!core || !romPath) { + return 0; + } + struct VFile* rom = VFileOpen(romPath, O_RDONLY); + if (!rom) { + return 0; + } + if (!core->loadROM(core, rom)) { + rom->close(rom); + return 0; + } + if (savePath) { + struct VFile* save = VFileOpen(savePath, O_RDWR | O_CREAT); + if (save) { + core->loadSave(core, save); + } + } + /* The video buffer is allocated and pinned once, at load: the core renders into + * it every runFrame; copy_frame only reads it back. RGBA8888, no stride padding + * (stride = width). */ + unsigned width = 0; + unsigned height = 0; + core->desiredVideoDimensions(core, &width, &height); + size_t pixels = (size_t)width * height; + uint32_t* frame = malloc(pixels * sizeof(uint32_t)); + if (frame) { + core->setVideoBuffer(core, frame, width); + } + if (handle) { + handle->frame = frame; + } + core->reset(core); + return 1; +} + +void pkforg_core_run_frame(intptr_t coreHandle) { + struct mCore* core = asCore(coreHandle); + if (core) { + core->runFrame(core); + } +} + +void pkforg_core_reset(intptr_t coreHandle) { + struct mCore* core = asCore(coreHandle); + if (core) { + core->reset(core); + } +} + +void pkforg_core_video_size(intptr_t coreHandle, unsigned* width, unsigned* height) { + struct mCore* core = asCore(coreHandle); + if (!core) { + *width = 0; + *height = 0; + return; + } + core->desiredVideoDimensions(core, width, height); +} + +void pkforg_core_copy_frame(intptr_t coreHandle, uint32_t* buffer, size_t pixels) { + struct mCore* core = asCore(coreHandle); + if (!core || !buffer) { + return; + } + unsigned width = 0; + unsigned height = 0; + core->desiredVideoDimensions(core, &width, &height); + size_t needed = (size_t)width * height; + if (pixels < needed) { + return; + } + /* The buffer was pinned at load; getPixels hands the core's own pointer back + * (a single memcpy, no re-render). */ + const void* pixelsOut = NULL; + size_t stride = 0; + core->getPixels(core, &pixelsOut, &stride); + if (pixelsOut) { + memcpy(buffer, pixelsOut, needed * sizeof(uint32_t)); + } +} + +void pkforg_core_set_keys(intptr_t coreHandle, uint32_t keys) { + struct mCore* core = asCore(coreHandle); + if (core) { + core->setKeys(core, keys); + } +} + +int pkforg_core_read_bytes(intptr_t coreHandle, uint32_t address, uint8_t* destination, size_t length) { + struct mCore* core = asCore(coreHandle); + if (!core || !destination || length == 0) { + return 0; + } + /* mCoreGetMemoryBlockInfo resolves the block that owns the address (mirrors + * included); the read is a single memcpy when the range fits the block, + * per-word bus reads otherwise (unmapped or straddling ranges). + */ + const struct mCoreMemoryBlock* info = mCoreGetMemoryBlockInfo(core, address); + if (!info || info->size == 0 || address < info->start + || length > (size_t)(info->end - address) || address < info->start) { + for (size_t i = 0; i < length; i += 4) { + uint32_t word = core->busRead32(core, address + (uint32_t)i); + size_t chunk = length - i < 4 ? length - i : 4; + memcpy(destination + i, &word, chunk); + } + return 1; + } + size_t blockSize = 0; + void* block = mCoreGetMemoryBlock(core, info->start, &blockSize); + if (!block || blockSize < info->size) { + return 0; + } + memcpy(destination, (uint8_t*)block + (address - info->start), length); + return 1; +} + +void pkforg_core_game_title(intptr_t coreHandle, char* title) { + struct mCore* core = asCore(coreHandle); + if (!core || !title) { + if (title) title[0] = '\0'; + return; + } + core->getGameTitle(core, title); + title[16] = '\0'; +} + +void pkforg_core_game_code(intptr_t coreHandle, char* code) { + struct mCore* core = asCore(coreHandle); + if (!core || !code) { + if (code) code[0] = '\0'; + return; + } + core->getGameCode(core, code); + code[4] = '\0'; +} + +int pkforg_core_save_state(intptr_t coreHandle, const char* path) { + struct mCore* core = asCore(coreHandle); + if (!core || !path) { + return 0; + } + struct VFile* vf = VFileOpen(path, O_WRONLY | O_CREAT | O_TRUNC); + if (!vf) { + return 0; + } + bool ok = mCoreSaveStateNamed(core, vf, PKFORGE_STATE_FLAGS); + vf->close(vf); + return ok ? 1 : 0; +} + +int pkforg_core_load_state(intptr_t coreHandle, const char* path) { + struct mCore* core = asCore(coreHandle); + if (!core || !path) { + return 0; + } + struct VFile* vf = VFileOpen(path, O_RDONLY); + if (!vf) { + return 0; + } + bool ok = mCoreLoadStateNamed(core, vf, PKFORGE_STATE_FLAGS); + vf->close(vf); + return ok ? 1 : 0; +} + +int pkforg_core_read_audio(intptr_t coreHandle, int16_t* destination, int samplesPerChannel) { + struct mCore* core = asCore(coreHandle); + if (!core || !destination || samplesPerChannel <= 0) { + return 0; + } + struct blip_t* left = core->getAudioChannel(core, 0); + struct blip_t* right = core->getAudioChannel(core, 1); + if (!left || !right) { + return 0; + } + int avail = blip_samples_avail(left); + if (avail <= 0) { + return 0; + } + int count = avail < samplesPerChannel ? avail : samplesPerChannel; + /* Interleave L,R into the destination (AudioTrack's stereo frame layout). */ + for (int i = 0; i < count; i++) { + int16_t l = 0; + int16_t r = 0; + blip_read_samples(left, &l, 1, false); + blip_read_samples(right, &r, 1, false); + destination[i * 2] = l; + destination[i * 2 + 1] = r; + } + return count; +}