Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
2866511
feat: emulator tab, ROM linking and launcher, RetroAchievements sign-…
mistral-vibe Oct 24, 2026
0f99547
feat: homemade trophies from save scans (museum-style)
mistral-vibe Oct 24, 2026
681418a
Merge pull request #1 from Varonal/vibe/emulators-rom-launcher-ra
Varonal Oct 24, 2026
25c3a23
feat: manual trophies, trophy ladder on the second screen, automatic …
mistral-vibe Oct 24, 2026
5c03f87
fix: keep RomLinkRules pure of the App layer's SaveDescriptions
mistral-vibe Oct 24, 2026
f250564
fix: restore the GamepadRouting using in EmulatorsPage; correct the m…
mistral-vibe Oct 24, 2026
4370857
fix: the App layer's first full build — usings, nullability and API f…
mistral-vibe Oct 24, 2026
f9a019c
fix: dedupe the ViewModels using; pin nullable Android contexts
mistral-vibe Oct 24, 2026
a16b1d4
fix: build the trophy notification statement by statement
mistral-vibe Oct 24, 2026
2aa6894
feat: console bindings (Cocoon-style), ES-DE ROM library, French-firs…
mistral-vibe Oct 24, 2026
2b5cdf9
fix: pin the nullable path in the library store's atomic write
mistral-vibe Oct 24, 2026
6c7ff31
fix: qualify AppStrings.RomMissing; first-or-default fallbacks on val…
mistral-vibe Oct 24, 2026
7d953e2
i18n: French for Home — cards, footer, link/settings/misc/filter menus
mistral-vibe Oct 25, 2026
879b0d9
i18n: French for the shelf status and the cartridge menu
mistral-vibe Oct 25, 2026
a2d3eb1
i18n: French for Trophies, RetroAchievements and Restore points
mistral-vibe Oct 25, 2026
070a2d5
i18n: French for the box browser's main flows
mistral-vibe Oct 25, 2026
0bd43a8
feat: PlayHub-inspired library cards, reliable launches, folder-free …
mistral-vibe Oct 25, 2026
fac08be
fix: qualify AppLog and AppStrings.Fr; flatten the truncation converter
mistral-vibe Oct 25, 2026
766a6e5
fix: launch contracts per family; library cards show text and the who…
mistral-vibe Oct 25, 2026
fc3bbb4
fix: package-scoped launch intents; catalog-ordered library with artw…
mistral-vibe Oct 25, 2026
8af923a
i18n: French for the Bank's main actions
mistral-vibe Oct 25, 2026
3fc6b15
fix: melonDS launches its ROM; French and No-Intro ROM names match th…
mistral-vibe Oct 25, 2026
ba591b8
refactor: clean-code pass over the fork's surfaces
mistral-vibe Oct 25, 2026
5153430
fix: duplicate folders variable; two real bugs the test run surfaced
mistral-vibe Oct 25, 2026
1b152c1
fix: the three launch contracts, from each emulator's verified config…
mistral-vibe Oct 25, 2026
2332da7
fix: the launch grants, the overlapping cards, the 3DS folders
mistral-vibe Oct 25, 2026
d268c40
docs: README for the fork — French-first, roadmap, feature map
mistral-vibe Oct 25, 2026
69cab5a
i18n: French for the box browser's tools and menus (second tranche)
mistral-vibe Oct 25, 2026
c898fe6
feat: the in-game companion — the roadmap's first 'in progress' item
mistral-vibe Oct 25, 2026
a2930d6
fix: switch-expression arms cannot be ternaries — when clauses instead
mistral-vibe Oct 25, 2026
891edad
fix: three leftovers the App build surfaced
mistral-vibe Oct 25, 2026
a50b09d
fix: narrow the companion's null state at the Show call site
mistral-vibe Oct 25, 2026
96b94cf
fix: RetroArch to the exact libretro CLI pair (ROM + LIBRETRO)
mistral-vibe Oct 25, 2026
9576caf
feat: option A — the embedded core foundation (PokeDaisy model)
mistral-vibe Oct 26, 2026
752b5bb
ci: build libpkforge_mgba.so on every push + harden the option-A files
mistral-vibe Oct 29, 2026
fc48e69
audit: fix build breaks, buffer overflows, RA parsing and SemVer compare
mistral-vibe Oct 29, 2026
7cf44a1
Merge pull request #2 from Varonal/vibe/audit-fixes-8ddc5c
Varonal Oct 29, 2026
c6514a6
Merge remote-tracking branch 'origin/vibe/emulators-rom-launcher-ra' …
mistral-vibe Oct 29, 2026
ce5282f
merge: the launcher branch's full history + the trophies one-column fix
mistral-vibe Oct 29, 2026
8d213ff
feat: GB/GBC/GBA play natively in-app — the embedded engine, audio an…
mistral-vibe Oct 29, 2026
ead20f6
fix: the three build breaks — the lost Domain file, unsafe blocks, th…
mistral-vibe Oct 29, 2026
52d7a09
fix: Android build breaks in the embedded engine — AudioUsageKind, Sk…
mistral-vibe Oct 30, 2026
7e18499
fix: embedded engine Android build — verified against real compiler (…
mistral-vibe Oct 30, 2026
76c9291
feat: native GB/GBC/GBA play + live PokeDaisy companion + trophies co…
mistral-vibe Oct 30, 2026
c64e0a5
Merge origin/main into vibe/embedded-mgba-core (audit fixes kept)
mistral-vibe Oct 30, 2026
db378b5
Merge origin/main into vibe/embedded-mgba-core (audit fixes kept)
mistral-vibe Oct 30, 2026
2bfbc68
feat: GB/GBC/GBA launch only in-app — external emulator bindings remo…
mistral-vibe Oct 30, 2026
6ea20ea
fix: hard-block external emulator binding for in-app consoles (GB/GBC…
mistral-vibe Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/build-mgba.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ coverage/
tools/**/out/
tools/**/bin/
tools/**/obj/
tools/**/build/
/pkhex/
/PKSafe/
src/PKForge.App/Resources/Raw/sgdb.key
Expand Down
310 changes: 97 additions & 213 deletions README.md

Large diffs are not rendered by default.

Binary file not shown.
26 changes: 26 additions & 0 deletions src/PKForge.App/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<App>().UseSkiaSharp()
.ConfigureFonts(fonts =>
Expand Down Expand Up @@ -87,6 +89,23 @@ public static MauiApp CreateMauiApp()
builder.Services.AddSingleton<SpritePackDownloader>();
builder.Services.AddSingleton<AppUpdateService>();
builder.Services.AddSingleton<TransferService>();
builder.Services.AddSingleton<IRomLinkStore>(new Infrastructure.JsonRomLinkStore(
Path.Combine(FileSystem.AppDataDirectory, "rom-links.json")));
builder.Services.AddSingleton<IRetroProgressStore>(new Infrastructure.JsonRetroProgressStore(
Path.Combine(FileSystem.AppDataDirectory, "retro-progress.json")));
builder.Services.AddSingleton<IRetroCredentialStore, Services.SecureRetroCredentialStore>();
builder.Services.AddSingleton<IRetroAchievementsClient, Services.HttpRetroAchievementsClient>();
builder.Services.AddSingleton<ViewModels.RetroAchievementsViewModel>();
builder.Services.AddSingleton<ITrophyStore>(new Infrastructure.JsonTrophyStore(
Path.Combine(FileSystem.AppDataDirectory, "trophies.json")));
builder.Services.AddSingleton<Services.TrophyScanService>();
builder.Services.AddSingleton<Services.GameCompanionStore>();
builder.Services.AddSingleton<Domain.IGameCompanionStore>(sp => sp.GetRequiredService<Services.GameCompanionStore>());
builder.Services.AddSingleton<Services.TrophyWatcherService>();
builder.Services.AddSingleton<IConsoleEmulatorStore>(new Infrastructure.JsonConsoleEmulatorStore(
Path.Combine(FileSystem.AppDataDirectory, "console-emulators.json")));
builder.Services.AddSingleton<IRomLibraryStore>(new Infrastructure.JsonRomLibraryStore(
Path.Combine(FileSystem.AppDataDirectory, "rom-library.json")));
#if ANDROID
builder.Services.AddSingleton<ISaveFileAccess, AndroidSafFileAccess>();
builder.Services.AddSingleton<Platforms.Android.MusicPlayer>();
Expand All @@ -96,6 +115,9 @@ public static MauiApp CreateMauiApp()
builder.Services.AddSingleton<IFolderPicker, AndroidFolderPicker>();
builder.Services.AddSingleton<IFolderFileAccess, AndroidFolderFileAccess>();
builder.Services.AddSingleton<IEmulatorDetectionService, AndroidEmulatorScanner>();
builder.Services.AddSingleton<IEmulatorLauncher, AndroidEmulatorLauncher>();
builder.Services.AddSingleton<IRomScanner, AndroidRomScanner>();
builder.Services.AddSingleton<Services.ITrophyNotifier, AndroidTrophyNotifier>();
#endif
builder.Services.AddSingleton<IWatchedRootStore, PreferencesWatchedRootStore>();
builder.Services.AddSingleton<BoxBrowserViewModel>();
Expand All @@ -107,6 +129,10 @@ public static MauiApp CreateMauiApp()
builder.Services.AddTransient<HomePage>();
builder.Services.AddTransient<SecondScreenBoxPage>();
builder.Services.AddTransient<BankPage>();
builder.Services.AddTransient<EmulatorsPage>();
builder.Services.AddTransient<RetroAchievementsPage>();
builder.Services.AddTransient<TrophiesPage>();
builder.Services.AddTransient<RomLibraryPage>();
App.Trace("builder.Build()");
return builder.Build();
}
Expand Down
7 changes: 7 additions & 0 deletions src/PKForge.App/PKForge.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
<ApplicationVersion>311</ApplicationVersion>
<ApplicationDisplayVersion>3.1.1</ApplicationDisplayVersion>
<RuntimeIdentifiers>android-arm64</RuntimeIdentifiers>
<!-- The LibraryImport source generator (MgbaNative) emits unsafe interop stubs. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<PropertyGroup Condition="'$(DiagnosticBuild)' == 'true'">
<!-- Diagnostic builds install beside production and cannot overwrite its app-private data. -->
Expand Down Expand Up @@ -82,6 +84,11 @@
<MauiAsset Include="Resources/UI/pksm/*.png" LogicalName="ui/pksm/%(Filename)%(Extension)" />
<!-- The designer's icons for the new art direction (summary tabs, footer buttons). -->
<MauiAsset Include="Resources/UI/design/*.png" LogicalName="ui/design/%(Filename)%(Extension)" />
<!-- The embedded mGBA core shim (built by tools/build-mgba/build.sh). Optional:
the app runs without it; the Play screen falls back to the external
emulators when the library is absent. -->
<AndroidNativeLibrary Include="Libraries/arm64-v8a/libpkforge_mgba.so" Condition="Exists('Libraries/arm64-v8a/libpkforge_mgba.so')" />

<!-- The Poképark: the artist's map at native size, its ground, objects and their depth. -->
<MauiAsset Include="Resources/Pokepark/park/*.png" LogicalName="pokepark/park/%(Filename)%(Extension)" />
</ItemGroup>
Expand Down
225 changes: 225 additions & 0 deletions src/PKForge.App/Platforms/Android/AndroidEmulatorLauncher.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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<bool> 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<bool> 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);
}

/// <summary>
/// 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).
/// </summary>
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;
}
}

/// <summary>The package-scoped intent per launch style. No explicit components:
/// the emulator's own manifest decides which activity answers.</summary>
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,
};

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>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.</summary>
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;
}

/// <summary>SAF ids are file://-shaped on primary storage; emulators only accept
/// plain paths, so the id is handed over as-is.</summary>
private static string RomPathFor(RomLink link) =>
link.RomUri.StartsWith("file://", StringComparison.Ordinal)
? link.RomUri["file://".Length..]
: link.RomUri;

/// <summary>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.</summary>
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}";
}

/// <summary>Starts the intent; false when no activity accepted it or the ROM grant failed.</summary>
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;
}
}
Loading