From 49c09aaeb2dbd109ebcf4413188e4de636333aa8 Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Thu, 6 Aug 2026 23:17:50 -0400 Subject: [PATCH 1/9] fix(native): harden libretro host threading, teardown, and option lifetime across platforms Includes an input-latch fix: draining pending and OR-ing the level cannot erase a bit that is still held. --- .../Classes/GameInputController.swift | 50 +- ios/game_host/Classes/GameSession.swift | 62 +- linux/runner/native_game.cc | 117 ++- .../Classes/GameInputController.swift | 50 +- macos/game_host/Classes/GameSession.swift | 62 +- native/libretro_host/libretro_host.c | 911 +++++++++++++++--- native/libretro_host/libretro_host.h | 92 +- native/libretro_host/test/stub_core.c | 215 ++++- native/libretro_host/test/test_harness.c | 683 ++++++++++++- windows/runner/flutter_window.cpp | 3 +- windows/runner/native_game.cpp | 165 +++- windows/runner/native_game.h | 54 +- 12 files changed, 2230 insertions(+), 234 deletions(-) diff --git a/ios/game_host/Classes/GameInputController.swift b/ios/game_host/Classes/GameInputController.swift index 97615007f..1ed28f6ba 100644 --- a/ios/game_host/Classes/GameInputController.swift +++ b/ios/game_host/Classes/GameInputController.swift @@ -1,9 +1,12 @@ import Foundation import GameController +import libretro_host -// Maps connected controllers to per-port RetroPad bitmasks, read by the host on -// its run loop. Button changes on port 0 also mirror to Flutter so the in-game -// overlay can be driven with the same controller, and the Menu button opens it. +// Maps connected controllers to per-port RetroPad bitmasks and reports every +// change to the host's input latch (lh_set_input) as it happens, so a press +// shorter than one core frame still lands. Button changes on port 0 also +// mirror to Flutter so the in-game overlay can be driven with the same +// controller, and the Menu button opens it. final class GameInputController { private enum Pad { static let b: UInt16 = 1 << 0 @@ -34,6 +37,14 @@ final class GameInputController { // RetroPad mask supplied from Dart (keyboard play and the deferred Start). private var dartMask: UInt16 = 0 + // Set by attach(host:) once GameSession has created the C host, cleared by + // stop(). Every mutation below reports its combined mask to the host's + // input latch immediately, rather than the host reading an instantaneous + // level whenever the core happens to poll - the level-sampling approach + // dropped presses shorter than one core frame. See lh_set_input's comment + // in libretro_host.h. + private var host: OpaquePointer? + var onButton: ((Int, Bool) -> Void)? var onControllersChanged: ((Int) -> Void)? @@ -76,12 +87,27 @@ final class GameInputController { maskLock.lock() pulseMask = 0 portMasks = [0, 0, 0, 0] + pushAllLocked() + host = nil + maskLock.unlock() + } + + // Called once, right after GameSession creates the C host and before + // input.start() can produce any events, so every push below always has + // somewhere to land. + func attach(host: OpaquePointer?) { + maskLock.lock() + self.host = host maskLock.unlock() } func mask(forPort port: Int) -> UInt16 { maskLock.lock() defer { maskLock.unlock() } + return maskLocked(forPort: port) + } + + private func maskLocked(forPort port: Int) -> UInt16 { guard port >= 0, port < portMasks.count else { return 0 } guard port == 0 else { return portMasks[port] } // Start is withheld from the physical pad because Dart owns it: a quick @@ -90,9 +116,23 @@ final class GameInputController { return (portMasks[0] & ~Pad.start) | pulseMask | dartMask } + // Reports port's combined mask to the host's input latch. Must be called + // with maskLock already held, so the read of portMasks/pulseMask/dartMask + // and the read of host that produce the pushed value are from one + // consistent snapshot. + private func pushLocked(_ port: Int) { + guard let host, port >= 0, port < portMasks.count else { return } + lh_set_input(host, Int32(port), maskLocked(forPort: port)) + } + + private func pushAllLocked() { + for port in 0..(_ chars: T) -> String { + withUnsafeBytes(of: chars) { raw in + guard let base = raw.baseAddress else { return "" } + return String(cString: base.assumingMemoryBound(to: CChar.self)) + } + } + func options() -> [[String: Any]] { guard let host else { return [] } var result: [[String: Any]] = [] for i in 0.. #include +#include #include #include @@ -14,13 +15,30 @@ namespace { lh_host* g_host = nullptr; FlTextureRegistrar* g_textures = nullptr; FlTexture* g_texture = nullptr; +FlEventChannel* g_events = nullptr; std::atomic g_mask{0}; std::atomic g_pulse{0}; -uint16_t OnPollInput(void* user, int port) { - (void)user; - if (port != 0) return 0; - return static_cast(g_mask.load() | g_pulse.load()); +// Guards g_host against the race between moonfin_game_texture_copy_pixels +// (called by Flutter on its render thread) and Teardown() destroying the +// host (called on the main loop thread from HandleMethod). Unregistering the +// texture is not a barrier against a copy_pixels call already in flight, so +// the pointer itself has to be protected, not just the texture registration +// - otherwise the render thread can read g_host and call into lh_get_frame +// after Teardown() has freed it. Held only across the pointer check plus +// lh_get_frame's front/back pointer swap (not the pixel copy Flutter +// performs afterwards with the returned buffer), and across lh_destroy in +// Teardown(), so it adds negligible per-frame contention. +std::mutex g_host_mutex; + +// Pushes the combined Dart + pulse mask into the host's input latch. Called +// on every write to either half, not once per frame: the host OR-latches +// whatever it's told between polls, so it needs every edge, not a level +// sampled only when the core happens to poll (see lh_set_input's comment). +void PushInput() { + if (g_host) { + lh_set_input(g_host, 0, static_cast(g_mask.load() | g_pulse.load())); + } } int OnControllerCount(void* user) { @@ -35,6 +53,39 @@ void OnFrameReady(void* user) { } } +// Heap-allocated so it survives the hop from the emulation thread to the +// main-loop idle callback below. +struct FatalErrorPayload { + gchar* message; +}; + +// Runs on the main loop thread. g_events is only ever written on this +// thread, so reading it here (unlike from OnFatalError) needs no lock. +gboolean DeliverFatalError(gpointer data) { + auto* payload = static_cast(data); + if (g_events) { + g_autoptr(FlValue) event = fl_value_new_map(); + fl_value_set_string_take(event, "event", fl_value_new_string("error")); + fl_value_set_string_take(event, "message", + fl_value_new_string(payload->message)); + g_autoptr(GError) error = nullptr; + fl_event_channel_send(g_events, event, nullptr, &error); + } + g_free(payload->message); + delete payload; + return G_SOURCE_REMOVE; +} + +// The emulation thread is dying from an unrecoverable error (e.g. a failed +// core restart). Called from the run-loop thread. The Flutter/GLib embedder +// requires event-channel access on the main loop thread, so the send is +// marshaled via g_idle_add instead of happening here. +void OnFatalError(void* user, const char* message) { + (void)user; + auto* payload = new FatalErrorPayload{g_strdup(message ? message : "")}; + g_idle_add(DeliverFatalError, payload); +} + // Nothing listens on the desktop event channel, so a core that complains or // quits says so in the log instead. void OnCoreMessage(void* user, const char* text) { @@ -65,6 +116,22 @@ static gboolean moonfin_game_texture_copy_pixels( uint32_t* height, GError** error) { (void)texture; (void)error; + // Runs on Flutter's render thread. Pairs with the lock in Teardown(): + // guarantees g_host is either the live pointer Teardown() hasn't started + // tearing down yet, or nullptr after Teardown() has fully destroyed it - + // never a pointer lh_destroy is mid-free on. lh_get_frame only swaps the + // front/back frame pointers under its own internal lock, so holding + // g_host_mutex across the call does not serialize the actual per-frame + // pixel copy, which Flutter performs after this function returns. + // + // KNOWN REMAINING HOLE: the buffer handed back below points directly at + // host-owned memory (lh_get_frame returns h->front.data), and Flutter reads + // it after this function returns, i.e. after the lock is released. A + // Teardown() landing in that window frees the framebuffer while the render + // thread is still reading it. Narrower than the dangling-g_host race this + // lock fixes, but the same class. Closing it needs a staging copy taken + // under the lock. + std::lock_guard lock(g_host_mutex); const void* data; int w, h, stride; if (!g_host || !lh_get_frame(g_host, &data, &w, &h, &stride)) return FALSE; @@ -99,16 +166,27 @@ int LookupInt(FlValue* args, const char* key, int fallback) { void Teardown() { lh_audio_stop(); - if (g_host) { - lh_stop(g_host); - lh_destroy(g_host); - g_host = nullptr; + { + // Pairs with the lock in moonfin_game_texture_copy_pixels: while this + // block runs, the render thread either already finished reading g_host + // before we got here, or blocks on g_host_mutex until we're done and + // then observes g_host == nullptr - it can never see a pointer + // lh_destroy is mid-free on. lh_stop joins the host's own worker thread, + // not the render or main loop thread, so holding the lock across it + // cannot deadlock. + std::lock_guard lock(g_host_mutex); + if (g_host) { + lh_stop(g_host); + lh_destroy(g_host); + g_host = nullptr; + } } if (g_textures && g_texture) { fl_texture_registrar_unregister_texture(g_textures, g_texture); g_clear_object(&g_texture); } g_mask = 0; + g_pulse = 0; } FlValue* Load(FlValue* args) { @@ -132,12 +210,16 @@ FlValue* Load(FlValue* args) { lh_callbacks cb = {}; cb.frame_ready = OnFrameReady; - cb.poll_input = OnPollInput; cb.controller_count = OnControllerCount; + cb.fatal_error = OnFatalError; cb.message = OnCoreMessage; cb.core_shutdown = OnCoreShutdown; g_host = lh_create(LH_FORMAT_RGBA8888, cb); + if (!g_host) { + // calloc failure inside lh_create; nothing to load into. + return fl_value_new_null(); + } lh_av_info info = {}; int rc = lh_load(g_host, core_path, rom_path, system_dir, save_dir, game_id, keys.data(), values.data(), static_cast(keys.size()), @@ -170,7 +252,10 @@ FlValue* Options(bool current_only) { int count = lh_option_count(g_host); for (int i = 0; i < count; i++) { lh_option opt; - if (lh_get_option(g_host, i, &opt) != 0) continue; + // A restart on the emulation thread can shrink the list between the count + // and this read, so a failure means "no more options", not "skip this one". + // opt is a self-contained copy; nothing below borrows from the host. + if (lh_get_option(g_host, i, &opt) != 0) break; if (current_only) { fl_value_set_string_take(result, opt.id, fl_value_new_string(opt.current)); continue; @@ -215,8 +300,12 @@ void HandleMethod(FlMethodChannel* channel, FlMethodCall* call, if (g_host) lh_resume(g_host); response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); } else if (g_strcmp0(method, "restart") == 0) { - if (g_host) lh_reset(g_host); - response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + if (g_host && lh_restart_async(g_host) == 0) { + response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } else { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "restart_unavailable", "The emulator is not running.", nullptr)); + } } else if (g_strcmp0(method, "stop") == 0) { Teardown(); response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); @@ -225,6 +314,7 @@ void HandleMethod(FlMethodChannel* channel, FlMethodCall* call, response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); } else if (g_strcmp0(method, "setInput") == 0) { g_mask = static_cast(LookupInt(args, "mask", 0)); + PushInput(); response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); } else if (g_strcmp0(method, "pulseButton") == 0) { int index = LookupInt(args, "index", -1); @@ -232,9 +322,11 @@ void HandleMethod(FlMethodChannel* channel, FlMethodCall* call, if (index >= 0 && index < 16) { uint16_t bit = static_cast(1 << index); g_pulse |= bit; + PushInput(); std::thread([bit, duration]() { std::this_thread::sleep_for(std::chrono::milliseconds(duration)); g_pulse &= static_cast(~bit); + PushInput(); }).detach(); } response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); @@ -310,4 +402,5 @@ void moonfin_game_register(FlEngine* engine) { messenger, "moonfin/native_game_events", FL_METHOD_CODEC(codec)); fl_event_channel_set_stream_handlers(events, OnListen, OnCancel, nullptr, nullptr); + g_events = events; } diff --git a/macos/game_host/Classes/GameInputController.swift b/macos/game_host/Classes/GameInputController.swift index 97615007f..1ed28f6ba 100644 --- a/macos/game_host/Classes/GameInputController.swift +++ b/macos/game_host/Classes/GameInputController.swift @@ -1,9 +1,12 @@ import Foundation import GameController +import libretro_host -// Maps connected controllers to per-port RetroPad bitmasks, read by the host on -// its run loop. Button changes on port 0 also mirror to Flutter so the in-game -// overlay can be driven with the same controller, and the Menu button opens it. +// Maps connected controllers to per-port RetroPad bitmasks and reports every +// change to the host's input latch (lh_set_input) as it happens, so a press +// shorter than one core frame still lands. Button changes on port 0 also +// mirror to Flutter so the in-game overlay can be driven with the same +// controller, and the Menu button opens it. final class GameInputController { private enum Pad { static let b: UInt16 = 1 << 0 @@ -34,6 +37,14 @@ final class GameInputController { // RetroPad mask supplied from Dart (keyboard play and the deferred Start). private var dartMask: UInt16 = 0 + // Set by attach(host:) once GameSession has created the C host, cleared by + // stop(). Every mutation below reports its combined mask to the host's + // input latch immediately, rather than the host reading an instantaneous + // level whenever the core happens to poll - the level-sampling approach + // dropped presses shorter than one core frame. See lh_set_input's comment + // in libretro_host.h. + private var host: OpaquePointer? + var onButton: ((Int, Bool) -> Void)? var onControllersChanged: ((Int) -> Void)? @@ -76,12 +87,27 @@ final class GameInputController { maskLock.lock() pulseMask = 0 portMasks = [0, 0, 0, 0] + pushAllLocked() + host = nil + maskLock.unlock() + } + + // Called once, right after GameSession creates the C host and before + // input.start() can produce any events, so every push below always has + // somewhere to land. + func attach(host: OpaquePointer?) { + maskLock.lock() + self.host = host maskLock.unlock() } func mask(forPort port: Int) -> UInt16 { maskLock.lock() defer { maskLock.unlock() } + return maskLocked(forPort: port) + } + + private func maskLocked(forPort port: Int) -> UInt16 { guard port >= 0, port < portMasks.count else { return 0 } guard port == 0 else { return portMasks[port] } // Start is withheld from the physical pad because Dart owns it: a quick @@ -90,9 +116,23 @@ final class GameInputController { return (portMasks[0] & ~Pad.start) | pulseMask | dartMask } + // Reports port's combined mask to the host's input latch. Must be called + // with maskLock already held, so the read of portMasks/pulseMask/dartMask + // and the read of host that produce the pushed value are from one + // consistent snapshot. + private func pushLocked(_ port: Int) { + guard let host, port >= 0, port < portMasks.count else { return } + lh_set_input(host, Int32(port), maskLocked(forPort: port)) + } + + private func pushAllLocked() { + for port in 0..(_ chars: T) -> String { + withUnsafeBytes(of: chars) { raw in + guard let base = raw.baseAddress else { return "" } + return String(cString: base.assumingMemoryBound(to: CChar.self)) + } + } + func options() -> [[String: Any]] { guard let host else { return [] } var result: [[String: Any]] = [] for i in 0.. #include #include +#include #include #include #include @@ -47,7 +48,12 @@ static uint64_t now_ns(void) { LARGE_INTEGER f, t; QueryPerformanceFrequency(&f); QueryPerformanceCounter(&t); - return (uint64_t)(t.QuadPart * 1000000000ull / f.QuadPart); + // t.QuadPart * 1e9 overflows a 64-bit value at ~1845s of uptime with a + // 10 MHz QPC. Split into whole seconds and the sub-second remainder before + // scaling so neither term can overflow. + uint64_t whole = (uint64_t)t.QuadPart / (uint64_t)f.QuadPart; + uint64_t frac = (uint64_t)t.QuadPart % (uint64_t)f.QuadPart; + return whole * 1000000000ull + (frac * 1000000000ull) / (uint64_t)f.QuadPart; } static void sleep_ns(uint64_t ns) { Sleep((DWORD)(ns / 1000000ull)); } static char *lh_strdup(const char *s) { return _strdup(s); } @@ -85,6 +91,22 @@ static void sleep_ns(uint64_t ns) { static char *lh_strdup(const char *s) { return strdup(s); } #endif +// Truncating copy into a fixed buffer. Always NUL-terminates and tolerates a +// NULL source, so the option snapshot below never leaves a caller with an +// unterminated buffer to read past. strncpy is deliberately avoided: it does +// not terminate on truncation, which is exactly the case that matters here. +static void lh_copy_bounded(char *dst, size_t cap, const char *src) { + if (!dst || cap == 0) return; + if (!src) { + dst[0] = '\0'; + return; + } + size_t n = strlen(src); + if (n >= cap) n = cap - 1; + memcpy(dst, src, n); + dst[n] = '\0'; +} + // --------------------------------------------------------------------------- // Core entry points, resolved from the loaded library. // --------------------------------------------------------------------------- @@ -164,6 +186,7 @@ typedef struct { typedef enum { JOB_RESET, + JOB_RESTART, JOB_SERIALIZE_SIZE, JOB_SERIALIZE, JOB_UNSERIALIZE, @@ -181,6 +204,22 @@ typedef struct { #define LH_MAX_JOBS 16 +// Largest per-side frame dimension the host will accept, either from +// notify_geometry (SET_SYSTEM_AV_INFO / SET_GEOMETRY) or from a video_refresh +// callback. Both ultimately come from the loaded core, which is not a trusted +// boundary: a buggy or malicious core can report whatever it likes. The real +// hazard is convert_frame's size_t multiply (out_width * out_height * 4). On +// a 32-bit size_t (armeabi-v7a is still shipped, see build.gradle) a large +// enough width/height pair wraps that multiply to a small number, so the +// allocation is undersized while the pixel loop below still walks the full, +// un-wrapped width*height - a heap overflow. 8192 per side is far beyond any +// real libretro core's output (the largest arcade/console framebuffers top +// out in the low thousands) and small enough that width*height*4 cannot +// overflow even a 32-bit size_t (8192*8192*4 < 2^32), so rejecting anything +// larger removes the wraparound case entirely rather than merely computing +// around it. +#define LH_MAX_FRAME_DIMENSION 8192 + struct lh_host { lh_output_format format; lh_callbacks cb; @@ -193,6 +232,8 @@ struct lh_host { char *system_dir; char *save_dir; char *sram_path; + char *core_path; + char *rom_path; unsigned pixel_format; // Options. @@ -200,7 +241,20 @@ struct lh_host { int def_count; lh_var *vars; int var_count; + // Option values the core may still be holding after they were replaced. + // See vars_retire: replaced value strings are parked here for the life of + // the load instead of being freed, and the whole list is released at + // session teardown. + char **retired_values; + int retired_count; int variables_dirty; + // Set when parse_variable fails to allocate while handling a + // SET_VARIABLES environment call during retro_init/retro_load_game. + // Checked once open_core/load_content return, so a core that hit an + // allocation failure mid-init still fails the load instead of running with + // silently incomplete option definitions. Reset before each such call (see + // open_core). + int alloc_failed; lh_mutex vars_lock; // Video. @@ -224,6 +278,8 @@ struct lh_host { atomic_int running; atomic_int paused; atomic_int fast_forward; + atomic_int restart_requested; + atomic_uint restart_generation; atomic_int shutdown_requested; uint64_t last_sram_flush_ns; @@ -234,11 +290,26 @@ struct lh_host { int jobs_open; lh_mutex jobs_lock; lh_cond jobs_cond; + + // Input latch. Writers (any thread, any platform) call lh_set_input, which + // ORs into input_pending and replaces input_level. Once per real libretro + // poll, latch_input exchanges input_pending back down to the current level + // and hands the exchanged value to input_frame - the value the core reads + // for the rest of that frame via input_state_cb. See lh_set_input's comment + // for why this, rather than a plain instantaneous read, is required. + // input_frame is touched only from the emulation thread (inside + // input_poll_cb/input_state_cb, or the equivalent test hooks called with no + // core running), so it is a plain array, not atomic. + atomic_uint input_level[LH_MAX_PORTS]; + atomic_uint input_pending[LH_MAX_PORTS]; + uint16_t input_frame[LH_MAX_PORTS]; }; // libretro's callbacks carry no user pointer, so the single live host is global. static struct lh_host *g_session; +static int restart_core(struct lh_host *h); + // --------------------------------------------------------------------------- // Options helpers. // --------------------------------------------------------------------------- @@ -250,68 +321,233 @@ static const char *vars_get(struct lh_host *h, const char *key) { return NULL; } -static void vars_set(struct lh_host *h, const char *key, const char *value) { +// Values handed to the core through RETRO_ENVIRONMENT_GET_VARIABLE must +// outlive a concurrent lh_set_option on the platform thread: the core is given +// a raw pointer into h->vars and is entitled to hold it across frames (most +// cores keep it until the next GET_VARIABLE_UPDATE tells them to re-poll, and +// re-polling is exactly what an option change triggers). Freeing the replaced +// string in place is therefore a use-after-free on the emulation thread. Park +// it here instead and release the whole list when the session ends. +// +// Growth is bounded in practice: one entry per option change, and option +// changes are a human action in the pause menu, so a session accumulates a +// handful of short strings at most. This is a per-load arena, not a leak - +// vars_free_retired drains it at every teardown and restart. +// +// Called with vars_lock held. +static void vars_retire(struct lh_host *h, char *old_value) { + if (!old_value) return; + char **grown = realloc(h->retired_values, + sizeof(char *) * (size_t)(h->retired_count + 1)); + if (!grown) { + // Losing the pointer leaks one string for the session; freeing it could + // crash the core that is still reading it. Leak deliberately. + h->alloc_failed = 1; + return; + } + h->retired_values = grown; + h->retired_values[h->retired_count++] = old_value; +} + +// Releases every retired value. Only safe once the core is torn down (or was +// never loaded), because that is the point at which no core-held pointer can +// still be dereferenced. Idempotent: it resets the list so a second call from +// a later teardown path is a no-op. +static void vars_free_retired(struct lh_host *h) { + for (int i = 0; i < h->retired_count; i++) free(h->retired_values[i]); + free(h->retired_values); + h->retired_values = NULL; + h->retired_count = 0; +} + +// Sets h->vars[key] = value, adding a new entry if key is unseen. Returns 0 on +// success, -1 if an allocation failed. The realloc result always lands in a +// temporary first: assigning straight into h->vars, as the old code did, +// loses the original block on failure (realloc leaves it untouched and +// returns NULL) and leaves h->vars NULL while var_count still claims entries +// exist - the next vars_get/vars_set dereferences that NULL. Every new-entry +// path below is fully populated or fully abandoned before var_count moves, so +// a failure here never publishes a half-built slot with a NULL key or value. +static int vars_set(struct lh_host *h, const char *key, const char *value) { for (int i = 0; i < h->var_count; i++) { if (strcmp(h->vars[i].key, key) == 0) { - free(h->vars[i].value); - h->vars[i].value = lh_strdup(value); - return; + char *new_value = lh_strdup(value); + if (!new_value) return -1; + vars_retire(h, h->vars[i].value); + h->vars[i].value = new_value; + return 0; } } - h->vars = realloc(h->vars, sizeof(lh_var) * (h->var_count + 1)); - h->vars[h->var_count].key = lh_strdup(key); - h->vars[h->var_count].value = lh_strdup(value); + lh_var *grown = realloc(h->vars, sizeof(lh_var) * (h->var_count + 1)); + if (!grown) return -1; + h->vars = grown; + char *key_copy = lh_strdup(key); + char *value_copy = lh_strdup(value); + if (!key_copy || !value_copy) { + free(key_copy); + free(value_copy); + return -1; + } + h->vars[h->var_count].key = key_copy; + h->vars[h->var_count].value = value_copy; h->var_count++; + return 0; +} + +static void free_option_definitions(struct lh_host *h) { + for (int i = 0; i < h->def_count; i++) { + free(h->defs[i].id); + free(h->defs[i].label); + for (int c = 0; c < h->defs[i].choice_count; c++) { + free(h->defs[i].choices[c]); + } + free(h->defs[i].choices); + } + free(h->defs); + h->defs = NULL; + h->def_count = 0; } -// Parses one SET_VARIABLES entry into its label and choice list. -static void parse_variable(struct lh_host *h, const char *key, - const char *raw) { +// Frees a not-yet-published choice list/label pair, for the allocation +// failure paths below where h->defs was never touched. +static void free_pending_choices(char *label, char **choices, int nchoices) { + free(label); + for (int c = 0; c < nchoices; c++) free(choices[c]); + free(choices); +} + +// Parses one SET_VARIABLES entry into its label and choice list. Returns 0 on +// success, -1 if an allocation failed anywhere along the way. Every realloc +// result lands in a temporary before it replaces choices/h->defs, so a +// failure never drops the last-good block (the bug the old code had: a failed +// realloc returns NULL, and assigning that straight back into choices/h->defs +// leaks the previous allocation and leaves a NULL the next line immediately +// writes through). On any failure, everything allocated for *this* entry is +// freed and nothing partial is published into h->defs, so the definitions +// table is left exactly as it was before this call - the caller only has to +// decide whether to abandon the load, not to unwind partial state here. +static int parse_variable(struct lh_host *h, const char *key, + const char *raw) { const char *sep = strstr(raw, "; "); - char *label; + char *label = NULL; char **choices = NULL; int nchoices = 0; if (sep) { size_t label_len = (size_t)(sep - raw); label = malloc(label_len + 1); + if (!label) return -1; memcpy(label, raw, label_len); label[label_len] = '\0'; const char *list = sep + 2; char *copy = lh_strdup(list); + if (!copy) { + free_pending_choices(label, choices, nchoices); + return -1; + } char *tok = strtok(copy, "|"); while (tok) { - choices = realloc(choices, sizeof(char *) * (nchoices + 1)); - choices[nchoices++] = lh_strdup(tok); + char **grown = realloc(choices, sizeof(char *) * (nchoices + 1)); + if (!grown) { + free(copy); + free_pending_choices(label, choices, nchoices); + return -1; + } + choices = grown; + char *choice_copy = lh_strdup(tok); + if (!choice_copy) { + free(copy); + free_pending_choices(label, choices, nchoices); + return -1; + } + choices[nchoices++] = choice_copy; tok = strtok(NULL, "|"); } free(copy); } else { label = lh_strdup(key); + if (!label) return -1; } - h->defs = realloc(h->defs, sizeof(lh_optdef) * (h->def_count + 1)); - h->defs[h->def_count].id = lh_strdup(key); + lh_optdef *grown_defs = + realloc(h->defs, sizeof(lh_optdef) * (h->def_count + 1)); + if (!grown_defs) { + free_pending_choices(label, choices, nchoices); + return -1; + } + h->defs = grown_defs; + char *id_copy = lh_strdup(key); + if (!id_copy) { + free_pending_choices(label, choices, nchoices); + return -1; + } + h->defs[h->def_count].id = id_copy; h->defs[h->def_count].label = label; h->defs[h->def_count].choices = choices; h->defs[h->def_count].choice_count = nchoices; h->def_count++; if (vars_get(h, key) == NULL && nchoices > 0) { - vars_set(h, key, choices[0]); + // The definition is already published above by this point, so a failure + // here only means the option keeps its core-supplied fallback instead of + // an explicit default - it does not need to unwind def_count. + if (vars_set(h, key, choices[0]) != 0) return -1; } + return 0; } // --------------------------------------------------------------------------- // Environment callback (mirrors the tvOS GameSession switch). // --------------------------------------------------------------------------- +// Routes a formatted diagnostic to the platform's log sink, if it supplied one. +static void host_log(struct lh_host *h, const char *fmt, ...) { + if (!h->cb.message) return; + char buf[256]; + va_list args; + va_start(args, fmt); + vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + h->cb.message(h->cb.user, buf); +} + +// The host bakes SET_ROTATION into the converted frame, so the geometry it +// advertises has to describe the rotated frame, not the core's raw buffer. +// Quarter turns swap the axes. +// +// The aspect ratio is deliberately left alone whenever the core supplied one: +// a core that asks for rotation reports the aspect of the *final* display, not +// of its own unrotated buffer. Verified against FBNeo, which pairs a landscape +// 512x480 buffer with a 0.75 portrait aspect for a vertical cabinet. Inverting +// that would double-correct and stretch the picture. Only a fallback aspect the +// host derived itself has to be recomputed from the swapped dimensions. +static void apply_rotation_to_av(struct lh_host *h, int aspect_reported) { + if (h->av.rotation == 1 || h->av.rotation == 3) { + int w = h->av.width; + h->av.width = h->av.height; + h->av.height = w; + } + if (!aspect_reported) { + h->av.aspect = + (double)h->av.width / (double)(h->av.height > 0 ? h->av.height : 1); + } +} + static void notify_geometry(struct lh_host *h, unsigned width, unsigned height, float aspect_ratio) { + // A core reporting zero or an absurd dimension here would otherwise flow + // straight into h->av (and out through geometry_changed to the platform's + // texture allocation) as well as size the next convert_frame call. Reject it + // at the source instead of trusting the core - see LH_MAX_FRAME_DIMENSION + // for why 8192 and why this matters on 32-bit size_t. + if (width == 0 || height == 0 || width > LH_MAX_FRAME_DIMENSION || + height > LH_MAX_FRAME_DIMENSION) { + host_log(h, "Rejected geometry %ux%u (out of bounds)", width, height); + return; + } h->av.width = (int)width; h->av.height = (int)height; - h->av.aspect = aspect_ratio > 0 - ? (double)aspect_ratio - : (double)width / (double)(height > 0 ? height : 1); + if (aspect_ratio > 0) h->av.aspect = (double)aspect_ratio; + apply_rotation_to_av(h, aspect_ratio > 0); if (h->cb.geometry_changed) { h->cb.geometry_changed(h->cb.user, h->av.width, h->av.height, h->av.aspect); } @@ -387,7 +623,14 @@ static bool RETRO_CALLCONV environment_cb(unsigned cmd, void *data) { if (!var->key) return false; mutex_lock(&h->vars_lock); const char *value = vars_get(h, var->key); - var->value = value; // owned by h->vars, stable until changed + // Owned by the host and readable for the life of this load. The core + // may keep this pointer across frames: a later lh_set_option on the + // platform thread swaps in a new string and retires the old one (see + // vars_retire) rather than freeing it, precisely because the core is + // still allowed to be reading it here. Only the contents can go stale, + // never the memory; a core that wants the new value re-polls after + // GET_VARIABLE_UPDATE. + var->value = value; mutex_unlock(&h->vars_lock); return value != NULL; } @@ -397,7 +640,17 @@ static bool RETRO_CALLCONV environment_cb(unsigned cmd, void *data) { (const struct retro_variable *)data; mutex_lock(&h->vars_lock); while (cursor->key && cursor->value) { - parse_variable(h, cursor->key, cursor->value); + // A core is allowed to publish dozens of options, so one allocation + // failure partway through shouldn't stop parsing the rest (later + // entries are independent and may still succeed) - but it does mean + // the option set is now incomplete, so the load has to be failed + // once open_core/load_content return control to lh_load. Keep + // parsing instead of bailing out here so h->def_count reflects + // whatever really was parsed, which matters if a caller ever + // inspects options after logging the failure. + if (parse_variable(h, cursor->key, cursor->value) != 0) { + h->alloc_failed = 1; + } cursor++; } h->variables_dirty = 1; @@ -431,6 +684,31 @@ static bool RETRO_CALLCONV environment_cb(unsigned cmd, void *data) { notify_geometry(h, geo->base_width, geo->base_height, geo->aspect_ratio); return true; } + case RETRO_ENVIRONMENT_SET_ROTATION: { + // Vertically-oriented arcade cabinets (and a few console games) ask the + // frontend to rotate. The host bakes it into convert_frame instead of + // asking every platform for a GPU transform. + if (!data) return false; + unsigned rot = *(const unsigned *)data; + if (rot > 3) return false; + int was_quarter = (h->av.rotation == 1 || h->av.rotation == 3); + int is_quarter = (rot == 1 || rot == 3); + h->av.rotation = (int)rot; + host_log(h, "SET_ROTATION rot=%u", rot); + // Geometry recorded before this request describes the other orientation. + // Only the axes move; the aspect the core reported already describes the + // final display (see apply_rotation_to_av). + if (was_quarter != is_quarter && h->av.width > 0) { + int w = h->av.width; + h->av.width = h->av.height; + h->av.height = w; + if (h->cb.geometry_changed) { + h->cb.geometry_changed(h->cb.user, h->av.width, h->av.height, + h->av.aspect); + } + } + return true; + } case RETRO_ENVIRONMENT_GET_LANGUAGE: if (data) *(unsigned *)data = RETRO_LANGUAGE_ENGLISH; return true; @@ -468,60 +746,167 @@ static void pack_pixel(struct lh_host *h, uint8_t *dst, unsigned r, unsigned g, memcpy(dst, &word, 4); } -static void convert_frame(struct lh_host *h, const void *src, int width, - int height, int pitch) { - size_t needed = (size_t)width * (size_t)height * 4; +// Decodes one source pixel of [fmt] into 8-bit r/g/b. [fmt] is constant for +// the whole frame, so this branch predicts perfectly; it is not the cost +// convert_frame's rewrite targets (see the comment below). +static void unpack_pixel(unsigned fmt, const uint8_t *p, unsigned *r, + unsigned *g, unsigned *b) { + if (fmt == RETRO_PIXEL_FORMAT_XRGB8888) { + uint32_t v; + memcpy(&v, p, 4); + *r = (v >> 16) & 0xFF; + *g = (v >> 8) & 0xFF; + *b = v & 0xFF; + } else if (fmt == RETRO_PIXEL_FORMAT_RGB565) { + uint16_t v; + memcpy(&v, p, 2); + unsigned r5 = (v >> 11) & 0x1F, g6 = (v >> 5) & 0x3F, b5 = v & 0x1F; + *r = (r5 << 3) | (r5 >> 2); + *g = (g6 << 2) | (g6 >> 4); + *b = (b5 << 3) | (b5 >> 2); + } else { // 0RGB1555 + uint16_t v; + memcpy(&v, p, 2); + unsigned r5 = (v >> 10) & 0x1F, g5 = (v >> 5) & 0x1F, b5 = v & 0x1F; + *r = (r5 << 3) | (r5 >> 2); + *g = (g5 << 3) | (g5 >> 2); + *b = (b5 << 3) | (b5 >> 2); + } +} + +static int convert_frame(struct lh_host *h, const void *src, int width, + int height, size_t pitch) { + const int bpp = h->pixel_format == RETRO_PIXEL_FORMAT_XRGB8888 ? 4 : 2; + + // `pitch` is whatever the core passed to video_refresh_cb; convert_frame + // reads `src_bytes + y*pitch + x*bpp` for every row below. A pitch smaller + // than one full row of pixels (width*bpp) would make that read walk into + // the next row's data - or, on the last scanline, off the end of the + // buffer entirely - which is an out-of-bounds read the core fully controls. + // Reject it here rather than trusting the core to report a sane pitch. + if (pitch < (size_t)width * (size_t)bpp) { + host_log(h, "Rejected frame: pitch %zu too small for %dx%d bpp %d", pitch, + width, height, bpp); + return 0; + } + + const int rotation = h->av.rotation; + const int quarter = (rotation == 1 || rotation == 3); + const int out_width = quarter ? height : width; + const int out_height = quarter ? width : height; + + // Overflow-safe size computation. notify_geometry/video_refresh_cb already + // reject any width/height over LH_MAX_FRAME_DIMENSION before a frame gets + // here, which keeps out_width*out_height*4 comfortably under 2^32 - but + // that bound lives in the callers, not in this function's own contract. + // Check by division (which cannot itself overflow) rather than by + // multiplying and inspecting the result, so a future caller that forgets to + // validate still fails safely here instead of silently wrapping size_t into + // an undersized allocation while the pixel loop below keeps writing the + // full, un-wrapped pixel count (the 32-bit armeabi-v7a heap overflow this + // guards against). + if (out_height <= 0 || out_width <= 0 || + (size_t)out_width > (SIZE_MAX / 4) / (size_t)out_height) { + host_log(h, "Rejected frame: %dx%d overflows frame buffer size", out_width, + out_height); + return 0; + } + size_t needed = (size_t)out_width * (size_t)out_height * 4; if (h->back.capacity < needed) { - h->back.data = realloc(h->back.data, needed); + uint8_t *data = realloc(h->back.data, needed); + if (!data) return 0; + h->back.data = data; h->back.capacity = needed; } + + // dst_row0 is the destination index a (x=0, y=0) source pixel maps to; + // step_x/step_y are how that index moves as x/y advance by one. Both are + // constant for the whole frame, derived once here instead of re-running + // this switch - and the index multiply it replaces - for every pixel. + // libretro's SET_ROTATION counts quarter turns counter-clockwise, so a 90 + // degree turn sends the source's right-hand column to the top row. + // + // A tiled/blocked write was also tried here for the quarter-turn case + // (rotations 1 and 3, where step_x is +-out_width*4 bytes and consecutive + // source pixels land on different destination cache lines). Measured + // against this plain accumulating walk on x86-64 at 512x480, tiling + // through a scratch buffer was consistently slower - about 25% - because + // it doubles the stores per pixel (once into the scratch tile, once out to + // the real destination) for a locality win that a modern desktop's cache + // hierarchy doesn't need. It was dropped in favor of this simpler, + // measured-faster loop; ARM Cortex-A55 class hardware couldn't be measured + // directly, but the doubled-store cost is architecture independent while + // the cache benefit is not guaranteed to outweigh it there either. + ptrdiff_t dst_row0, step_x, step_y; + switch (rotation) { + case 1: // 90 CCW + dst_row0 = (ptrdiff_t)(width - 1) * out_width; + step_x = -(ptrdiff_t)out_width; + step_y = 1; + break; + case 2: // 180 + dst_row0 = (ptrdiff_t)(height - 1) * out_width + (width - 1); + step_x = -1; + step_y = -(ptrdiff_t)out_width; + break; + case 3: // 270 CCW + dst_row0 = height - 1; + step_x = out_width; + step_y = -1; + break; + default: // 0 + dst_row0 = 0; + step_x = 1; + step_y = out_width; + break; + } + const uint8_t *src_bytes = (const uint8_t *)src; + uint8_t *back = h->back.data; + unsigned fmt = h->pixel_format; + ptrdiff_t row_dst = dst_row0; for (int y = 0; y < height; y++) { const uint8_t *s = src_bytes + (size_t)y * pitch; - uint8_t *d = h->back.data + (size_t)y * width * 4; + ptrdiff_t dst = row_dst; for (int x = 0; x < width; x++) { unsigned r, g, b; - if (h->pixel_format == RETRO_PIXEL_FORMAT_XRGB8888) { - uint32_t p; - memcpy(&p, s + x * 4, 4); - r = (p >> 16) & 0xFF; - g = (p >> 8) & 0xFF; - b = p & 0xFF; - } else if (h->pixel_format == RETRO_PIXEL_FORMAT_RGB565) { - uint16_t p; - memcpy(&p, s + x * 2, 2); - unsigned r5 = (p >> 11) & 0x1F, g6 = (p >> 5) & 0x3F, b5 = p & 0x1F; - r = (r5 << 3) | (r5 >> 2); - g = (g6 << 2) | (g6 >> 4); - b = (b5 << 3) | (b5 >> 2); - } else { // 0RGB1555 - uint16_t p; - memcpy(&p, s + x * 2, 2); - unsigned r5 = (p >> 10) & 0x1F, g5 = (p >> 5) & 0x1F, b5 = p & 0x1F; - r = (r5 << 3) | (r5 >> 2); - g = (g5 << 3) | (g5 >> 2); - b = (b5 << 3) | (b5 >> 2); - } - pack_pixel(h, d + x * 4, r, g, b); + unpack_pixel(fmt, s + (size_t)x * bpp, &r, &g, &b); + pack_pixel(h, back + (size_t)dst * 4, r, g, b); + dst += step_x; } + row_dst += step_y; } - h->back.width = width; - h->back.height = height; + h->back.width = out_width; + h->back.height = out_height; + return 1; } static void RETRO_CALLCONV video_refresh_cb(const void *data, unsigned width, unsigned height, size_t pitch) { struct lh_host *h = g_session; if (!h || width == 0 || height == 0) return; + // Reject an out-of-bounds frame here, before it ever reaches convert_frame's + // allocation math. width/height come straight from the core on every frame + // (unlike notify_geometry's SET_SYSTEM_AV_INFO/SET_GEOMETRY, this path has + // no separate announcement step to reject first), so the same + // LH_MAX_FRAME_DIMENSION bound has to be enforced here too. + if (width > LH_MAX_FRAME_DIMENSION || height > LH_MAX_FRAME_DIMENSION) { + host_log(h, "Rejected frame %ux%u (out of bounds)", width, height); + return; + } if (!data) { // duped frame: re-signal the last one if (h->cb.frame_ready) h->cb.frame_ready(h->cb.user); return; } mutex_lock(&h->video_lock); - convert_frame(h, data, (int)width, (int)height, (int)pitch); - h->back_ready = 1; + // pitch stays a size_t end to end (see convert_frame): truncating it to int + // here would let a core report a huge real pitch that wraps to something + // small and passes convert_frame's `pitch < width*bpp` rejection check by + // accident, defeating FIX 3 instead of enforcing it. + int converted = convert_frame(h, data, (int)width, (int)height, pitch); + if (converted) h->back_ready = 1; mutex_unlock(&h->video_lock); - if (h->cb.frame_ready) h->cb.frame_ready(h->cb.user); + if (converted && h->cb.frame_ready) h->cb.frame_ready(h->cb.user); } // --------------------------------------------------------------------------- @@ -554,7 +939,13 @@ static size_t RETRO_CALLCONV audio_batch_cb(const int16_t *data, size_t frames) { struct lh_host *h = g_session; if (!h || !data || frames == 0) return frames; - if (h->fast_forward <= 1) audio_push(h, data, (int)frames); + // audio_push doubles this for stereo sample count and stores it in an int, + // so anything that wouldn't fit after doubling must be clamped here, before + // the (int) cast below can turn a huge frame count negative. No real core + // reports anything close to this many frames in one callback; this is + // purely a guard against untrusted core input. + size_t clamped = frames > (size_t)(INT_MAX / 2) ? (size_t)(INT_MAX / 2) : frames; + if (h->fast_forward <= 1) audio_push(h, data, (int)clamped); return frames; } @@ -567,14 +958,49 @@ static void RETRO_CALLCONV audio_sample_cb(int16_t left, int16_t right) { // Input. // --------------------------------------------------------------------------- -static void RETRO_CALLCONV input_poll_cb(void) {} +// The one implementation of the OR-latch: every platform writer calls +// lh_set_input, and this is the only place that ever drains it. Called once +// per real libretro poll from input_poll_cb (on the emulation thread), and +// directly by lh_test_poll_input for deterministic tests that don't want to +// race a running core's frame timing. +static void latch_input(struct lh_host *h) { + for (int p = 0; p < LH_MAX_PORTS; p++) { + // Drain the edges first, then read the level, and never write a level back + // into pending. Seeding pending with a previously read level is a race: a + // write landing between the read and the exchange publishes its bit into + // level and into pending, and the exchange then overwrites pending with + // the stale level -- erasing a bit that is still physically held, so the + // next poll reports the button released mid-press. + // + // Ordering here is the mirror of lh_set_input's: it ORs the edge into + // pending before publishing the level, and this drains pending before + // reading the level, so a write that straddles either pair is caught by + // one half or the other. Worst case a bit is reported one poll later + // rather than lost. + unsigned observed = atomic_exchange(&h->input_pending[p], 0u); + unsigned level = atomic_load(&h->input_level[p]); + h->input_frame[p] = (uint16_t)(observed | level); + } +} + +// Was a no-op: the core polled input by immediately re-reading whatever the +// platform's atomic mask held at that instant, so a press whose down and up +// both landed inside one poll window was never observed at all (see +// lh_set_input's comment). Latching here, once per poll, is what fixes that. +static void RETRO_CALLCONV input_poll_cb(void) { + struct lh_host *h = g_session; + if (h) latch_input(h); +} static int16_t RETRO_CALLCONV input_state_cb(unsigned port, unsigned device, unsigned index, unsigned id) { (void)index; struct lh_host *h = g_session; - if (!h || device != RETRO_DEVICE_JOYPAD || !h->cb.poll_input) return 0; - uint16_t mask = h->cb.poll_input(h->cb.user, (int)port); + if (!h || device != RETRO_DEVICE_JOYPAD || port >= LH_MAX_PORTS) return 0; + // Latched by input_poll_cb above, not a fresh read: every id read during + // this frame sees the same snapshot, so composing e.g. up and left from two + // different instants of the same frame (a torn read) can't happen. + uint16_t mask = h->input_frame[port]; if (id == RETRO_DEVICE_ID_JOYPAD_MASK) return (int16_t)mask; if (id >= 16) return 0; return (mask & (1u << id)) ? 1 : 0; @@ -589,6 +1015,22 @@ static void execute_job(struct lh_host *h, lh_job *job) { case JOB_RESET: if (h->core.reset) h->core.reset(); break; + case JOB_RESTART: + job->result_ok = restart_core(h) == 0; + if (!job->result_ok) { + // restart_core already set h->running = 0 on failure, which would + // otherwise freeze the picture with no signal to the platform layer. + // Mirror the async restart path (run_loop's restart_requested + // handling) so a synchronous lh_restart failure is reported the + // same way. + if (h->cb.message) { + h->cb.message(h->cb.user, "Failed to restart libretro core"); + } + if (h->cb.fatal_error) { + h->cb.fatal_error(h->cb.user, "Failed to restart libretro core"); + } + } + break; case JOB_SERIALIZE_SIZE: job->result_size = h->core.serialize_size ? h->core.serialize_size() : 0; break; @@ -652,8 +1094,14 @@ static void sram_load(struct lh_host *h) { if (size == 0 || !mem) return; FILE *f = fopen(h->sram_path, "rb"); if (!f) return; - fread(mem, 1, size, f); + size_t got = fread(mem, 1, size, f); fclose(f); + if (got < size) { + // A truncated .srm otherwise leaves the tail of SRAM at whatever + // retro_init put there, silently. + host_log(h, "sram_load: read %zu of %zu expected bytes from %s", got, + size, h->sram_path); + } } static void sram_flush(struct lh_host *h) { @@ -682,6 +1130,17 @@ static void *run_loop(void *arg) { uint64_t next = now_ns(); while (h->running) { + if (atomic_exchange(&h->restart_requested, 0)) { + if (restart_core(h) != 0) { + if (h->cb.message) { + h->cb.message(h->cb.user, "Failed to restart libretro core"); + } + if (h->cb.fatal_error) { + h->cb.fatal_error(h->cb.user, "Failed to restart libretro core"); + } + } + continue; + } if (h->paused) { sleep_ns(16000000); drain_jobs(h); @@ -763,10 +1222,10 @@ static DWORD WINAPI run_loop_win(LPVOID arg) { static void thread_start(struct lh_host *h) { #ifdef _WIN32 h->thread = CreateThread(NULL, 0, run_loop_win, h, 0, NULL); + h->has_thread = h->thread != NULL; #else - pthread_create(&h->thread, NULL, run_loop, h); + h->has_thread = pthread_create(&h->thread, NULL, run_loop, h) == 0; #endif - h->has_thread = 1; } static void thread_join(struct lh_host *h) { @@ -818,6 +1277,7 @@ static int resolve_core(lh_core *core) { lh_host *lh_create(lh_output_format fmt, lh_callbacks cb) { struct lh_host *h = calloc(1, sizeof(struct lh_host)); + if (!h) return NULL; h->format = fmt; h->cb = cb; h->fast_forward = 1; @@ -831,28 +1291,65 @@ lh_host *lh_create(lh_output_format fmt, lh_callbacks cb) { return h; } -// Unwinds a load that failed after retro_init, pairing the init and freeing the -// directory strings that lh_stop would otherwise own. -static int load_failed(struct lh_host *h, int code) { - h->core.deinit(); - lib_close(h->core.handle); - memset(&h->core, 0, sizeof(h->core)); +void lh_set_input(lh_host *host, int port, uint16_t mask) { + if (!host || port < 0 || port >= LH_MAX_PORTS) return; + // Order matters: OR the edge into pending before publishing the new level, + // so a poll landing between these two lines still sees the bit that just + // went low in level - it was already folded into pending on the line + // above, which is exactly the case latch_input exists to preserve. + atomic_fetch_or(&host->input_pending[port], (unsigned)mask); + atomic_store(&host->input_level[port], (unsigned)mask); +} + +void lh_test_poll_input(lh_host *host) { + if (host) latch_input(host); +} + +uint16_t lh_test_read_input(lh_host *host, int port) { + if (!host || port < 0 || port >= LH_MAX_PORTS) return 0; + return host->input_frame[port]; +} + +static void free_load_paths(struct lh_host *h) { free(h->system_dir); free(h->save_dir); free(h->sram_path); - h->system_dir = h->save_dir = h->sram_path = NULL; + free(h->core_path); + free(h->rom_path); + h->system_dir = h->save_dir = h->sram_path = h->core_path = h->rom_path = + NULL; +} + +// Unwinds a core that was opened (and possibly loaded) for an attempt that +// is now being abandoned: lh_load's load_failed and restart_core's failure +// path both reach this. load_content can fail (e.g. -7, alloc_failed) after +// it already set core_loaded = 1 and called retro_load_game successfully; +// leaving core_loaded = 1 here would let lh_serialize_size/lh_reset/lh_stop +// pass their core_loaded guard and run a job against a core struct this +// function is about to zero out. retro_unload_game must also be paired with +// the retro_load_game that succeeded before deinit runs - calling deinit +// without unload_game first is out of the libretro contract. +static void unwind_failed_core(struct lh_host *h) { + if (h->core_loaded) { + if (h->core.unload_game) h->core.unload_game(); + h->core_loaded = 0; + } + if (h->core.deinit) h->core.deinit(); + if (h->core.handle) lib_close(h->core.handle); + memset(&h->core, 0, sizeof(h->core)); +} + +// Unwinds a load that failed after retro_init, pairing the init and freeing the +// paths that lh_stop would otherwise own. +static int load_failed(struct lh_host *h, int code) { + unwind_failed_core(h); + free_load_paths(h); g_session = NULL; return code; } -int lh_load(lh_host *h, const char *core_path, const char *rom_path, - const char *system_dir, const char *save_dir, const char *game_id, - const char *const *opt_keys, const char *const *opt_vals, - int opt_count, lh_av_info *out_info) { - if (g_session) return -1; // one session per process - h->shutdown_requested = 0; - - h->core.handle = lib_open(core_path); +static int open_core(struct lh_host *h) { + h->core.handle = lib_open(h->core_path); if (!h->core.handle) return -2; if (!resolve_core(&h->core)) { lib_close(h->core.handle); @@ -860,14 +1357,13 @@ int lh_load(lh_host *h, const char *core_path, const char *rom_path, return -3; } - h->system_dir = lh_strdup(system_dir); - h->save_dir = lh_strdup(save_dir); - size_t sram_len = strlen(save_dir) + strlen(game_id) + 6; - h->sram_path = malloc(sram_len); - snprintf(h->sram_path, sram_len, "%s/%s.srm", save_dir, game_id); - for (int i = 0; i < opt_count; i++) vars_set(h, opt_keys[i], opt_vals[i]); - - g_session = h; + h->pixel_format = RETRO_PIXEL_FORMAT_0RGB1555; + h->av.rotation = 0; + // retro_init (below) and, later, retro_load_game are the two points a core + // can call SET_VARIABLES from; reset the flag here so a failure from a + // *previous* load/restart attempt (already handled by that attempt's own + // rc check) can't be mistaken for one from this one. + h->alloc_failed = 0; h->core.set_environment(environment_cb); h->core.set_video_refresh(video_refresh_cb); h->core.set_audio_sample(audio_sample_cb); @@ -875,31 +1371,34 @@ int lh_load(lh_host *h, const char *core_path, const char *rom_path, h->core.set_input_poll(input_poll_cb); h->core.set_input_state(input_state_cb); h->core.init(); + return 0; +} +static int load_content(struct lh_host *h) { struct retro_system_info info; memset(&info, 0, sizeof(info)); h->core.get_system_info(&info); struct retro_game_info game; memset(&game, 0, sizeof(game)); - game.path = rom_path; + game.path = h->rom_path; void *rom_data = NULL; if (!info.need_fullpath) { - FILE *f = fopen(rom_path, "rb"); - if (!f) return load_failed(h, -4); + FILE *f = fopen(h->rom_path, "rb"); + if (!f) return -4; fseek(f, 0, SEEK_END); long len = ftell(f); fseek(f, 0, SEEK_SET); if (len <= 0) { fclose(f); - return load_failed(h, -4); + return -4; } rom_data = malloc((size_t)len); size_t got = rom_data ? fread(rom_data, 1, (size_t)len, f) : 0; fclose(f); if (got != (size_t)len) { free(rom_data); - return load_failed(h, -4); + return -4; } game.data = rom_data; game.size = (size_t)len; @@ -907,11 +1406,12 @@ int lh_load(lh_host *h, const char *core_path, const char *rom_path, bool ok = h->core.load_game(&game); free(rom_data); - if (!ok) return load_failed(h, -5); - // A core that asked to quit during the load has nothing to run. + if (!ok) return -5; + // A core that asked to quit during the load has nothing to run. Unload before + // reporting, so the caller's teardown is not left holding a live game. if (h->shutdown_requested) { if (h->core.unload_game) h->core.unload_game(); - return load_failed(h, -6); + return -6; } struct retro_system_av_info av; @@ -919,23 +1419,99 @@ int lh_load(lh_host *h, const char *core_path, const char *rom_path, h->core.get_system_av_info(&av); h->av.width = (int)av.geometry.base_width; h->av.height = (int)av.geometry.base_height; - h->av.aspect = - av.geometry.aspect_ratio > 0 - ? (double)av.geometry.aspect_ratio - : (double)av.geometry.base_width / - (double)(av.geometry.base_height > 0 ? av.geometry.base_height - : 1); + if (av.geometry.aspect_ratio > 0) { + h->av.aspect = (double)av.geometry.aspect_ratio; + } + // load_game is where SET_ROTATION arrives, so it is already known here. + apply_rotation_to_av(h, av.geometry.aspect_ratio > 0); h->av.fps = av.timing.fps > 0 ? av.timing.fps : 60; h->av.sample_rate = av.timing.sample_rate > 0 ? av.timing.sample_rate : 44100; int cap_frames = (int)(h->av.sample_rate * 0.25); - h->ring_capacity = cap_frames * 2; - h->ring = calloc((size_t)h->ring_capacity, sizeof(int16_t)); + if (cap_frames <= 0 || cap_frames > INT_MAX / 2) return -6; + int ring_capacity = cap_frames * 2; + int16_t *ring = calloc((size_t)ring_capacity, sizeof(int16_t)); + if (!ring) return -6; + mutex_lock(&h->audio_lock); + int16_t *old_ring = h->ring; + h->ring = ring; + h->ring_capacity = ring_capacity; h->ring_read = h->ring_write = h->ring_stored = 0; + mutex_unlock(&h->audio_lock); + free(old_ring); sram_load(h); h->core_loaded = 1; h->last_sram_flush_ns = now_ns(); + // load_game (just above) is the other point besides retro_init that a core + // can call SET_VARIABLES from, so alloc_failed has to be re-checked here + // too: a core is fully loaded and playable at this point, but with an + // incomplete option set, which lh_load and restart_core both need to treat + // as a failure rather than a silent partial success. -7 joins this + // function's existing -4/-5/-6 as "the same allocation-failure code lh_load + // uses for its own setup", so both callers just check `rc != 0`. + if (h->alloc_failed) return -7; + return 0; +} + +int lh_load(lh_host *h, const char *core_path, const char *rom_path, + const char *system_dir, const char *save_dir, const char *game_id, + const char *const *opt_keys, const char *const *opt_vals, + int opt_count, lh_av_info *out_info) { + if (g_session) return -1; // one session per process + h->shutdown_requested = 0; + + // FIX 5: game_id names the SRAM file below and ultimately originates from a + // route parameter seeded by server data (app_router.dart -> + // LibretroBridge.kt -> nativeLoad here), which is not a trusted boundary. A + // hostile or compromised server could hand this an id containing "../" to + // make sram_path point outside save_dir - an arbitrary file write/overwrite + // once combined with the core's own read/write of that path. Reject it + // outright rather than stripping or rewriting the offending characters: + // silently sanitizing would silently change which save file a legitimate + // game_id maps to, which is a worse, quieter failure mode than refusing the + // load with a clear error. + if (!game_id || strchr(game_id, '/') || strchr(game_id, '\\') || + strstr(game_id, "..")) { + return -8; + } + + h->system_dir = lh_strdup(system_dir); + h->save_dir = lh_strdup(save_dir); + h->core_path = lh_strdup(core_path); + h->rom_path = lh_strdup(rom_path); + size_t sram_len = strlen(save_dir) + strlen(game_id) + 6; + h->sram_path = malloc(sram_len); + // FIX 4: lh_strdup/malloc can all return NULL under memory pressure. The + // old code walked straight into snprintf-ing through h->sram_path (a + // guaranteed NULL-pointer write if that malloc failed) and later into + // open_core, which hands system_dir/core_path/rom_path to dlopen/fopen + // without ever having checked them. -7 is a new code (existing ones are all + // core/content specific: -2/-3 core load, -4/-5 rom/game, -6 audio ring), + // reserved for "the host itself couldn't allocate what it needed to attempt + // the load" so a caller can tell this apart from a bad core or ROM. + if (!h->system_dir || !h->save_dir || !h->core_path || !h->rom_path || + !h->sram_path) { + free_load_paths(h); + return -7; + } + snprintf(h->sram_path, sram_len, "%s/%s.srm", save_dir, game_id); + for (int i = 0; i < opt_count; i++) { + if (vars_set(h, opt_keys[i], opt_vals[i]) != 0) { + free_load_paths(h); + return -7; + } + } + + g_session = h; + int rc = open_core(h); + if (rc != 0) { + free_load_paths(h); + g_session = NULL; + return rc; + } + rc = load_content(h); + if (rc != 0) return load_failed(h, rc); if (out_info) *out_info = h->av; return 0; } @@ -948,6 +1524,7 @@ void lh_start(lh_host *h) { h->jobs_open = 1; mutex_unlock(&h->jobs_lock); thread_start(h); + if (!h->has_thread) h->running = 0; } void lh_pause(lh_host *h) { h->paused = 1; } @@ -966,9 +1543,77 @@ void lh_reset(lh_host *h) { run_job(h, &job); } +// This follows the same lifecycle as leaving and launching the game again, +// while keeping the platform's existing texture and input objects alive. +static int restart_core(struct lh_host *h) { + if (!h->core_loaded) { + atomic_fetch_add(&h->restart_generation, 1); + return -1; + } + + sram_flush(h); + if (h->core.unload_game) h->core.unload_game(); + if (h->core.deinit) h->core.deinit(); + if (h->core.handle) lib_close(h->core.handle); + memset(&h->core, 0, sizeof(h->core)); + h->core_loaded = 0; + + mutex_lock(&h->vars_lock); + free_option_definitions(h); + // The old core is fully unloaded above, so nothing can still be holding a + // GET_VARIABLE pointer from the previous session. Draining here (rather + // than only at lh_stop) keeps the arena from carrying across restarts. + vars_free_retired(h); + h->variables_dirty = 1; + mutex_unlock(&h->vars_lock); + + int rc = open_core(h); + if (rc == 0) rc = load_content(h); + if (rc != 0) { + // Same unwind load_failed performs for lh_load, but restart_core keeps + // g_session and the load paths alive (a later restart attempt reuses + // core_path/rom_path via open_core, and lh_stop still needs them). + unwind_failed_core(h); + h->running = 0; + } + atomic_fetch_add(&h->restart_generation, 1); + return rc; +} + +int lh_restart(lh_host *h) { + if (!h->core_loaded) return -1; + lh_job job = {0}; + job.kind = JOB_RESTART; + run_job(h, &job); + return job.result_ok ? 0 : -1; +} + +int lh_restart_async(lh_host *h) { + if (!h || !atomic_load(&h->running)) { + return -1; + } + atomic_store(&h->restart_requested, 1); + return 0; +} + +unsigned lh_restart_generation(lh_host *h) { + return atomic_load(&h->restart_generation); +} + void lh_stop(lh_host *h) { if (h->has_thread) { + // Clearing running under jobs_lock makes this transition atomic with + // run_job's queue-or-execute decision (see the comment on run_job): either + // a concurrent run_job call observes running==1 here and queues into a + // loop that is guaranteed one more drain_jobs before it can exit (see + // run_loop's final drain), or it observes running==0 and fails the job + // without entering a core that may be unloading. Without holding the lock across this + // write, run_job could read running==1 a moment after this store and + // queue into a loop that has already fallen out of its while() condition, + // and the job would then wait on jobs_cond forever. + mutex_lock(&h->jobs_lock); h->running = 0; + mutex_unlock(&h->jobs_lock); thread_join(h); // the loop flushes SRAM and tears the core down } else if (h->core_loaded) { // Same order as the run loop's exit, so a job from another thread is @@ -982,25 +1627,20 @@ void lh_stop(lh_host *h) { if (h->core.handle) lib_close(h->core.handle); memset(&h->core, 0, sizeof(h->core)); } + // Both branches above end with the core torn down (thread_join returns only + // after run_loop has unloaded it), so no core-held GET_VARIABLE pointer can + // outlive this point and the retired values can go. h->vars themselves stay + // put - lh_destroy's free_options owns those. + mutex_lock(&h->vars_lock); + vars_free_retired(h); + mutex_unlock(&h->vars_lock); + g_session = NULL; - free(h->system_dir); - free(h->save_dir); - free(h->sram_path); - h->system_dir = h->save_dir = h->sram_path = NULL; + free_load_paths(h); } static void free_options(struct lh_host *h) { - for (int i = 0; i < h->def_count; i++) { - free(h->defs[i].id); - free(h->defs[i].label); - for (int c = 0; c < h->defs[i].choice_count; c++) { - free(h->defs[i].choices[c]); - } - free(h->defs[i].choices); - } - free(h->defs); - h->defs = NULL; - h->def_count = 0; + free_option_definitions(h); for (int i = 0; i < h->var_count; i++) { free(h->vars[i].key); free(h->vars[i].value); @@ -1008,6 +1648,10 @@ static void free_options(struct lh_host *h) { free(h->vars); h->vars = NULL; h->var_count = 0; + // Backstop for the lh_destroy path that never loaded a core (and so never + // ran lh_stop). vars_free_retired is idempotent, so the ordinary + // lh_stop-then-destroy sequence just finds an empty list here. + vars_free_retired(h); } void lh_destroy(lh_host *h) { @@ -1101,7 +1745,15 @@ int lh_option_count(lh_host *h) { return count; } +// Copies one definition into caller-owned storage while still holding the +// lock. Handing back the host's own pointers, as this used to, was a +// cross-thread use-after-free: restart_core frees every definition from the +// emulation thread and lh_set_option frees the replaced value from whichever +// thread changed it, both after this function has already unlocked and +// returned. Copying is cheap next to the JNI/Flutter marshalling every caller +// does with the result anyway. int lh_get_option(lh_host *h, int index, lh_option *out) { + if (!h || !out) return -1; mutex_lock(&h->vars_lock); if (index < 0 || index >= h->def_count) { mutex_unlock(&h->vars_lock); @@ -1109,19 +1761,32 @@ int lh_get_option(lh_host *h, int index, lh_option *out) { } lh_optdef *def = &h->defs[index]; const char *current = vars_get(h, def->id); - out->id = def->id; - out->label = def->label; - out->current = current ? current - : (def->choice_count > 0 ? def->choices[0] : ""); - out->choices = (const char *const *)def->choices; - out->choice_count = def->choice_count; + lh_copy_bounded(out->id, sizeof(out->id), def->id); + lh_copy_bounded(out->label, sizeof(out->label), def->label); + lh_copy_bounded(out->current, sizeof(out->current), + current ? current + : (def->choice_count > 0 ? def->choices[0] : "")); + int n = def->choice_count; + if (n > LH_OPTION_CHOICE_MAX) n = LH_OPTION_CHOICE_MAX; + for (int c = 0; c < n; c++) { + lh_copy_bounded(out->choices[c], sizeof(out->choices[c]), def->choices[c]); + } + out->choice_count = n; mutex_unlock(&h->vars_lock); return 0; } void lh_set_option(lh_host *h, const char *id, const char *value) { mutex_lock(&h->vars_lock); - vars_set(h, id, value); - h->variables_dirty = 1; + // There is no "load" to fail here - the core is already running - so an + // allocation failure is skipped cleanly instead: leave the option at its + // previous value and don't mark variables dirty for a change that never + // actually took, rather than telling the core an update happened when it + // didn't. + if (vars_set(h, id, value) == 0) { + h->variables_dirty = 1; + } else { + host_log(h, "Failed to set option %s (allocation failure)", id); + } mutex_unlock(&h->vars_lock); } diff --git a/native/libretro_host/libretro_host.h b/native/libretro_host/libretro_host.h index 8b03f7733..a0bba1090 100644 --- a/native/libretro_host/libretro_host.h +++ b/native/libretro_host/libretro_host.h @@ -17,6 +17,10 @@ extern "C" { typedef struct lh_host lh_host; +// Ports the input latch tracks. Matches the largest per-platform mask array +// (Android, macOS/iOS); the desktop runners only ever touch port 0. +#define LH_MAX_PORTS 4 + // The 32-bit layout the host writes converted frames in. Pick whichever the // platform texture expects: Flutter desktop pixel buffers want RGBA, a macOS // CVPixelBuffer wants BGRA. @@ -26,11 +30,18 @@ typedef enum { } lh_output_format; typedef struct { + // Post-rotation display geometry: when the core requested a 90 or 270 degree + // rotation, width/height are already swapped and aspect already inverted, so + // these match the frames lh_get_frame hands back. int width; int height; double aspect; double fps; double sample_rate; + // 0, 1, 2, 3 = 0, 90, 180, 270 degrees counter-clockwise, per + // RETRO_ENVIRONMENT_SET_ROTATION. Informational: the host already bakes the + // rotation into the converted frame, so platforms need not act on it. + int rotation; } lh_av_info; typedef struct { @@ -38,9 +49,6 @@ typedef struct { // A new converted frame is ready. The platform pulls it with lh_get_frame. // Called from the run-loop thread. void (*frame_ready)(void *user); - // The RetroPad button bitmask (RETRO_DEVICE_ID_JOYPAD_* bits) for a port. - // Called from the run-loop thread on every input poll. - uint16_t (*poll_input)(void *user, int port); // Number of connected controllers, for the player-count event. int (*controller_count)(void *user); // The core changed its output geometry or aspect ratio. @@ -55,20 +63,58 @@ typedef struct { // it exits, so post the teardown elsewhere rather than calling lh_stop or // lh_destroy from here. Optional, may be NULL. void (*core_shutdown)(void *user); + // The emulation thread is terminating because of an unrecoverable error. + // Distinct from core_shutdown, which is the core asking to quit cleanly. + // Optional, may be NULL. Called from the run-loop thread. + void (*fatal_error)(void *user, const char *message); } lh_callbacks; -// One core option and its choices. +// Bounds for the option snapshot below. The host only speaks the legacy +// SET_VARIABLES form ("Label; a|b|c" - GET_CORE_OPTIONS_VERSION is answered +// with 0), where ids, labels, and values are all short, so these caps are +// generous rather than tight. Anything longer is truncated, never overrun. +#define LH_OPTION_ID_MAX 128 +#define LH_OPTION_LABEL_MAX 256 +#define LH_OPTION_VALUE_MAX 128 +#define LH_OPTION_CHOICE_MAX 64 + +// One core option and its choices, as a caller-owned snapshot. +// +// These used to be borrowed pointers into the host's own allocations, which +// was not safe to hand out: restart_core frees every definition on the +// emulation thread and lh_set_option frees the old value on whichever thread +// changed it, so a caller reading its lh_option afterwards read freed memory. +// lh_get_option now copies everything under the host's option lock, so a +// snapshot stays valid for as long as the caller keeps the struct, whatever +// the other threads do to the host in the meantime. typedef struct { - const char *id; - const char *label; - const char *current; - const char *const *choices; + char id[LH_OPTION_ID_MAX]; + char label[LH_OPTION_LABEL_MAX]; + char current[LH_OPTION_VALUE_MAX]; + char choices[LH_OPTION_CHOICE_MAX][LH_OPTION_VALUE_MAX]; + // Choices actually copied, so it never exceeds LH_OPTION_CHOICE_MAX even if + // the core published more. int choice_count; } lh_option; // Creates a host that writes frames in [fmt] and reports back through [cb]. lh_host *lh_create(lh_output_format fmt, lh_callbacks cb); +// Reports the RetroPad button mask (RETRO_DEVICE_ID_JOYPAD_* bits) currently +// held on [port]. Call this every time the platform's raw input state +// changes - on a key/button edge, a controller value-changed callback, a +// method-channel message, whatever the platform's transport is - not once +// per frame. The host does not sample this instantaneously: every mask +// reported between two core polls is OR'd into a pending latch, so a press +// whose down and up both land inside one ~16.7ms poll window is still +// observed by the core for exactly one frame, and reads of different button +// ids within the same frame stay coherent (RetroArch does the same thing). +// A bit held continuously across many frames stays set in every one of them. +// Thread-safe and safe to call before a core is loaded or after lh_stop; the +// write just has nothing to be read by yet. [port] outside +// [0, LH_MAX_PORTS) is ignored. +void lh_set_input(lh_host *host, int port, uint16_t mask); + // Loads [core_path] and [rom_path]. [system_dir] and [save_dir] back the core's // directory requests. [game_id] names the SRAM file. [opt_keys]/[opt_vals] seed // core options (may be NULL when [opt_count] is 0). Fills [out_info] and returns @@ -83,6 +129,17 @@ int lh_load(lh_host *host, const char *core_path, const char *rom_path, void lh_start(lh_host *host); void lh_pause(lh_host *host); void lh_resume(lh_host *host); +// Fully recreates the core and reloads its current content. Unlike lh_reset, +// this applies options that a core only reads during initialization. +int lh_restart(lh_host *host); +// Schedules the same restart on the emulation thread without waiting for it. +// Returns non-zero when no running core can accept the request. +int lh_restart_async(lh_host *host); +// Increments once for every restart the run loop applies, whether scheduled by +// lh_restart or lh_restart_async and whether it succeeds or fails. Lets a +// caller poll for a scheduled restart to actually land before reading state +// that only makes sense post-restart. +unsigned lh_restart_generation(lh_host *host); void lh_reset(lh_host *host); void lh_set_fast_forward(lh_host *host, int factor); void lh_stop(lh_host *host); @@ -111,12 +168,27 @@ int lh_serialize(lh_host *host, void *dst, size_t size); int lh_unserialize(lh_host *host, const void *src, size_t size); // Core options. lh_option_count and lh_get_option read the definitions the core -// published, and lh_set_option changes a value. Strings stay valid until the next -// options call or lh_stop. +// published, and lh_set_option changes a value. lh_get_option fills [out] with +// a self-contained copy taken under the host's option lock, so the caller owns +// the strings and nothing invalidates them. Returns 0 on success, -1 for a NULL +// argument or an index outside the current definitions - and note the count can +// shrink between the two calls, because a restart on the emulation thread +// rebuilds the whole definition list. Treat a -1 as "stop enumerating", not as +// a hole to skip past. int lh_option_count(lh_host *host); int lh_get_option(lh_host *host, int index, lh_option *out); void lh_set_option(lh_host *host, const char *id, const char *value); +// Test-only: drives one input-latch step directly, without a running core or +// run loop, and reads the value that step produced for [port]. This is the +// exact same latch step input_poll_cb runs once per real libretro poll (see +// lh_set_input) - these exist so its exactly-once-per-edge semantics can be +// verified deterministically in native/libretro_host/test, instead of racing +// a live run loop's wall-clock pacing. Not part of the platform-facing +// contract; no shipping caller should need these. +void lh_test_poll_input(lh_host *host); +uint16_t lh_test_read_input(lh_host *host, int port); + #ifdef __cplusplus } #endif diff --git a/native/libretro_host/test/stub_core.c b/native/libretro_host/test/stub_core.c index 80f3bf5e3..a634d9a3c 100644 --- a/native/libretro_host/test/stub_core.c +++ b/native/libretro_host/test/stub_core.c @@ -1,8 +1,14 @@ // A minimal libretro core for exercising the host without a real emulator. Each // frame it advances a counter, paints a frame whose colours encode the counter // and the current input, emits audio, and keeps the counter as its save state. +// +// stub_pattern switches the painted frame to a coordinate-encoded test +// pattern instead, for pinning down convert_frame's rotation and pixel-format +// handling: stub_rotation drives RETRO_ENVIRONMENT_SET_ROTATION and +// stub_format drives RETRO_ENVIRONMENT_SET_PIXEL_FORMAT. #include +#include #include #include "../libretro.h" @@ -16,16 +22,88 @@ static retro_audio_sample_batch_t audio_batch_cb; static retro_input_poll_t input_poll_cb; static retro_input_state_t input_state_cb; -static uint32_t framebuffer[STUB_WIDTH * STUB_HEIGHT]; +// Sized for the widest format (XRGB8888 at 4 bytes/pixel); narrower formats +// use a shorter pitch and leave the tail unused. +static uint8_t framebuffer[STUB_WIDTH * STUB_HEIGHT * 4]; static int32_t frame_counter; +static int32_t speed_fast; +static int32_t pattern_mode; +static enum retro_pixel_format pixel_fmt; static uint8_t sram[64]; +// stub_huge_frame/stub_bad_pitch drive the host's video_refresh_cb bounds +// checks directly (LH_MAX_FRAME_DIMENSION and the pitch >= width*bpp check in +// convert_frame): each makes retro_run hand the host a frame the real +// hardware could never produce, so a test can confirm the host rejects it +// cleanly (no frame delivered, no crash) instead of converting it. +static int32_t huge_frame_mode; +static int32_t bad_pitch_mode; +// Holds onto a GET_VARIABLE pointer across frames the way a real core does, +// so the host's promise that the pointer stays readable for the life of the +// load can be checked. The first retro_run stashes the pointer for +// "stub_speed" plus a private copy of the string; once the host reports the +// variables dirty (i.e. the platform thread ran lh_set_option and replaced +// that value), a later retro_run reads the *stashed* pointer again. Before +// the retired-value arena, vars_set had already freed it and this read is a +// heap-use-after-free under ASAN. +static const char *stashed_value; +static char stashed_copy[32]; +// 0 = not re-read yet, 1 = re-read and the bytes still matched, 2 = re-read +// and the bytes had changed. Exposed through the save state so the harness +// can assert on it. +static int32_t stash_recheck; +static int32_t saw_variable_update; + +static void stash_reset(void) { + stashed_value = NULL; + stashed_copy[0] = '\0'; + stash_recheck = 0; + saw_variable_update = 0; +} + +// Runs once per frame, before the frame is painted. +static void stash_step(void) { + if (!stashed_value) { + struct retro_variable speed = {"stub_speed", NULL}; + env_cb(RETRO_ENVIRONMENT_GET_VARIABLE, &speed); + if (!speed.value) return; + stashed_value = speed.value; + size_t n = strlen(speed.value); + if (n > sizeof(stashed_copy) - 1) n = sizeof(stashed_copy) - 1; + memcpy(stashed_copy, speed.value, n); + stashed_copy[n] = '\0'; + // Swallow the dirty flag the initial SET_VARIABLES raised, so only an + // option change made after the stash arms the re-read below. + bool ignored = false; + env_cb(RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE, &ignored); + return; + } + bool updated = false; + env_cb(RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE, &updated); + if (updated) saw_variable_update = 1; + if (saw_variable_update && stash_recheck == 0) { + stash_recheck = strcmp(stashed_value, stashed_copy) == 0 ? 1 : 2; + } +} + +void retro_set_environment(retro_environment_t cb) { + env_cb = cb; + static const struct retro_variable vars[] = { + {"stub_speed", "Speed; normal|fast"}, + {"stub_pattern", "Pattern; off|on"}, + {"stub_rotation", "Rotation; 0|1|2|3"}, + {"stub_format", "Pixel format; xrgb8888|rgb565|0rgb1555"}, + {"stub_huge_frame", "Huge frame; off|on"}, + {"stub_bad_pitch", "Bad pitch; off|on"}, + {NULL, NULL}, + }; + env_cb(RETRO_ENVIRONMENT_SET_VARIABLES, (void *)vars); +} // Set from the ROM contents, to mimic a core whose boot fails a few frames in: // it asks the frontend to quit and would fault if it were run again. static int shutdown_frame; static int did_shutdown; -void retro_set_environment(retro_environment_t cb) { env_cb = cb; } void retro_set_video_refresh(retro_video_refresh_t cb) { video_cb = cb; } void retro_set_audio_sample(retro_audio_sample_t cb) { (void)cb; } void retro_set_audio_sample_batch(retro_audio_sample_batch_t cb) { @@ -38,6 +116,13 @@ void retro_set_input_state(retro_input_state_t cb) { input_state_cb = cb; } // the stub does the same to keep the host honest about GET_LOG_INTERFACE. void retro_init(void) { frame_counter = 0; + // A restart reloads the core into the same process, and the host drains its + // retired-value arena as part of that teardown, so a pointer stashed by the + // previous session must not be carried into this one. + stash_reset(); + struct retro_variable speed = {"stub_speed", NULL}; + env_cb(RETRO_ENVIRONMENT_GET_VARIABLE, &speed); + speed_fast = speed.value && strcmp(speed.value, "fast") == 0; struct retro_log_callback logging; memset(&logging, 0, sizeof(logging)); env_cb(RETRO_ENVIRONMENT_GET_LOG_INTERFACE, &logging); @@ -65,7 +150,24 @@ void retro_get_system_av_info(struct retro_system_av_info *info) { info->timing.sample_rate = 44100.0; } +// Packs 8-bit r/g/b into [dst] using [fmt], mirroring the host's unpack_pixel +// shifts exactly so a test can predict the converted value bit-for-bit. +static void write_pixel(uint8_t *dst, enum retro_pixel_format fmt, unsigned r, + unsigned g, unsigned b) { + if (fmt == RETRO_PIXEL_FORMAT_XRGB8888) { + uint32_t word = (r << 16) | (g << 8) | b; + memcpy(dst, &word, 4); + } else if (fmt == RETRO_PIXEL_FORMAT_RGB565) { + uint16_t word = (uint16_t)(((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)); + memcpy(dst, &word, 2); + } else { // 0RGB1555 + uint16_t word = (uint16_t)(((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3)); + memcpy(dst, &word, 2); + } +} + bool retro_load_game(const struct retro_game_info *game) { + (void)game; shutdown_frame = 0; did_shutdown = 0; if (game && game->data && game->size >= 8 && @@ -75,17 +177,41 @@ bool retro_load_game(const struct retro_game_info *game) { enum retro_pixel_format fmt = RETRO_PIXEL_FORMAT_XRGB8888; env_cb(RETRO_ENVIRONMENT_SET_PIXEL_FORMAT, &fmt); - static const struct retro_variable vars[] = { - {"stub_speed", "Speed; normal|fast"}, - {NULL, NULL}, - }; - env_cb(RETRO_ENVIRONMENT_SET_VARIABLES, (void *)vars); + struct retro_variable fmt_var = {"stub_format", NULL}; + env_cb(RETRO_ENVIRONMENT_GET_VARIABLE, &fmt_var); + if (fmt_var.value && strcmp(fmt_var.value, "rgb565") == 0) { + pixel_fmt = RETRO_PIXEL_FORMAT_RGB565; + } else if (fmt_var.value && strcmp(fmt_var.value, "0rgb1555") == 0) { + pixel_fmt = RETRO_PIXEL_FORMAT_0RGB1555; + } else { + pixel_fmt = RETRO_PIXEL_FORMAT_XRGB8888; + } + env_cb(RETRO_ENVIRONMENT_SET_PIXEL_FORMAT, &pixel_fmt); + + struct retro_variable rot_var = {"stub_rotation", NULL}; + env_cb(RETRO_ENVIRONMENT_GET_VARIABLE, &rot_var); + unsigned rotation = rot_var.value ? (unsigned)atoi(rot_var.value) : 0; + env_cb(RETRO_ENVIRONMENT_SET_ROTATION, &rotation); + + struct retro_variable pat_var = {"stub_pattern", NULL}; + env_cb(RETRO_ENVIRONMENT_GET_VARIABLE, &pat_var); + pattern_mode = pat_var.value && strcmp(pat_var.value, "on") == 0; + + struct retro_variable huge_var = {"stub_huge_frame", NULL}; + env_cb(RETRO_ENVIRONMENT_GET_VARIABLE, &huge_var); + huge_frame_mode = huge_var.value && strcmp(huge_var.value, "on") == 0; + + struct retro_variable pitch_var = {"stub_bad_pitch", NULL}; + env_cb(RETRO_ENVIRONMENT_GET_VARIABLE, &pitch_var); + bad_pitch_mode = pitch_var.value && strcmp(pitch_var.value, "on") == 0; + return true; } void retro_unload_game(void) {} void retro_run(void) { + stash_step(); if (did_shutdown) { // A real core would fault here. Say so instead, so the harness can tell. struct retro_message late = {"stub ran after shutdown", 180}; @@ -102,16 +228,57 @@ void retro_run(void) { input_poll_cb(); int16_t mask = input_state_cb(0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_MASK); - frame_counter++; + frame_counter += speed_fast ? 2 : 1; + + int bpp = pixel_fmt == RETRO_PIXEL_FORMAT_XRGB8888 ? 4 : 2; + int pitch = STUB_WIDTH * bpp; + + // Reports a frame far past the host's LH_MAX_FRAME_DIMENSION bound. The + // dimensions alone are what the host has to reject, so the real + // framebuffer (sized for STUB_WIDTH*STUB_HEIGHT) is passed unchanged - the + // host must never read pixel data out of it at these dimensions, only look + // at width/height and refuse before touching the buffer. + if (huge_frame_mode) { + video_cb(framebuffer, 100000, 100000, pitch); + return; + } - // Encode the counter and input into the frame so the host's pixel conversion - // and input plumbing can be checked end to end. XRGB8888: 0x00RRGGBB. - uint32_t r = (uint32_t)(frame_counter & 0xFF); - uint32_t g = (uint32_t)(mask & 0xFF); - uint32_t b = 0x55; - uint32_t color = (r << 16) | (g << 8) | b; - for (int i = 0; i < STUB_WIDTH * STUB_HEIGHT; i++) framebuffer[i] = color; - video_cb(framebuffer, STUB_WIDTH, STUB_HEIGHT, STUB_WIDTH * 4); + // Reports a valid, in-bounds frame size with a pitch too small to hold one + // real scanline, so a host that doesn't check pitch against width*bpp would + // read off the end of a real row into the next one (or past the buffer on + // the last row). + if (bad_pitch_mode) { + video_cb(framebuffer, STUB_WIDTH, STUB_HEIGHT, 1); + return; + } + + if (pattern_mode) { + // Coordinates encoded with different multipliers per axis (x*3, y*5) so a + // transposed or mirrored rotation produces a detectably wrong pixel, + // unlike a symmetric pattern which a 90-degree transpose could pass. + for (int y = 0; y < STUB_HEIGHT; y++) { + for (int x = 0; x < STUB_WIDTH; x++) { + unsigned r = (unsigned)(x * 3) & 0xFF; + unsigned g = (unsigned)(y * 5) & 0xFF; + unsigned b = 0x11; + write_pixel(framebuffer + (size_t)y * pitch + (size_t)x * bpp, + pixel_fmt, r, g, b); + } + } + } else { + // Encode the counter and input into the frame so the host's pixel + // conversion and input plumbing can be checked end to end. + unsigned r = (unsigned)(frame_counter & 0xFF); + unsigned g = (unsigned)(mask & 0xFF); + unsigned b = 0x55; + for (int y = 0; y < STUB_HEIGHT; y++) { + for (int x = 0; x < STUB_WIDTH; x++) { + write_pixel(framebuffer + (size_t)y * pitch + (size_t)x * bpp, + pixel_fmt, r, g, b); + } + } + } + video_cb(framebuffer, STUB_WIDTH, STUB_HEIGHT, pitch); // One frame of a constant tone so the ring has something to read. int16_t samples[735 * 2]; @@ -124,17 +291,23 @@ void retro_run(void) { void retro_reset(void) { frame_counter = 0; } -size_t retro_serialize_size(void) { return sizeof(frame_counter); } +size_t retro_serialize_size(void) { return sizeof(frame_counter) * 3; } bool retro_serialize(void *data, size_t size) { - if (size < sizeof(frame_counter)) return false; - memcpy(data, &frame_counter, sizeof(frame_counter)); + if (size < sizeof(frame_counter) * 3) return false; + int32_t *state = data; + state[0] = frame_counter; + state[1] = speed_fast; + state[2] = stash_recheck; return true; } bool retro_unserialize(const void *data, size_t size) { - if (size < sizeof(frame_counter)) return false; - memcpy(&frame_counter, data, sizeof(frame_counter)); + if (size < sizeof(frame_counter) * 3) return false; + const int32_t *state = data; + frame_counter = state[0]; + speed_fast = state[1]; + stash_recheck = state[2]; return true; } diff --git a/native/libretro_host/test/test_harness.c b/native/libretro_host/test/test_harness.c index 8c0cbd165..4a41f90f7 100644 --- a/native/libretro_host/test/test_harness.c +++ b/native/libretro_host/test/test_harness.c @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "../libretro_host.h" @@ -13,6 +14,7 @@ #include static void msleep(int ms) { Sleep(ms); } #else +#include #include static void msleep(int ms) { struct timespec ts = {ms / 1000, (long)(ms % 1000) * 1000000L}; @@ -22,6 +24,13 @@ static void msleep(int ms) { static int g_failures; static atomic_int g_frames_ready; + +// Must match STUB_WIDTH/STUB_HEIGHT in stub_core.c. Deliberately non-square +// (and each dimension a different prime multiplier away from the other) so a +// rotation bug that transposes or mirrors the frame cannot hide behind a +// square or symmetric geometry. +#define SRC_W 64 +#define SRC_H 48 static _Atomic uint16_t g_mask; static atomic_int g_shutdowns; static atomic_int g_late_runs; @@ -41,10 +50,6 @@ static void on_frame_ready(void *user) { (void)user; g_frames_ready++; } -static uint16_t on_poll_input(void *user, int port) { - (void)user; - return port == 0 ? g_mask : 0; -} static int on_controller_count(void *user) { (void)user; return 1; @@ -71,7 +76,6 @@ static lh_callbacks make_callbacks(void) { lh_callbacks cb; memset(&cb, 0, sizeof(cb)); cb.frame_ready = on_frame_ready; - cb.poll_input = on_poll_input; cb.controller_count = on_controller_count; cb.geometry_changed = on_geometry; cb.message = on_message; @@ -87,6 +91,87 @@ static void write_rom(const char *path, const char *contents) { } } +// --------------------------------------------------------------------------- +// Regression tests for the input OR-latch. Before this fix, poll_input +// returned the platform's raw instantaneous mask, and input_poll_cb was a +// no-op - so a press whose down and up both landed inside one ~16.7ms poll +// window was never observed by the core at all. lh_set_input now folds every +// write into a pending OR mask, and lh_test_poll_input runs the same latch +// step input_poll_cb runs on every real libretro poll, exchanging pending +// back down to the current level and handing the exchanged value to the +// frame the core reads. Driven directly through lh_set_input/ +// lh_test_poll_input/lh_test_read_input rather than a loaded core, so the +// exact poll boundaries are deterministic instead of racing the run loop's +// wall-clock pacing. +// --------------------------------------------------------------------------- + +static void test_input_latch(void) { + printf("input latch:\n"); + lh_callbacks cb; + memset(&cb, 0, sizeof(cb)); + lh_host *host = lh_create(LH_FORMAT_RGBA8888, cb); + CHECK(host != NULL, "latch test host allocates"); + if (!host) return; + + // The bug report, reproduced directly: a bit set and cleared entirely + // between two polls must still be observed for exactly one frame. + lh_set_input(host, 0, 0x0001); + lh_set_input(host, 0, 0x0000); + lh_test_poll_input(host); + CHECK(lh_test_read_input(host, 0) == 0x0001, + "a press+release inside one poll window is observed on the next poll"); + lh_test_poll_input(host); + CHECK(lh_test_read_input(host, 0) == 0x0000, + "and is gone by the poll after that"); + + // A bit held continuously stays set in every frame it's held, not just the + // first one after it went down. + lh_set_input(host, 0, 0x0002); + lh_test_poll_input(host); + CHECK(lh_test_read_input(host, 0) == 0x0002, "held bit observed frame 1"); + lh_test_poll_input(host); + CHECK(lh_test_read_input(host, 0) == 0x0002, "held bit still observed frame 2"); + lh_test_poll_input(host); + CHECK(lh_test_read_input(host, 0) == 0x0002, "held bit still observed frame 3"); + lh_set_input(host, 0, 0x0000); + // A release of a bit that was already observed clears on the very next + // poll. The latch no longer seeds pending with the level it read, so there + // is nothing left over to re-report: pending holds only edges since the + // last poll, and this release added none. A tap still survives (above), + // because its press did OR an edge in before the release landed. + lh_test_poll_input(host); + CHECK(lh_test_read_input(host, 0) == 0x0000, + "a release of an already-observed bit clears on the next poll"); + + // Simultaneous bits reported in one write must not tear: input_state_cb + // reads each id from the same latched snapshot, not a fresh read per id. + lh_set_input(host, 0, 0x8001); + lh_test_poll_input(host); + CHECK(lh_test_read_input(host, 0) == 0x8001, + "simultaneous bits in one write are not torn"); + lh_set_input(host, 0, 0x0000); + lh_test_poll_input(host); + + // Clearing with no poll between the press and the release still yields + // exactly one observation - this is an OR-latch, not "last write wins". + lh_set_input(host, 0, 0x0004); + lh_set_input(host, 0, 0x0000); + lh_test_poll_input(host); + CHECK(lh_test_read_input(host, 0) == 0x0004, + "clearing without an intervening poll still yields one observation"); + lh_test_poll_input(host); + CHECK(lh_test_read_input(host, 0) == 0x0000, "and is gone on the poll after that"); + + // Ports latch independently. + lh_set_input(host, 0, 0x0010); + lh_set_input(host, 1, 0x0020); + lh_test_poll_input(host); + CHECK(lh_test_read_input(host, 0) == 0x0010, "port 0 unaffected by port 1's write"); + CHECK(lh_test_read_input(host, 1) == 0x0020, "port 1 unaffected by port 0's write"); + + lh_destroy(host); +} + // A core that asks to quit must be left alone afterwards, since running it // again is what turns a failed boot into a native crash. static void test_shutdown(const char *core_path, const char *work_dir) { @@ -124,9 +209,12 @@ static void test_format(const char *core_path, const char *rom_path, const char *work_dir, lh_output_format fmt) { printf("format %s:\n", fmt == LH_FORMAT_RGBA8888 ? "RGBA" : "BGRA"); g_frames_ready = 0; - g_mask = 0x2A; lh_host *host = lh_create(fmt, make_callbacks()); + // Held continuously, so it's still in effect whichever frame the pixel + // check below happens to land on - this test is about pixel conversion, + // not the latch itself (see test_input_latch for that). + lh_set_input(host, 0, 0x2A); lh_av_info av; int rc = lh_load(host, core_path, rom_path, work_dir, work_dir, "testgame", NULL, NULL, 0, &av); @@ -168,7 +256,9 @@ static void test_format(const char *core_path, const char *rom_path, CHECK(read > 0, "audio produced"); if (fmt == LH_FORMAT_RGBA8888) { - CHECK(lh_option_count(host) == 1, "one core option"); + // stub_speed, stub_pattern, stub_rotation, stub_format, stub_huge_frame, + // stub_bad_pitch. + CHECK(lh_option_count(host) == 6, "six core options"); lh_option opt; int opt_rc = lh_get_option(host, 0, &opt); CHECK(opt_rc == 0 && strcmp(opt.id, "stub_speed") == 0, "option id"); @@ -179,10 +269,30 @@ static void test_format(const char *core_path, const char *rom_path, lh_pause(host); msleep(40); + unsigned gen_before = lh_restart_generation(host); + CHECK(lh_restart_async(host) == 0, "core restart schedules"); + // The flag is only checked at the top of the run loop, and the paused + // branch sleeps up to 16ms before rechecking it, so a caller cannot infer + // the restart has landed just because lh_restart_async returned. Poll the + // generation counter for a real barrier before reading post-restart state. + int waited_ms = 0; + while (lh_restart_generation(host) == gen_before && waited_ms < 2000) { + msleep(5); + waited_ms += 5; + } + CHECK(lh_restart_generation(host) != gen_before, + "core restart applied before timeout"); size_t size = lh_serialize_size(host); - CHECK(size > 0, "serialize size"); uint8_t blob_a[64], blob_b[64], blob_c[64]; - CHECK(lh_serialize(host, blob_a, size) == 0, "serialize"); + CHECK(size > 0, "serialize size"); + CHECK(lh_serialize(host, blob_a, size) == 0, "serialize after restart"); + CHECK(lh_option_count(host) == 6, "restart replaces option definitions"); + lh_get_option(host, 0, &opt); + CHECK(strcmp(opt.current, "fast") == 0, "restart retains option value"); + int32_t restart_marker; + memcpy(&restart_marker, blob_a + sizeof(int32_t), sizeof(restart_marker)); + CHECK(restart_marker == 1, + "restart applies option during core initialization"); lh_resume(host); msleep(80); lh_pause(host); @@ -199,6 +309,506 @@ static void test_format(const char *core_path, const char *rom_path, lh_destroy(host); } +// Reproduces the RETRO_ENVIRONMENT_SET_ROTATION semantics convert_frame +// implements: 0/1/2/3 are 0/90/180/270 degrees counter-clockwise. Rotation 1 +// sends the source's rightmost column to the top output row; rotation 3 +// (clockwise from the viewer's perspective) sends the leftmost column to the +// top row. This mirrors the spec, not convert_frame's internals, so it is an +// independent check rather than a restatement of the code under test. +static void rotate_point(int rotation, int src_w, int src_h, int x, int y, + int *out_row, int *out_col) { + switch (rotation) { + case 1: // 90 CCW + *out_row = (src_w - 1) - x; + *out_col = y; + break; + case 2: // 180 + *out_row = (src_h - 1) - y; + *out_col = (src_w - 1) - x; + break; + case 3: // 270 CCW + *out_row = x; + *out_col = (src_h - 1) - y; + break; + default: // 0 + *out_row = y; + *out_col = x; + break; + } +} + +// Reduces an 8-bit channel to the precision a 5- or 6-bit pixel format keeps +// and expands it back exactly as unpack_pixel does, so the expected value +// accounts for the format's lossy round trip instead of assuming full +// precision. +static unsigned quantize5(unsigned v) { + unsigned v5 = v >> 3; + return (v5 << 3) | (v5 >> 2); +} +static unsigned quantize6(unsigned v) { + unsigned v6 = v >> 2; + return (v6 << 2) | (v6 >> 4); +} + +// fmt_tag: 0 = xrgb8888 (full precision), 1 = rgb565, 2 = 0rgb1555. +static void expected_rgb(unsigned fmt_tag, int x, int y, unsigned *r, + unsigned *g, unsigned *b) { + unsigned raw_r = (unsigned)(x * 3) & 0xFF; + unsigned raw_g = (unsigned)(y * 5) & 0xFF; + unsigned raw_b = 0x11; + if (fmt_tag == 0) { + *r = raw_r; + *g = raw_g; + *b = raw_b; + } else if (fmt_tag == 1) { + *r = quantize5(raw_r); + *g = quantize6(raw_g); + *b = quantize5(raw_b); + } else { + *r = quantize5(raw_r); + *g = quantize5(raw_g); + *b = quantize5(raw_b); + } +} + +static void check_pixel(const uint8_t *px, int out_width, int row, int col, + unsigned exp_r, unsigned exp_g, unsigned exp_b, + const char *label) { + size_t off = ((size_t)row * (size_t)out_width + (size_t)col) * 4; + char msg[96]; + snprintf(msg, sizeof(msg), "%s -> row %d col %d", label, row, col); + CHECK(px[off] == exp_r && px[off + 1] == exp_g && px[off + 2] == exp_b, msg); +} + +// Drives the stub core's coordinate test pattern through one rotation/format +// combination and checks both corners (catches a swapped axis) and an +// interior asymmetric point (catches a transpose that corners alone would +// miss, since corner values are the same for the CW and CCW quarter turns). +static void test_rotation(const char *core_path, const char *rom_path, + const char *work_dir, int rotation, + const char *fmt_name, unsigned fmt_tag) { + printf("rotation %d (%s):\n", rotation, fmt_name); + + char rot_str[4]; + snprintf(rot_str, sizeof(rot_str), "%d", rotation); + const char *keys[] = {"stub_pattern", "stub_rotation", "stub_format"}; + const char *vals[] = {"on", rot_str, fmt_name}; + + lh_host *host = lh_create(LH_FORMAT_RGBA8888, make_callbacks()); + lh_av_info av; + int rc = lh_load(host, core_path, rom_path, work_dir, work_dir, "rotgame", + keys, vals, 3, &av); + CHECK(rc == 0, "rotation core loads"); + if (rc != 0) { + lh_destroy(host); + return; + } + + int quarter = rotation == 1 || rotation == 3; + int out_w = quarter ? SRC_H : SRC_W; + int out_h = quarter ? SRC_W : SRC_H; + CHECK(av.width == out_w && av.height == out_h, "rotated geometry"); + + lh_start(host); + msleep(200); + + const void *data; + int w, h, stride; + int got = lh_get_frame(host, &data, &w, &h, &stride); + CHECK(got == 1, "rotation frame available"); + if (got) { + CHECK(w == out_w && h == out_h, "rotation frame dimensions"); + const uint8_t *px = (const uint8_t *)data; + + struct { + int x, y; + const char *name; + } corners[] = { + {0, 0, "top-left source"}, + {SRC_W - 1, 0, "top-right source"}, + {0, SRC_H - 1, "bottom-left source"}, + {SRC_W - 1, SRC_H - 1, "bottom-right source"}, + }; + int max_index = -1; + for (size_t i = 0; i < sizeof(corners) / sizeof(corners[0]); i++) { + int row, col; + rotate_point(rotation, SRC_W, SRC_H, corners[i].x, corners[i].y, &row, + &col); + unsigned er, eg, eb; + expected_rgb(fmt_tag, corners[i].x, corners[i].y, &er, &eg, &eb); + check_pixel(px, out_w, row, col, er, eg, eb, corners[i].name); + int idx = row * out_w + col; + if (idx > max_index) max_index = idx; + } + // Every rotation is a bijection over the source rectangle, so the corner + // farthest from the origin always lands on the last destination pixel. + CHECK(max_index == out_w * out_h - 1, "max destination index"); + + // Interior point away from any symmetry axis: this is what actually + // distinguishes a 90-degree CW turn from CCW, which corners cannot. + int irow, icol; + rotate_point(rotation, SRC_W, SRC_H, 10, 3, &irow, &icol); + unsigned er, eg, eb; + expected_rgb(fmt_tag, 10, 3, &er, &eg, &eb); + check_pixel(px, out_w, irow, icol, er, eg, eb, "interior source"); + } + + lh_stop(host); + lh_destroy(host); +} + +// --------------------------------------------------------------------------- +// Regression test for the shutdown deadlock: run_job queues a job onto the +// emulation thread and waits on job->done; lh_stop clears h->running and +// joins that same thread. Before the fix, lh_stop's write and run_job's +// queue-or-execute check were unsynchronized, and run_loop only drained the +// queue inside its while(running) body - so a job that got queued in the +// narrow window around shutdown could wait on jobs_cond forever, hanging +// whatever thread called it (the ANR scenario: a UI thread doing a +// save-state right before exit). The fix serializes both decisions on +// jobs_lock and adds a final drain after the loop exits, which makes the +// race harmless rather than merely narrower - but this test still drives the +// exact concurrent pattern (a job-issuing thread racing lh_stop with no +// artificial delay) so a regression has a realistic chance of being caught +// again. +// --------------------------------------------------------------------------- + +typedef struct { + lh_host *host; + void *buf; + size_t size; + atomic_int done; +} stop_race_ctx; + +static void stop_race_run(stop_race_ctx *ctx) { + // The return value doesn't matter to this test - a job that gets dropped + // (e.g. by the full-queue path added alongside this fix) still has to + // complete and set done, it just completes with a failure result instead + // of hanging. Only "did this ever return" is being checked here. + lh_serialize(ctx->host, ctx->buf, ctx->size); + atomic_store(&ctx->done, 1); +} + +#ifdef _WIN32 +static DWORD WINAPI stop_race_thread(LPVOID arg) { + stop_race_run((stop_race_ctx *)arg); + return 0; +} +#else +static void *stop_race_thread(void *arg) { + stop_race_run((stop_race_ctx *)arg); + return NULL; +} +#endif + +static void test_stop_with_queued_job(const char *core_path, + const char *rom_path, + const char *work_dir) { + printf("stop races a queued job:\n"); + lh_host *host = lh_create(LH_FORMAT_RGBA8888, make_callbacks()); + lh_av_info av; + int rc = lh_load(host, core_path, rom_path, work_dir, work_dir, + "stopracegame", NULL, NULL, 0, &av); + CHECK(rc == 0, "core loads for stop-race test"); + if (rc != 0) { + lh_destroy(host); + return; + } + lh_start(host); + msleep(50); // let the run loop actually start iterating + + size_t size = lh_serialize_size(host); + uint8_t *buf = malloc(size > 0 ? size : 1); + stop_race_ctx ctx = {host, buf, size, 0}; + +#ifdef _WIN32 + HANDLE th = CreateThread(NULL, 0, stop_race_thread, &ctx, 0, NULL); +#else + pthread_t th; + pthread_create(&th, NULL, stop_race_thread, &ctx); +#endif + + // No sleep between spawning the helper thread and stopping: the race this + // test targets is exactly "run_job and lh_stop's running=0 happen at + // nearly the same instant", and inserting a delay here would only make it + // easier to accidentally avoid. + lh_stop(host); + + // A blocking join here would itself hang the whole test binary if this + // regressed, defeating the point of a test. Poll instead, so a regression + // reports FAIL and the harness still finishes (and reports failure). + int waited_ms = 0; + while (!atomic_load(&ctx.done) && waited_ms < 2000) { + msleep(5); + waited_ms += 5; + } + CHECK(atomic_load(&ctx.done), + "job thread returns instead of hanging when lh_stop races it"); + + if (atomic_load(&ctx.done)) { +#ifdef _WIN32 + WaitForSingleObject(th, INFINITE); + CloseHandle(th); +#else + pthread_join(th, NULL); +#endif + } + // If ctx.done never became true the helper thread is (by definition of the + // bug this guards against) permanently blocked on jobs_cond; joining it + // would hang the harness right after already reporting the failure, so it + // is deliberately leaked here and reclaimed by the OS at process exit. + + free(buf); + lh_destroy(host); +} + +// --------------------------------------------------------------------------- +// Regression tests for the unbounded/unvalidated frame geometry fixes: a +// frame the host must reject (either because a dimension is absurd, or +// because the pitch is too small to hold one real scanline) should be +// dropped cleanly - no crash, no delivered frame, no frame_ready signal - +// rather than converted into an undersized or overrun buffer. +// --------------------------------------------------------------------------- + +static void test_rejects_bad_frame(const char *core_path, const char *rom_path, + const char *work_dir, const char *var_key, + const char *game_id, const char *label) { + printf("%s:\n", label); + g_frames_ready = 0; + const char *keys[] = {var_key}; + const char *vals[] = {"on"}; + + lh_host *host = lh_create(LH_FORMAT_RGBA8888, make_callbacks()); + lh_av_info av; + int rc = lh_load(host, core_path, rom_path, work_dir, work_dir, game_id, + keys, vals, 1, &av); + CHECK(rc == 0, "core loads with the bad-frame variable set"); + if (rc == 0) { + lh_start(host); + msleep(150); + + const void *data; + int w, h, stride; + int got = lh_get_frame(host, &data, &w, &h, &stride); + CHECK(got == 0, "rejected frame never becomes available"); + CHECK(g_frames_ready == 0, "frame_ready is never signalled for a rejected frame"); + + lh_stop(host); + } + lh_destroy(host); +} + +// --------------------------------------------------------------------------- +// Regression test for the lh_get_option use-after-free: lh_get_option used to +// hand back pointers borrowed from h->defs and h->vars and then unlock, so the +// caller read them with nothing holding them alive. Two different threads free +// exactly those allocations - the emulation thread through restart_core -> +// free_option_definitions, and whichever thread calls lh_set_option through +// vars_set - which is the ordinary "restart the core, then open the options +// menu" sequence on Android, where the enumeration runs on the platform thread. +// +// The contract is now a caller-owned snapshot, so this test takes one, lets +// both invalidators run, and reads the snapshot afterwards. Under ASAN a +// regression reports heap-use-after-free; without ASAN the freed bytes usually +// still read back correctly, so the string comparisons below are the weaker +// backstop and the ASAN build is the real check. +// --------------------------------------------------------------------------- + +static void test_option_snapshot_survives_invalidation(const char *core_path, + const char *rom_path, + const char *work_dir) { + printf("option snapshot outlives its definitions:\n"); + lh_host *host = lh_create(LH_FORMAT_RGBA8888, make_callbacks()); + lh_av_info av; + int rc = lh_load(host, core_path, rom_path, work_dir, work_dir, "optlifegame", + NULL, NULL, 0, &av); + CHECK(rc == 0, "core loads for the option lifetime test"); + if (rc != 0) { + lh_destroy(host); + return; + } + lh_start(host); + msleep(50); + + // Invalidator 1: lh_set_option frees the previous value string. + lh_option before_set; + CHECK(lh_get_option(host, 0, &before_set) == 0, "snapshot before set_option"); + CHECK(strcmp(before_set.id, "stub_speed") == 0, "snapshot id"); + lh_set_option(host, "stub_speed", "fast"); + CHECK(strcmp(before_set.current, "normal") == 0, + "snapshot value survives lh_set_option freeing the old value"); + + // Invalidator 2: restart_core runs free_option_definitions on the emulation + // thread, freeing every id, label, and choice string the snapshot points at. + lh_option before_restart; + CHECK(lh_get_option(host, 0, &before_restart) == 0, "snapshot before restart"); + CHECK(before_restart.choice_count == 2, "snapshot choice count"); + + lh_pause(host); + msleep(40); + unsigned gen_before = lh_restart_generation(host); + CHECK(lh_restart_async(host) == 0, "restart schedules for the lifetime test"); + int waited_ms = 0; + while (lh_restart_generation(host) == gen_before && waited_ms < 2000) { + msleep(5); + waited_ms += 5; + } + CHECK(lh_restart_generation(host) != gen_before, "restart applied"); + + CHECK(strcmp(before_restart.id, "stub_speed") == 0, + "snapshot id survives a concurrent restart"); + CHECK(strcmp(before_restart.label, "Speed") == 0, + "snapshot label survives a concurrent restart"); + CHECK(strcmp(before_restart.current, "fast") == 0, + "snapshot value survives a concurrent restart"); + CHECK(before_restart.choice_count == 2 && + strcmp(before_restart.choices[0], "normal") == 0 && + strcmp(before_restart.choices[1], "fast") == 0, + "snapshot choices survive a concurrent restart"); + + // Out-of-range and NULL arguments must be rejected without writing anything. + lh_option unused; + CHECK(lh_get_option(host, -1, &unused) != 0, "negative index rejected"); + CHECK(lh_get_option(host, 9999, &unused) != 0, "past-the-end index rejected"); + CHECK(lh_get_option(host, 0, NULL) != 0, "NULL out param rejected"); + CHECK(lh_get_option(NULL, 0, &unused) != 0, "NULL host rejected"); + + lh_resume(host); + lh_stop(host); + lh_destroy(host); +} + +// --------------------------------------------------------------------------- +// Regression test for the GET_VARIABLE use-after-free. The host hands the core +// a raw pointer into h->vars and unlocks; the core is entitled to keep that +// pointer across frames. vars_set used to free the replaced value in place +// from whichever thread called lh_set_option - the platform thread, on the +// ordinary "change a core option in the pause menu" path - while the core on +// the emulation thread still held it. +// +// The stub core stashes the "stub_speed" pointer on its first frame and reads +// it again on the frame that observes GET_VARIABLE_UPDATE, which is exactly +// the frame after the lh_set_option below. It reports the result through the +// third word of its save state: 1 = the stashed pointer still read back +// correctly, 2 = the bytes had changed, 0 = the re-read never happened. Under +// ASAN a regression is a heap-use-after-free on that read; without ASAN the +// freed bytes usually still read back intact, so the assertion below is the +// weaker backstop and the ASAN build is the real check. +// --------------------------------------------------------------------------- + +static void test_option_value_outlives_set_option(const char *core_path, + const char *rom_path, + const char *work_dir) { + printf("option value outlives a concurrent set_option:\n"); + lh_host *host = lh_create(LH_FORMAT_RGBA8888, make_callbacks()); + lh_av_info av; + int rc = lh_load(host, core_path, rom_path, work_dir, work_dir, "varlifegame", + NULL, NULL, 0, &av); + CHECK(rc == 0, "core loads for the option value lifetime test"); + if (rc != 0) { + lh_destroy(host); + return; + } + lh_start(host); + msleep(60); // let the core stash the pointer on an early frame + + // The platform thread replaces the value the core is still holding. + lh_set_option(host, "stub_speed", "fast"); + msleep(80); // let the core observe the update and re-read its stash + + size_t size = lh_serialize_size(host); + uint8_t blob[64]; + CHECK(size >= sizeof(int32_t) * 3 && size <= sizeof(blob), + "stub state carries the stash result"); + if (size >= sizeof(int32_t) * 3 && size <= sizeof(blob)) { + CHECK(lh_serialize(host, blob, size) == 0, "serialize for stash result"); + int32_t stash_recheck; + memcpy(&stash_recheck, blob + sizeof(int32_t) * 2, sizeof(stash_recheck)); + CHECK(stash_recheck != 0, + "core re-read its stashed option pointer after the change"); + CHECK(stash_recheck == 1, + "the stashed GET_VARIABLE pointer is still readable and intact " + "after lh_set_option replaced the value"); + } + + // Retiring instead of freeing must not turn into an unbounded leak: every + // one of these 100 replaced strings has to be reclaimed by the drain in + // lh_stop. Under ASAN's leak checker a drain that misses entries shows up + // here. + for (int i = 0; i < 100; i++) { + lh_set_option(host, "stub_speed", (i & 1) ? "normal" : "fast"); + } + + lh_stop(host); + lh_destroy(host); +} + +// --------------------------------------------------------------------------- +// Regression test for the truncated-SRAM-file fix: sram_load used to ignore +// fread's return value, so a .srm shorter than the core's declared SAVE_RAM +// size silently left the tail of the buffer at whatever retro_init put +// there instead of reporting anything. It now logs a warning through +// cb.log_message on a short read (and stays silent on a full one). +// --------------------------------------------------------------------------- + +// The core's own load path (SET_ROTATION, etc.) logs unconditionally, so the +// signal to look for is specifically the sram_load warning, not "was +// log_message called at all". +static int g_saw_sram_warning; + +static void on_log_message(void *user, const char *message) { + (void)user; + if (message && strstr(message, "sram_load") != NULL) { + g_saw_sram_warning = 1; + } +} + +static void test_truncated_sram_warns(const char *core_path, + const char *rom_path, + const char *work_dir) { + printf("truncated SRAM file logs a warning:\n"); + + // The stub core's SAVE_RAM is 64 bytes (see stub_core.c); write far fewer + // so sram_load's fread comes back short. + char srm_path[1024]; + snprintf(srm_path, sizeof(srm_path), "%s/%s.srm", work_dir, "sramtruncgame"); + FILE *f = fopen(srm_path, "wb"); + CHECK(f != NULL, "truncated .srm file created"); + if (f) { + uint8_t partial[10] = {0}; + fwrite(partial, 1, sizeof(partial), f); + fclose(f); + } + + g_saw_sram_warning = 0; + lh_callbacks cb = make_callbacks(); + cb.message = on_log_message; + lh_host *host = lh_create(LH_FORMAT_RGBA8888, cb); + lh_av_info av; + int rc = lh_load(host, core_path, rom_path, work_dir, work_dir, + "sramtruncgame", NULL, NULL, 0, &av); + CHECK(rc == 0, "core loads with a truncated SRAM file present"); + CHECK(g_saw_sram_warning, "a short SRAM read is logged"); + lh_destroy(host); + + // A full-length file must load quietly - no false positives on the + // ordinary path. + f = fopen(srm_path, "wb"); + CHECK(f != NULL, "full-length .srm file created"); + if (f) { + uint8_t full[64] = {0}; + fwrite(full, 1, sizeof(full), f); + fclose(f); + } + g_saw_sram_warning = 0; + lh_host *host2 = lh_create(LH_FORMAT_RGBA8888, cb); + rc = lh_load(host2, core_path, rom_path, work_dir, work_dir, + "sramtruncgame", NULL, NULL, 0, &av); + CHECK(rc == 0, "core loads with a full-length SRAM file present"); + CHECK(!g_saw_sram_warning, "a full-length SRAM read stays quiet"); + lh_destroy(host2); +} + int main(int argc, char **argv) { if (argc < 3) { printf("usage: %s \n", argv[0]); @@ -219,10 +829,65 @@ int main(int argc, char **argv) { CHECK(bad_rc != 0, "missing rom fails load"); lh_destroy(bad); + test_input_latch(); + test_format(core_path, rom_path, work_dir, LH_FORMAT_RGBA8888); test_format(core_path, rom_path, work_dir, LH_FORMAT_BGRA8888); test_shutdown(core_path, work_dir); + // All four rotations at full precision: rotation is the risky axis for + // convert_frame, so it gets complete coverage against one format. + test_rotation(core_path, rom_path, work_dir, 0, "xrgb8888", 0); + test_rotation(core_path, rom_path, work_dir, 1, "xrgb8888", 0); + test_rotation(core_path, rom_path, work_dir, 2, "xrgb8888", 0); + test_rotation(core_path, rom_path, work_dir, 3, "xrgb8888", 0); + // Quarter turns again against the two reduced-precision formats, where the + // index math and the unpack shifts interact. + test_rotation(core_path, rom_path, work_dir, 1, "rgb565", 1); + test_rotation(core_path, rom_path, work_dir, 3, "rgb565", 1); + test_rotation(core_path, rom_path, work_dir, 1, "0rgb1555", 2); + test_rotation(core_path, rom_path, work_dir, 3, "0rgb1555", 2); + + test_stop_with_queued_job(core_path, rom_path, work_dir); + + test_option_snapshot_survives_invalidation(core_path, rom_path, work_dir); + + test_option_value_outlives_set_option(core_path, rom_path, work_dir); + + test_rejects_bad_frame(core_path, rom_path, work_dir, "stub_huge_frame", + "hugeframegame", "oversized geometry frame rejected"); + test_rejects_bad_frame(core_path, rom_path, work_dir, "stub_bad_pitch", + "badpitchgame", "undersized pitch frame rejected"); + + test_truncated_sram_warns(core_path, rom_path, work_dir); + + // Regression test for the SRAM path traversal fix: game_id flows straight + // into sram_path (save_dir/game_id.srm), and ultimately originates from a + // route parameter seeded by server data, so it must be rejected outright + // rather than sanitized whenever it could escape save_dir. + printf("game_id path traversal:\n"); + lh_host *traversal_slash = lh_create(LH_FORMAT_RGBA8888, make_callbacks()); + lh_av_info traversal_av; + int traversal_rc = + lh_load(traversal_slash, core_path, rom_path, work_dir, work_dir, + "../escape", NULL, NULL, 0, &traversal_av); + CHECK(traversal_rc == -8, "game_id containing .. is rejected"); + lh_destroy(traversal_slash); + + lh_host *traversal_backslash = lh_create(LH_FORMAT_RGBA8888, make_callbacks()); + int traversal_rc2 = + lh_load(traversal_backslash, core_path, rom_path, work_dir, work_dir, + "sub\\dir", NULL, NULL, 0, &traversal_av); + CHECK(traversal_rc2 == -8, "game_id containing a backslash is rejected"); + lh_destroy(traversal_backslash); + + lh_host *traversal_ok = lh_create(LH_FORMAT_RGBA8888, make_callbacks()); + int traversal_rc3 = lh_load(traversal_ok, core_path, rom_path, work_dir, + work_dir, "normal-game-id", NULL, NULL, 0, + &traversal_av); + CHECK(traversal_rc3 == 0, "an ordinary game_id still loads"); + lh_destroy(traversal_ok); + printf("\n%s (%d failure%s)\n", g_failures == 0 ? "PASS" : "FAIL", g_failures, g_failures == 1 ? "" : "s"); return g_failures == 0 ? 0 : 1; diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index bd20dfcff..cab0bd690 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -190,7 +190,8 @@ bool FlutterWindow::OnCreate() { native_game_ = std::make_unique( flutter_controller_->engine()->messenger(), - native_game_registrar_->texture_registrar()); + native_game_registrar_->texture_registrar(), + native_game_registrar_.get()); if (!g_hdr_display_channel) { g_hdr_display_channel = diff --git a/windows/runner/native_game.cpp b/windows/runner/native_game.cpp index 558f25b5b..10592ddcb 100644 --- a/windows/runner/native_game.cpp +++ b/windows/runner/native_game.cpp @@ -32,11 +32,49 @@ int GetInt(const flutter::EncodableMap& map, const char* key, int fallback) { // A pulse bit is held briefly so the overlay can send Start or Select. std::atomic g_pulse_mask{0}; +// Mirrors NativeGame::mask_ and host_ at namespace scope so pulseButton's +// detached timer thread (below) can push a combined mask into the host's +// input latch without capturing `this` - the thread can outlive the +// NativeGame instance across a stop/reload, and this was already the reason +// g_pulse_mask itself lives here rather than on the instance. +std::atomic g_dart_mask{0}; +std::atomic g_input_host{nullptr}; + +// Pushes the combined Dart + pulse mask into the host's input latch. Called +// on every write to either half, not once per frame: the host OR-latches +// whatever it's told between polls, so it needs every edge, not a level +// sampled only when the core happens to poll (see lh_set_input's comment). +void PushInput() { + lh_host* host = g_input_host.load(); + if (host) { + lh_set_input(host, 0, + static_cast(g_dart_mask.load() | g_pulse_mask.load())); + } +} + +// Custom top-level window message used to wake the platform thread when a +// fatal error is queued from the emulation thread. WM_APP..0xBFFF is reserved +// for private application use. +constexpr UINT kFatalErrorMessage = WM_APP + 1; + } // namespace NativeGame::NativeGame(flutter::BinaryMessenger* messenger, - flutter::TextureRegistrar* textures) + flutter::TextureRegistrar* textures, + flutter::PluginRegistrarWindows* registrar) : textures_(textures) { + if (registrar && registrar->GetView()) { + // The Flutter view's HWND is a child of the top-level runner window; + // walk up to the root so PostMessage reaches the window whose WndProc + // the delegate below is hooked into. + hwnd_ = GetAncestor(registrar->GetView()->GetNativeWindow(), GA_ROOT); + registrar->RegisterTopLevelWindowProcDelegate( + [this](HWND, UINT message, WPARAM, LPARAM) -> std::optional { + if (message != kFatalErrorMessage) return std::nullopt; + FlushPendingError(); + return 0; + }); + } control_ = std::make_unique>( messenger, "moonfin/native_game_control", &flutter::StandardMethodCodec::GetInstance()); @@ -47,17 +85,23 @@ NativeGame::NativeGame(flutter::BinaryMessenger* messenger, [this](const auto& call, auto result) { HandleMethod(call, std::move(result)); }); - // Input and the overlay are driven from Dart on desktop, so the event stream - // stays open but never fires. + // Input and the overlay are driven from Dart on desktop, so the event + // stream's only current producer is FlushPendingError. Listen/cancel run + // on the platform thread, same as every other event_sink_ access. events_->SetStreamHandler( std::make_unique>( - [](const flutter::EncodableValue*, - std::unique_ptr>&&) + [this](const flutter::EncodableValue*, + std::unique_ptr>&& + events) -> std::unique_ptr> { + std::lock_guard lock(event_mutex_); + event_sink_ = std::move(events); return nullptr; }, - [](const flutter::EncodableValue*) + [this](const flutter::EncodableValue*) -> std::unique_ptr> { + std::lock_guard lock(event_mutex_); + event_sink_.reset(); return nullptr; })); } @@ -67,6 +111,10 @@ NativeGame::~NativeGame() { Stop(); } void NativeGame::HandleMethod( const flutter::MethodCall& call, std::unique_ptr> result) { + // Every call arrives on the platform thread, so this is a safe place to + // flush an error the emulation thread may have queued. + FlushPendingError(); + const auto* args = std::get_if(call.arguments()); const flutter::EncodableMap empty; const flutter::EncodableMap& map = args ? *args : empty; @@ -84,8 +132,11 @@ void NativeGame::HandleMethod( if (host_) lh_resume(host_); result->Success(); } else if (method == "restart") { - if (host_) lh_reset(host_); - result->Success(); + if (host_ && lh_restart_async(host_) == 0) { + result->Success(); + } else { + result->Error("restart_unavailable", "The emulator is not running."); + } } else if (method == "stop") { Stop(); result->Success(); @@ -93,7 +144,8 @@ void NativeGame::HandleMethod( if (host_) lh_set_fast_forward(host_, GetInt(map, "factor", 1)); result->Success(); } else if (method == "setInput") { - mask_ = static_cast(GetInt(map, "mask", 0)); + g_dart_mask = static_cast(GetInt(map, "mask", 0)); + PushInput(); result->Success(); } else if (method == "pulseButton") { int index = GetInt(map, "index", -1); @@ -101,9 +153,11 @@ void NativeGame::HandleMethod( if (index >= 0 && index < 16) { uint16_t bit = static_cast(1 << index); g_pulse_mask |= bit; + PushInput(); std::thread([bit, duration]() { std::this_thread::sleep_for(std::chrono::milliseconds(duration)); g_pulse_mask &= static_cast(~bit); + PushInput(); }).detach(); } result->Success(); @@ -169,12 +223,17 @@ flutter::EncodableValue NativeGame::Load(const flutter::EncodableMap& args) { lh_callbacks cb = {}; cb.user = this; cb.frame_ready = &NativeGame::OnFrameReady; - cb.poll_input = &NativeGame::OnPollInput; cb.controller_count = &NativeGame::OnControllerCount; + cb.fatal_error = &NativeGame::OnFatalError; cb.message = &NativeGame::OnCoreMessage; cb.core_shutdown = &NativeGame::OnCoreShutdown; host_ = lh_create(LH_FORMAT_RGBA8888, cb); + if (!host_) { + // calloc failure inside lh_create; nothing to load into. + return flutter::EncodableValue(); + } + g_input_host = host_; lh_av_info info = {}; int rc = lh_load(host_, core_path.c_str(), rom_path.c_str(), system_dir.c_str(), save_dir.c_str(), game_id.c_str(), @@ -207,7 +266,11 @@ flutter::EncodableValue NativeGame::Options(bool current_only) { int count = lh_option_count(host_); for (int i = 0; i < count; i++) { lh_option opt; - if (lh_get_option(host_, i, &opt) != 0) continue; + // A restart on the emulation thread can shrink the list between the + // count and this read, so a failure means "no more options", not "skip + // this one". opt is a self-contained copy; nothing below borrows from + // the host. + if (lh_get_option(host_, i, &opt) != 0) break; if (current_only) { current[flutter::EncodableValue(std::string(opt.id))] = flutter::EncodableValue(std::string(opt.current)); @@ -235,23 +298,51 @@ flutter::EncodableValue NativeGame::Options(bool current_only) { void NativeGame::Stop() { lh_audio_stop(); - if (host_) { - lh_stop(host_); - lh_destroy(host_); - host_ = nullptr; + { + // Pairs with the lock in CopyPixelBuffer: while this block runs, the + // raster thread either already finished reading host_ before we got + // here, or blocks on host_mutex_ until we're done and then observes + // host_ == nullptr - it can never see a pointer lh_destroy is mid-free + // on. lh_stop joins the host's own worker thread, not the raster or + // platform thread, so holding the lock across it cannot deadlock. + std::lock_guard lock(host_mutex_); + if (host_) { + g_input_host = nullptr; + lh_stop(host_); + lh_destroy(host_); + host_ = nullptr; + } } if (texture_id_ != -1) { textures_->UnregisterTexture(texture_id_, nullptr); texture_id_ = -1; texture_.reset(); } - mask_ = 0; + g_dart_mask = 0; + g_pulse_mask = 0; } const FlutterDesktopPixelBuffer* NativeGame::CopyPixelBuffer(size_t width, size_t height) { (void)width; (void)height; + // Runs on the raster thread. Pairs with the lock in Stop(): guarantees + // host_ is either the live pointer Stop() hasn't started tearing down yet, + // or nullptr after Stop() has fully destroyed it - never a pointer + // lh_destroy is mid-free on. lh_get_frame only swaps the front/back frame + // pointers under its own internal lock, so holding host_mutex_ across the + // call does not serialize the actual per-frame pixel copy, which Flutter + // performs after this function returns. + // + // KNOWN REMAINING HOLE: pixel_buffer_.buffer below points directly at + // host-owned memory (lh_get_frame hands back h->front.data), and Flutter + // reads it after this function returns, i.e. after the lock is released. + // A Stop() landing in that window frees the framebuffer while the raster + // thread is still reading it. Narrower than the dangling-host_ race this + // lock fixes, but the same class. Closing it needs either a staging copy + // taken under the lock, or FlutterDesktopPixelBuffer::release_callback to + // keep the host alive until the embedder is done with the buffer. + std::lock_guard lock(host_mutex_); const void* data; int w, h, stride; if (!host_ || !lh_get_frame(host_, &data, &w, &h, &stride)) return nullptr; @@ -268,17 +359,47 @@ void NativeGame::OnFrameReady(void* user) { } } -uint16_t NativeGame::OnPollInput(void* user, int port) { - if (port != 0) return 0; - auto* self = static_cast(user); - return static_cast(self->mask_.load() | g_pulse_mask.load()); -} - int NativeGame::OnControllerCount(void* user) { (void)user; return 1; } +// The emulation thread is dying from an unrecoverable error (e.g. a failed +// core restart). Called from the run-loop thread. The Flutter Windows +// embedder requires event-sink sends on the platform thread, so the message +// is recorded here and then a custom window message wakes the platform +// thread to flush it immediately, rather than waiting for the next inbound +// control call (which may never come while Dart is otherwise idle). +void NativeGame::OnFatalError(void* user, const char* message) { + auto* self = static_cast(user); + { + std::lock_guard lock(self->event_mutex_); + self->pending_error_ = std::string(message ? message : ""); + } + if (self->hwnd_) { + PostMessage(self->hwnd_, kFatalErrorMessage, 0, 0); + } +} + +void NativeGame::FlushPendingError() { + std::string message; + flutter::EventSink* sink = nullptr; + { + std::lock_guard lock(event_mutex_); + if (!pending_error_) return; + message = std::move(*pending_error_); + pending_error_.reset(); + sink = event_sink_.get(); + } + // Sent outside the lock in case the sink re-enters NativeGame. + if (sink) { + sink->Success(flutter::EncodableValue(flutter::EncodableMap{ + {flutter::EncodableValue("event"), flutter::EncodableValue("error")}, + {flutter::EncodableValue("message"), flutter::EncodableValue(message)}, + })); + } +} + // Nothing listens on the desktop event channel, so a core that complains or // quits says so in the log instead. void NativeGame::OnCoreMessage(void* user, const char* text) { diff --git a/windows/runner/native_game.h b/windows/runner/native_game.h index cfa95645e..df05f2a8e 100644 --- a/windows/runner/native_game.h +++ b/windows/runner/native_game.h @@ -1,15 +1,20 @@ #ifndef RUNNER_NATIVE_GAME_H_ #define RUNNER_NATIVE_GAME_H_ +#include + #include #include #include #include #include +#include #include -#include #include +#include +#include +#include #include "libretro_host.h" @@ -18,8 +23,14 @@ // and takes a RetroPad mask from Dart. class NativeGame { public: + // |registrar| is used to (a) find the top-level window's HWND, so a fatal + // error raised from the emulation thread can be posted to it, and (b) + // register a WindowProc delegate that flushes the error when that message + // arrives on the platform thread. May be null in tests; fatal errors then + // fall back to the next inbound control-channel call, as before. NativeGame(flutter::BinaryMessenger* messenger, - flutter::TextureRegistrar* textures); + flutter::TextureRegistrar* textures, + flutter::PluginRegistrarWindows* registrar); ~NativeGame(); private: @@ -29,10 +40,13 @@ class NativeGame { flutter::EncodableValue Options(bool current_only); void Stop(); const FlutterDesktopPixelBuffer* CopyPixelBuffer(size_t width, size_t height); + // Delivers a fatal-error event queued by OnFatalError, if any. Must only be + // called from the platform thread. + void FlushPendingError(); static void OnFrameReady(void* user); - static uint16_t OnPollInput(void* user, int port); static int OnControllerCount(void* user); + static void OnFatalError(void* user, const char* message); static void OnCoreMessage(void* user, const char* text); static void OnCoreShutdown(void* user); @@ -40,11 +54,43 @@ class NativeGame { std::unique_ptr> control_; std::unique_ptr> events_; + // Guards event_sink_ and pending_error_. event_sink_ is written from the + // platform thread (listen/cancel) and, without this lock, would be read + // from the emulation thread by OnFatalError; the Flutter Windows embedder + // also requires event-sink sends to happen on the platform thread. So + // OnFatalError (emulation thread) never touches event_sink_ directly: it + // only records pending_error_, which FlushPendingError (platform thread, + // called from HandleMethod) drains and sends. + std::mutex event_mutex_; + // Captured on listen so FlushPendingError can push an unsolicited event; + // the overlay's own input/geometry are still driven from Dart, so this is + // the only producer today. + std::unique_ptr> event_sink_; + // Set by OnFatalError, drained by FlushPendingError. Delivery is deferred + // to the next control-channel call because that is the next guaranteed + // platform-thread entry point. + std::optional pending_error_; + + // Top-level window HWND, used to post kFatalErrorMessage from the + // emulation thread; null if no registrar was supplied. + HWND hwnd_ = nullptr; + + // Guards host_ against the race between CopyPixelBuffer (called by Flutter + // on the raster thread) and Stop() tearing the host down (called on the + // platform thread from HandleMethod or the destructor). Unregistering the + // texture is not a barrier against a CopyPixelBuffer call already in + // flight, so the pointer itself has to be protected, not just the texture + // registration - otherwise the raster thread can read host_ and call into + // lh_get_frame after Stop() has freed it. Held only across the pointer + // check plus lh_get_frame's front/back pointer swap (not the pixel copy + // Flutter performs afterwards with the returned buffer), and across + // lh_destroy in Stop(), so it adds negligible per-frame contention. + std::mutex host_mutex_; + lh_host* host_ = nullptr; int64_t texture_id_ = -1; std::unique_ptr texture_; FlutterDesktopPixelBuffer pixel_buffer_ = {}; - std::atomic mask_{0}; }; #endif // RUNNER_NATIVE_GAME_H_ From 646df12cf36f22d75d2740cbcc03ef9e6f6bdf75 Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Thu, 6 Aug 2026 23:17:50 -0400 Subject: [PATCH 2/9] feat(input): give native Android gameplay its own low-latency input path Forwards the pad as one 16-bit mask per event, and recovers a direction whose release was lost or orphaned by a re-enumeration. --- android/app/src/main/cpp/native_game_jni.c | 355 ++++++++++-- .../androidtv/AndroidGamepadIdentity.kt | 27 + .../org/moonfin/androidtv/GameInputRouter.kt | 323 +++++++++++ .../org/moonfin/androidtv/LibretroBridge.kt | 186 ++++-- .../org/moonfin/androidtv/MainActivity.kt | 248 ++++---- .../org/moonfin/androidtv/NativePadInput.kt | 533 ++++++++++++++++++ 6 files changed, 1445 insertions(+), 227 deletions(-) create mode 100644 android/app/src/main/kotlin/org/moonfin/androidtv/AndroidGamepadIdentity.kt create mode 100644 android/app/src/main/kotlin/org/moonfin/androidtv/GameInputRouter.kt create mode 100644 android/app/src/main/kotlin/org/moonfin/androidtv/NativePadInput.kt diff --git a/android/app/src/main/cpp/native_game_jni.c b/android/app/src/main/cpp/native_game_jni.c index f0ed89e86..b4b25d938 100644 --- a/android/app/src/main/cpp/native_game_jni.c +++ b/android/app/src/main/cpp/native_game_jni.c @@ -24,10 +24,10 @@ typedef struct { ANativeWindow *window; int window_width; int window_height; - atomic_uint mask[4]; JavaVM *vm; jobject bridge; jmethodID on_geometry; + jmethodID on_error; jmethodID on_core_message; jmethodID on_core_shutdown; pthread_t render_thread; @@ -108,58 +108,86 @@ static void frame_ready(void *user) { atomic_store(&c->frame_dirty, 1); } -static uint16_t poll_input(void *user, int port) { - native_ctx *c = (native_ctx *)user; - if (port < 0 || port >= 4) return 0; - return (uint16_t)atomic_load(&c->mask[port]); -} - static int controller_count(void *user) { (void)user; return 1; } -// Host callbacks arrive on the emulation thread once the game runs, and on the -// platform thread while the core is still loading. Only an attach we made is -// ours to undo, since detaching the platform thread would break every later JNI -// call on it. -static JNIEnv *jni_enter(native_ctx *c, int *attached) { +// Detaches the calling native thread from the JVM when that thread exits. +// Registered as the destructor for g_geometry_thread_key below, so a thread +// that attached via get_geometry_thread_env never has to detach explicitly - +// pthread runs this automatically as part of thread teardown. +static void detach_on_thread_exit(void *value) { + (void)value; + if (g_ctx.vm) (*g_ctx.vm)->DetachCurrentThread(g_ctx.vm); +} + +static pthread_key_t g_geometry_thread_key; +static pthread_once_t g_geometry_thread_key_once = PTHREAD_ONCE_INIT; + +static void make_geometry_thread_key(void) { + pthread_key_create(&g_geometry_thread_key, detach_on_thread_exit); +} + +// Returns a JNIEnv* for the calling thread, attaching as a daemon thread at +// most once per native thread rather than once per call. Cores can call +// SET_GEOMETRY every frame; attaching/detaching a java.lang.Thread on every +// one of those (up to 60x/second) is wasted work the JVM has to do and undo. +// The thread stays attached until it exits, at which point +// detach_on_thread_exit runs via the pthread key destructor. +static JNIEnv *get_geometry_thread_env(void) { + if (!g_ctx.vm) return NULL; JNIEnv *env = NULL; - *attached = 0; - if ((*c->vm)->GetEnv(c->vm, (void **)&env, JNI_VERSION_1_6) == JNI_OK) { - return env; + jint state = (*g_ctx.vm)->GetEnv(g_ctx.vm, (void **)&env, JNI_VERSION_1_6); + if (state == JNI_OK) return env; + if (state != JNI_EDETACHED) return NULL; + + pthread_once(&g_geometry_thread_key_once, make_geometry_thread_key); + if ((*g_ctx.vm)->AttachCurrentThreadAsDaemon(g_ctx.vm, &env, NULL) != JNI_OK) { + return NULL; } - if ((*c->vm)->AttachCurrentThread(c->vm, &env, NULL) != JNI_OK) return NULL; - *attached = 1; + // Any non-NULL value marks this thread as attached for the key's + // destructor; the value itself is never read back. + pthread_setspecific(g_geometry_thread_key, (void *)1); return env; } -static void jni_leave(native_ctx *c, int attached) { - if (attached) (*c->vm)->DetachCurrentThread(c->vm); -} - static void geometry_changed(void *user, int width, int height, double aspect) { native_ctx *c = (native_ctx *)user; if (!c->vm || !c->bridge || !c->on_geometry) return; - int attached; - JNIEnv *env = jni_enter(c, &attached); + JNIEnv *env = get_geometry_thread_env(); if (!env) return; (*env)->CallVoidMethod(env, c->bridge, c->on_geometry, width, height, aspect); - jni_leave(c, attached); } + +static void fatal_error(void *user, const char *message) { + native_ctx *c = (native_ctx *)user; + if (message) LOGE("fatal: %s", message); + if (!c->vm || !c->bridge || !c->on_error) return; + JNIEnv *env = NULL; + int attached_here = 0; + jint state = (*c->vm)->GetEnv(c->vm, (void **)&env, JNI_VERSION_1_6); + if (state == JNI_EDETACHED) { + if ((*c->vm)->AttachCurrentThread(c->vm, &env, NULL) != JNI_OK) return; + attached_here = 1; + } else if (state != JNI_OK) { + return; + } + jstring jmessage = (*env)->NewStringUTF(env, message ? message : ""); + (*env)->CallVoidMethod(env, c->bridge, c->on_error, jmessage); + (*env)->DeleteLocalRef(env, jmessage); + if (attached_here) (*c->vm)->DetachCurrentThread(c->vm); static void core_message(void *user, const char *text) { native_ctx *c = (native_ctx *)user; if (!c->vm || !c->bridge || !c->on_core_message || !text) return; - int attached; - JNIEnv *env = jni_enter(c, &attached); + JNIEnv *env = get_geometry_thread_env(); if (!env) return; jstring message = (*env)->NewStringUTF(env, text); if (message) { (*env)->CallVoidMethod(env, c->bridge, c->on_core_message, message); (*env)->DeleteLocalRef(env, message); } - jni_leave(c, attached); } // Kotlin ends the session from the main thread, since this runs on the @@ -167,11 +195,9 @@ static void core_message(void *user, const char *text) { static void core_shutdown(void *user) { native_ctx *c = (native_ctx *)user; if (!c->vm || !c->bridge || !c->on_core_shutdown) return; - int attached; - JNIEnv *env = jni_enter(c, &attached); + JNIEnv *env = get_geometry_thread_env(); if (!env) return; (*env)->CallVoidMethod(env, c->bridge, c->on_core_shutdown); - jni_leave(c, attached); } static void teardown(JNIEnv *env) { @@ -196,6 +222,7 @@ static void teardown(JNIEnv *env) { g_ctx.bridge = NULL; } g_ctx.on_geometry = NULL; + g_ctx.on_error = NULL; g_ctx.on_core_message = NULL; g_ctx.on_core_shutdown = NULL; } @@ -209,6 +236,27 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { return JNI_VERSION_1_6; } +// Releases everything nativeLoad may have partially collected for the option +// arrays and clears any pending exception, so every early-return path below +// can share one cleanup instead of duplicating it. Safe to call with any +// prefix of the arrays populated: unfilled slots are NULL/zero because keys/ +// vals/key_refs/val_refs are all calloc'd, and ReleaseStringUTFChars/ +// DeleteLocalRef on a NULL jstring/pointer is a no-op per the JNI spec. +static void release_options(JNIEnv *env, int count, const char **keys, + const char **vals, jstring *key_refs, + jstring *val_refs) { + for (int i = 0; i < count; i++) { + if (keys && keys[i]) (*env)->ReleaseStringUTFChars(env, key_refs[i], keys[i]); + if (vals && vals[i]) (*env)->ReleaseStringUTFChars(env, val_refs[i], vals[i]); + if (key_refs && key_refs[i]) (*env)->DeleteLocalRef(env, key_refs[i]); + if (val_refs && val_refs[i]) (*env)->DeleteLocalRef(env, val_refs[i]); + } + free((void *)keys); + free((void *)vals); + free(key_refs); + free(val_refs); +} + JNI(jdoubleArray, nativeLoad)( JNIEnv *env, jobject thiz, jstring core, jstring corePath, jstring romPath, jstring systemDir, jstring saveDir, jstring gameId, jobjectArray optKeys, @@ -216,42 +264,165 @@ JNI(jdoubleArray, nativeLoad)( (void)core; teardown(env); + // optVals is indexed below with the same loop bound derived from optKeys. + // A caller passing arrays of different lengths would make + // GetObjectArrayElement throw ArrayIndexOutOfBoundsException partway + // through that loop and return NULL; the old code went on to call + // GetStringUTFChars on that NULL jstring anyway, which is illegal with an + // exception already pending. Reject the mismatch up front so the loop + // below never has to discover it mid-iteration. + jsize opt_count = optKeys ? (*env)->GetArrayLength(env, optKeys) : 0; + jsize val_count = optVals ? (*env)->GetArrayLength(env, optVals) : 0; + if (opt_count != val_count) { + LOGE("nativeLoad: optKeys/optVals length mismatch (%d vs %d)", + (int)opt_count, (int)val_count); + return NULL; + } + lh_callbacks cb; memset(&cb, 0, sizeof(cb)); cb.user = &g_ctx; cb.frame_ready = frame_ready; - cb.poll_input = poll_input; cb.controller_count = controller_count; cb.geometry_changed = geometry_changed; cb.message = core_message; cb.core_shutdown = core_shutdown; + cb.fatal_error = fatal_error; g_ctx.host = lh_create(LH_FORMAT_RGBA8888, cb); - for (int i = 0; i < 4; i++) atomic_store(&g_ctx.mask[i], 0); + if (!g_ctx.host) { + LOGE("Could not allocate libretro host"); + return NULL; + } g_ctx.bridge = (*env)->NewGlobalRef(env, thiz); + if (!g_ctx.bridge) { + // NewGlobalRef returns NULL (and throws OutOfMemoryError) rather than + // failing loudly; every callback below dereferences g_ctx.bridge, so + // bail out before any further JNI call runs with that exception pending. + LOGE("nativeLoad: NewGlobalRef(thiz) failed"); + (*env)->ExceptionClear(env); + teardown(env); + return NULL; + } jclass cls = (*env)->GetObjectClass(env, thiz); g_ctx.on_geometry = (*env)->GetMethodID(env, cls, "onGeometry", "(IID)V"); + if ((*env)->ExceptionCheck(env)) { + // GetMethodID throws NoSuchMethodError on failure; clear it before the + // next JNI call rather than letting it ride into GetStringUTFChars below. + (*env)->ExceptionClear(env); + LOGE("nativeLoad: GetMethodID(onGeometry) failed"); + (*env)->DeleteLocalRef(env, cls); + teardown(env); + return NULL; + } + g_ctx.on_error = + (*env)->GetMethodID(env, cls, "onError", "(Ljava/lang/String;)V"); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + LOGE("nativeLoad: GetMethodID(onError) failed"); + (*env)->DeleteLocalRef(env, cls); + teardown(env); + return NULL; + } g_ctx.on_core_message = (*env)->GetMethodID(env, cls, "onCoreMessage", "(Ljava/lang/String;)V"); g_ctx.on_core_shutdown = (*env)->GetMethodID(env, cls, "onCoreShutdown", "()V"); + (*env)->DeleteLocalRef(env, cls); const char *c_core_path = (*env)->GetStringUTFChars(env, corePath, NULL); const char *c_rom = (*env)->GetStringUTFChars(env, romPath, NULL); const char *c_sys = (*env)->GetStringUTFChars(env, systemDir, NULL); const char *c_save = (*env)->GetStringUTFChars(env, saveDir, NULL); const char *c_id = (*env)->GetStringUTFChars(env, gameId, NULL); + // GetStringUTFChars returns NULL and throws OutOfMemoryError if the JVM + // can't allocate the UTF-8 copy. Walking into lh_load with a NULL path + // would segfault inside strlen/lh_strdup, and making any further JNI call + // (including the option-array loop below) with the exception still + // pending is illegal per the JNI spec, so check and clear it here, before + // anything else touches env. + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + LOGE("nativeLoad: GetStringUTFChars failed for one of the load paths"); + if (c_core_path) (*env)->ReleaseStringUTFChars(env, corePath, c_core_path); + if (c_rom) (*env)->ReleaseStringUTFChars(env, romPath, c_rom); + if (c_sys) (*env)->ReleaseStringUTFChars(env, systemDir, c_sys); + if (c_save) (*env)->ReleaseStringUTFChars(env, saveDir, c_save); + if (c_id) (*env)->ReleaseStringUTFChars(env, gameId, c_id); + teardown(env); + return NULL; + } - int opt_count = optKeys ? (*env)->GetArrayLength(env, optKeys) : 0; const char **keys = opt_count ? calloc(opt_count, sizeof(char *)) : NULL; const char **vals = opt_count ? calloc(opt_count, sizeof(char *)) : NULL; jstring *key_refs = opt_count ? calloc(opt_count, sizeof(jstring)) : NULL; jstring *val_refs = opt_count ? calloc(opt_count, sizeof(jstring)) : NULL; - for (int i = 0; i < opt_count; i++) { + if (opt_count && (!keys || !vals || !key_refs || !val_refs)) { + LOGE("nativeLoad: could not allocate option arrays"); + release_options(env, opt_count, keys, vals, key_refs, val_refs); + (*env)->ReleaseStringUTFChars(env, corePath, c_core_path); + (*env)->ReleaseStringUTFChars(env, romPath, c_rom); + (*env)->ReleaseStringUTFChars(env, systemDir, c_sys); + (*env)->ReleaseStringUTFChars(env, saveDir, c_save); + (*env)->ReleaseStringUTFChars(env, gameId, c_id); + teardown(env); + return NULL; + } + // Each JNI call below is checked individually, not batched at the end of + // the iteration: GetObjectArrayElement/GetStringUTFChars can each throw + // (GetObjectArrayElement can throw ArrayIndexOutOfBoundsException, + // GetStringUTFChars can throw OutOfMemoryError), and making the *next* JNI + // call while an earlier one left an exception pending is itself illegal + // per the JNI spec - so the check has to happen before that next call, not + // after the whole group. A legitimately-null string element (no exception, + // just a null array entry) is also rejected here, since passing NULL to + // GetStringUTFChars is undefined behavior rather than a documented no-op. + int opts_ok = 1; + for (int i = 0; i < opt_count && opts_ok; i++) { key_refs[i] = (jstring)(*env)->GetObjectArrayElement(env, optKeys, i); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + LOGE("nativeLoad: GetObjectArrayElement(optKeys, %d) failed", i); + opts_ok = 0; + break; + } val_refs[i] = (jstring)(*env)->GetObjectArrayElement(env, optVals, i); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + LOGE("nativeLoad: GetObjectArrayElement(optVals, %d) failed", i); + opts_ok = 0; + break; + } + if (!key_refs[i] || !val_refs[i]) { + LOGE("nativeLoad: null option key/value at %d", i); + opts_ok = 0; + break; + } keys[i] = (*env)->GetStringUTFChars(env, key_refs[i], NULL); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + LOGE("nativeLoad: GetStringUTFChars(optKeys[%d]) failed", i); + opts_ok = 0; + break; + } vals[i] = (*env)->GetStringUTFChars(env, val_refs[i], NULL); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + LOGE("nativeLoad: GetStringUTFChars(optVals[%d]) failed", i); + opts_ok = 0; + break; + } + } + + if (!opts_ok) { + release_options(env, opt_count, keys, vals, key_refs, val_refs); + (*env)->ReleaseStringUTFChars(env, corePath, c_core_path); + (*env)->ReleaseStringUTFChars(env, romPath, c_rom); + (*env)->ReleaseStringUTFChars(env, systemDir, c_sys); + (*env)->ReleaseStringUTFChars(env, saveDir, c_save); + (*env)->ReleaseStringUTFChars(env, gameId, c_id); + teardown(env); + return NULL; } lh_av_info info; @@ -259,14 +430,7 @@ JNI(jdoubleArray, nativeLoad)( int rc = lh_load(g_ctx.host, c_core_path, c_rom, c_sys, c_save, c_id, keys, vals, opt_count, &info); - for (int i = 0; i < opt_count; i++) { - (*env)->ReleaseStringUTFChars(env, key_refs[i], keys[i]); - (*env)->ReleaseStringUTFChars(env, val_refs[i], vals[i]); - } - free(keys); - free(vals); - free(key_refs); - free(val_refs); + release_options(env, opt_count, keys, vals, key_refs, val_refs); (*env)->ReleaseStringUTFChars(env, corePath, c_core_path); (*env)->ReleaseStringUTFChars(env, romPath, c_rom); (*env)->ReleaseStringUTFChars(env, systemDir, c_sys); @@ -305,6 +469,7 @@ JNI(void, nativeStart)(JNIEnv *env, jobject thiz) { (void)env; (void)thiz; if (!g_ctx.host) return; + if (g_ctx.has_render_thread) return; // already started; see lh_start's guard lh_set_audio_paced(g_ctx.host, 1); atomic_store(&g_ctx.frame_dirty, 0); atomic_store(&g_ctx.render_running, 1); @@ -326,10 +491,12 @@ JNI(void, nativeResume)(JNIEnv *env, jobject thiz) { if (g_ctx.host) lh_resume(g_ctx.host); } -JNI(void, nativeReset)(JNIEnv *env, jobject thiz) { +JNI(jboolean, nativeReset)(JNIEnv *env, jobject thiz) { (void)env; (void)thiz; - if (g_ctx.host) lh_reset(g_ctx.host); + if (g_ctx.host && lh_restart_async(g_ctx.host) == 0) return JNI_TRUE; + LOGE("Could not schedule libretro restart"); + return JNI_FALSE; } JNI(void, nativeStop)(JNIEnv *env, jobject thiz) { @@ -346,14 +513,34 @@ JNI(void, nativeSetFastForward)(JNIEnv *env, jobject thiz, jint factor) { JNI(void, nativeSetMask)(JNIEnv *env, jobject thiz, jint port, jint mask) { (void)env; (void)thiz; - if (port >= 0 && port < 4) atomic_store(&g_ctx.mask[port], (unsigned)mask); + if (g_ctx.host) lh_set_input(g_ctx.host, (int)port, (uint16_t)mask); } JNI(jint, nativeReadAudio)(JNIEnv *env, jobject thiz, jshortArray buffer, jint frames) { (void)thiz; - if (!g_ctx.host) return 0; + // The g_ctx.host read here is NOT synchronized against teardown's + // lh_destroy, so it cannot by itself make a concurrent teardown safe. What + // makes it safe is the caller: LibretroBridge.stopAudio() stops the + // AudioTrack (unblocking any in-flight write), then joins the audio thread + // unbounded, and only then does stop() call nativeStop(). Keep that + // ordering - a bounded join would put this function back in a race with + // lh_destroy over a freed ring buffer and a destroyed mutex. + if (!g_ctx.host || !buffer) return 0; + // lh_read_audio always writes frame_count*2 shorts to dst, including its + // silence fill for any shortfall (see libretro_host.h) - it has no way to + // know how big the caller's buffer actually is. The current Kotlin caller + // (LibretroBridge.kt) always sizes buffer correctly and passes a matching + // frames, so this is defense in depth against a future or buggy caller + // rather than a fix for an observed bug: clamp frames to what buffer can + // actually hold (2 shorts per frame, interleaved stereo) so a mismatched + // call can't write past the end of a JNI-pinned array. + jsize buffer_len = (*env)->GetArrayLength(env, buffer); + jint max_frames = (jint)(buffer_len / 2); + if (frames > max_frames) frames = max_frames; + if (frames <= 0) return 0; jshort *data = (*env)->GetShortArrayElements(env, buffer, NULL); + if (!data) return 0; int read = lh_read_audio(g_ctx.host, (int16_t *)data, frames); (*env)->ReleaseShortArrayElements(env, buffer, data, 0); return read; @@ -365,11 +552,19 @@ JNI(jbyteArray, nativeSaveState)(JNIEnv *env, jobject thiz) { size_t size = lh_serialize_size(g_ctx.host); if (size == 0) return NULL; void *buf = malloc(size); + if (!buf) { + LOGE("nativeSaveState: could not allocate %zu bytes", size); + return NULL; + } int ok = lh_serialize(g_ctx.host, buf, size) == 0; jbyteArray result = NULL; if (ok) { result = (*env)->NewByteArray(env, (jsize)size); - (*env)->SetByteArrayRegion(env, result, 0, (jsize)size, (const jbyte *)buf); + if (result) { + (*env)->SetByteArrayRegion(env, result, 0, (jsize)size, (const jbyte *)buf); + } else { + LOGE("nativeSaveState: NewByteArray(%zu) failed", size); + } } free(buf); return result; @@ -377,9 +572,18 @@ JNI(jbyteArray, nativeSaveState)(JNIEnv *env, jobject thiz) { JNI(jboolean, nativeLoadState)(JNIEnv *env, jobject thiz, jbyteArray data) { (void)thiz; - if (!g_ctx.host) return JNI_FALSE; + if (!g_ctx.host || !data) return JNI_FALSE; jsize size = (*env)->GetArrayLength(env, data); jbyte *bytes = (*env)->GetByteArrayElements(env, data, NULL); + if (!bytes) { + // GetByteArrayElements returns NULL (and throws OutOfMemoryError) on + // failure; without this check a NULL/size pair would reach + // lh_unserialize, and the pending exception would ride into the next + // JNI call. + LOGE("nativeLoadState: GetByteArrayElements failed"); + (*env)->ExceptionClear(env); + return JNI_FALSE; + } int ok = lh_unserialize(g_ctx.host, bytes, (size_t)size) == 0; (*env)->ReleaseByteArrayElements(env, data, bytes, JNI_ABORT); return ok ? JNI_TRUE : JNI_FALSE; @@ -390,33 +594,78 @@ JNI(jobjectArray, nativeOptions)(JNIEnv *env, jobject thiz) { jclass string_cls = (*env)->FindClass(env, "java/lang/String"); if (!g_ctx.host) return (*env)->NewObjectArray(env, 0, string_cls, NULL); + // lh_option_count and lh_get_option are separate locked calls, so a restart + // on the emulation thread can shrink the definition list in between. Skipping + // a failed index would leave a null element in an array Kotlin types as + // Array, which NPEs on the platform thread the moment it is iterated. + // Stop at the first failure instead and hand back only what was filled - a + // short list of live options is correct, a list with a hole in it is not. int count = lh_option_count(g_ctx.host); jobjectArray result = (*env)->NewObjectArray(env, count, string_cls, NULL); + if (!result) return NULL; + int filled = 0; for (int i = 0; i < count; i++) { lh_option opt; - if (lh_get_option(g_ctx.host, i, &opt) != 0) continue; + if (lh_get_option(g_ctx.host, i, &opt) != 0) break; // Tab-joined: id, label, current, then each choice. size_t len = strlen(opt.id) + strlen(opt.label) + strlen(opt.current) + 3; for (int c = 0; c < opt.choice_count; c++) len += strlen(opt.choices[c]) + 1; char *joined = malloc(len + 1); + if (!joined) break; int n = snprintf(joined, len + 1, "%s\t%s\t%s", opt.id, opt.label, opt.current); + // A negative or truncated result would make len + 1 - n underflow into a + // huge size_t on the next snprintf, so abandon the entry instead. + if (n < 0 || (size_t)n > len) { + free(joined); + break; + } for (int c = 0; c < opt.choice_count; c++) { - n += snprintf(joined + n, len + 1 - n, "\t%s", opt.choices[c]); + int w = snprintf(joined + n, len + 1 - (size_t)n, "\t%s", opt.choices[c]); + if (w < 0 || (size_t)(n + w) > len) break; + n += w; } jstring entry = (*env)->NewStringUTF(env, joined); - (*env)->SetObjectArrayElement(env, result, i, entry); - (*env)->DeleteLocalRef(env, entry); free(joined); + if (!entry) break; + (*env)->SetObjectArrayElement(env, result, filled++, entry); + (*env)->DeleteLocalRef(env, entry); } - return result; + if (filled == count) return result; + + // Something cut the enumeration short. Re-pack into an array with no + // trailing nulls rather than returning one Kotlin cannot safely iterate. + jobjectArray trimmed = (*env)->NewObjectArray(env, filled, string_cls, NULL); + if (!trimmed) return NULL; + for (int i = 0; i < filled; i++) { + jobject entry = (*env)->GetObjectArrayElement(env, result, i); + (*env)->SetObjectArrayElement(env, trimmed, i, entry); + (*env)->DeleteLocalRef(env, entry); + } + (*env)->DeleteLocalRef(env, result); + return trimmed; } JNI(void, nativeSetOption)(JNIEnv *env, jobject thiz, jstring id, jstring value) { (void)thiz; if (!g_ctx.host) return; const char *c_id = (*env)->GetStringUTFChars(env, id, NULL); + if (!c_id) { + // GetStringUTFChars throws OutOfMemoryError on failure; a NULL id would + // flow into lh_set_option -> vars_set -> strcmp(key, NULL). Clear the + // exception and bail before the next JNI call (GetStringUTFChars(value)) + // runs with it pending. + LOGE("nativeSetOption: GetStringUTFChars(id) failed"); + (*env)->ExceptionClear(env); + return; + } const char *c_value = (*env)->GetStringUTFChars(env, value, NULL); + if (!c_value) { + LOGE("nativeSetOption: GetStringUTFChars(value) failed"); + (*env)->ExceptionClear(env); + (*env)->ReleaseStringUTFChars(env, id, c_id); + return; + } lh_set_option(g_ctx.host, c_id, c_value); (*env)->ReleaseStringUTFChars(env, id, c_id); (*env)->ReleaseStringUTFChars(env, value, c_value); diff --git a/android/app/src/main/kotlin/org/moonfin/androidtv/AndroidGamepadIdentity.kt b/android/app/src/main/kotlin/org/moonfin/androidtv/AndroidGamepadIdentity.kt new file mode 100644 index 000000000..36b93d4ce --- /dev/null +++ b/android/app/src/main/kotlin/org/moonfin/androidtv/AndroidGamepadIdentity.kt @@ -0,0 +1,27 @@ +package org.moonfin.androidtv + +import android.view.InputDevice +import java.security.MessageDigest + +/** + * Stable, privacy-safe device identity shared by [GameInputRouter] (EmulatorJS + * device labelling) and [NativePadInput] (custom RetroPad binding lookups) so + * both agree on the same id for the same physical controller. Derived from + * vendor/product/descriptor only -- no [InputDevice.hasKeys] binder call here, + * so this stays safe to call outside a hot per-key-event path (device + * (re)connect, mapping capture, table (re)build) without the IPC cost + * `isPhysicalGamepad` carries. + */ +internal object AndroidGamepadIdentity { + fun of(device: InputDevice): Map { + val identity = "${device.vendorId}:${device.productId}:${device.descriptor}" + val hash = MessageDigest.getInstance("SHA-256") + .digest(identity.toByteArray()) + .take(10) + .joinToString("") { "%02x".format(it) } + return mapOf( + "id" to "android-$hash", + "name" to device.name.ifBlank { "Android gamepad" }, + ) + } +} diff --git a/android/app/src/main/kotlin/org/moonfin/androidtv/GameInputRouter.kt b/android/app/src/main/kotlin/org/moonfin/androidtv/GameInputRouter.kt new file mode 100644 index 000000000..c511e4a56 --- /dev/null +++ b/android/app/src/main/kotlin/org/moonfin/androidtv/GameInputRouter.kt @@ -0,0 +1,323 @@ +package org.moonfin.androidtv + +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent + +/** + * Owns Android input policy and state for the EmulatorJS (WebView) path only. + * EmulatorJS remains the mapping and persistence authority; this router only + * translates Android events to its upstream labels. The native libretro path + * is owned end-to-end by [NativePadInput], checked first in + * [MainActivity.dispatchKeyEvent] so nothing gameplay-shaped reaches here + * while a native session is active. + */ +internal class GameInputRouter( + private val callbacks: Callbacks, +) { + internal interface Callbacks { + fun onEmulatorButton(label: String, pressed: Boolean, device: Map?) + fun onEmulatorKeyboard(keyCode: Int) + fun onNavigate(axis: String, direction: String) + } + + private var gameActive = false + private var emulatorControlsActive = false + private var hatX = 0 + private var hatY = 0 + private var motionDpadX = 0 + private var motionDpadY = 0 + private val pressedDpadKeys = mutableSetOf() + private var leftTriggerPressed = false + private var rightTriggerPressed = false + private var navX = 0 + private var navY = 0 + + // Android device IDs can change after reconnecting. Cache the derived + // identity by descriptor for the active game session only. + private val deviceCache = mutableMapOf>() + + fun setGameActive(active: Boolean) { + gameActive = active + resetSessionState() + } + + fun setEmulatorControlsActive(active: Boolean) { + emulatorControlsActive = active + } + + fun gamepadDevices(): List> = + InputDevice.getDeviceIds() + .asIterable() + .mapNotNull { InputDevice.getDevice(it) } + .filter(::isPhysicalGamepad) + .map(::deviceIdentity) + + /** Returns true only when this router consumed the event. */ + fun onKeyEvent(event: KeyEvent): Boolean { + if (!gameActive) return false + + val device = physicalDevice(event.device) + if (handlePhysicalDpadKey(event, device)) return true + val label = emulatorGamepadLabel(event.keyCode) + if (label != null) { + // Let Android's normal back handling operate outside the emulator controls. + if (label == BACK && !emulatorControlsActive) return false + + val isRemoteNavigationRepeat = + emulatorControlsActive && + device == null && + label in DPAD_LABELS && + event.action == KeyEvent.ACTION_DOWN + if ((event.repeatCount == 0 || isRemoteNavigationRepeat) && isButtonTransition(event)) { + callbacks.onEmulatorButton( + label, + event.action == KeyEvent.ACTION_DOWN, + device?.let(::deviceIdentity), + ) + } + return true + } + + if (emulatorControlsActive && + event.action == KeyEvent.ACTION_DOWN && + event.repeatCount == 0 + ) { + domKeyCode(event.keyCode)?.let { + callbacks.onEmulatorKeyboard(it) + return true + } + } + return false + } + + /** Returns true when gameplay motion was consumed. */ + fun onMotionEvent(event: MotionEvent): Boolean { + if (gameActive && isJoystickMove(event)) { + val device = event.device + motionDpadX = axisDirection(event, MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_X) + motionDpadY = axisDirection(event, MotionEvent.AXIS_HAT_Y, MotionEvent.AXIS_Y) + updateGameplayDpad(device) + + updateTrigger(event, MotionEvent.AXIS_LTRIGGER, LEFT_BOTTOM_SHOULDER, leftTriggerPressed) { + leftTriggerPressed = it + } + updateTrigger(event, MotionEvent.AXIS_RTRIGGER, RIGHT_BOTTOM_SHOULDER, rightTriggerPressed) { + rightTriggerPressed = it + } + return true + } + + // Outside a game the left stick drives UI focus. Ignore the HAT here: + // Android already exposes its d-pad as keys, and double delivery can + // cause one path to release the other's held direction. + if (!gameActive && isJoystickMove(event)) { + updateNavigation("h", stickDirection(event, MotionEvent.AXIS_X), navX) { navX = it } + updateNavigation("v", stickDirection(event, MotionEvent.AXIS_Y), navY) { navY = it } + } + return false + } + + private fun resetSessionState() { + hatX = 0 + hatY = 0 + motionDpadX = 0 + motionDpadY = 0 + pressedDpadKeys.clear() + leftTriggerPressed = false + rightTriggerPressed = false + navX = 0 + navY = 0 + deviceCache.clear() + } + + private fun emitEmulatorAxisTransition( + previous: Int, + next: Int, + negative: String, + positive: String, + device: InputDevice?, + ) { + val identity = physicalDevice(device)?.let(::deviceIdentity) + if (previous == -1) callbacks.onEmulatorButton(negative, false, identity) + if (previous == 1) callbacks.onEmulatorButton(positive, false, identity) + if (next == -1) callbacks.onEmulatorButton(negative, true, identity) + if (next == 1) callbacks.onEmulatorButton(positive, true, identity) + } + + /** Returns true when this key was a D-pad transition this method fully handled. */ + private fun handlePhysicalDpadKey(event: KeyEvent, device: InputDevice?): Boolean { + if (device == null || event.repeatCount != 0 || !isButtonTransition(event)) return false + if (event.keyCode !in DPAD_KEY_CODES) return false + if (event.action == KeyEvent.ACTION_DOWN) { + pressedDpadKeys += event.keyCode + } else { + pressedDpadKeys -= event.keyCode + } + updateGameplayDpad(device) + return true + } + + private fun updateGameplayDpad(device: InputDevice?) { + val keyX = when { + KeyEvent.KEYCODE_DPAD_LEFT in pressedDpadKeys -> -1 + KeyEvent.KEYCODE_DPAD_RIGHT in pressedDpadKeys -> 1 + else -> 0 + } + val keyY = when { + KeyEvent.KEYCODE_DPAD_UP in pressedDpadKeys -> -1 + KeyEvent.KEYCODE_DPAD_DOWN in pressedDpadKeys -> 1 + else -> 0 + } + val nextX = keyX.takeIf { it != 0 } ?: motionDpadX + val nextY = keyY.takeIf { it != 0 } ?: motionDpadY + if (nextX != hatX) { + emitEmulatorAxisTransition(hatX, nextX, "DPAD_LEFT", "DPAD_RIGHT", device) + hatX = nextX + } + if (nextY != hatY) { + emitEmulatorAxisTransition(hatY, nextY, "DPAD_UP", "DPAD_DOWN", device) + hatY = nextY + } + } + + private fun updateTrigger( + event: MotionEvent, + axis: Int, + label: String, + previous: Boolean, + setPressed: (Boolean) -> Unit, + ) { + val pressed = event.getAxisValue(axis) >= AXIS_PRESS_THRESHOLD + if (pressed != previous) { + callbacks.onEmulatorButton(label, pressed, physicalDevice(event.device)?.let(::deviceIdentity)) + setPressed(pressed) + } + } + + private fun updateNavigation(axis: String, next: Int, previous: Int, setDirection: (Int) -> Unit) { + if (next != previous) { + setDirection(next) + callbacks.onNavigate( + axis, + when (next) { + -1 -> if (axis == "h") "left" else "up" + 1 -> if (axis == "h") "right" else "down" + else -> "none" + }, + ) + } + } + + private fun physicalDevice(device: InputDevice?): InputDevice? = + device?.takeIf(::isPhysicalGamepad) + + private fun isPhysicalGamepad(device: InputDevice): Boolean { + if (device.isVirtual) return false + val hasGamepadSource = device.supportsSource(InputDevice.SOURCE_GAMEPAD) + val hasJoystickSource = device.supportsSource(InputDevice.SOURCE_JOYSTICK) + if (!hasGamepadSource && !hasJoystickSource) return false + val hasJoystickAxis = device.motionRanges.any { range -> + range.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK + } + // hasKeys() is a synchronous binder call to system_server. Only pay for + // it when hasJoystickAxis alone can't decide the result -- this method + // runs per key event via physicalDevice(), so evaluating it eagerly + // (as a val computed before the ||) meant every real gamepad, which + // already satisfies hasJoystickAxis, paid the IPC for a result that + // was then discarded by short-circuiting. + return hasJoystickAxis || (device.isExternal && hasFaceButtons(device)) + } + + private fun hasFaceButtons(device: InputDevice): Boolean = device.hasKeys( + KeyEvent.KEYCODE_BUTTON_A, + KeyEvent.KEYCODE_BUTTON_B, + KeyEvent.KEYCODE_BUTTON_X, + KeyEvent.KEYCODE_BUTTON_Y, + ).any { it } + + private fun deviceIdentity(device: InputDevice): Map { + deviceCache[device.descriptor]?.let { return it } + return AndroidGamepadIdentity.of(device).also { deviceCache[device.descriptor] = it } + } + + private fun isJoystickMove(event: MotionEvent): Boolean = + event.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK && + event.action == MotionEvent.ACTION_MOVE + + private fun axisDirection(event: MotionEvent, hatAxis: Int, stickAxis: Int): Int = + direction(event.getAxisValue(hatAxis)).takeIf { it != 0 } + ?: direction(event.getAxisValue(stickAxis)) + + private fun stickDirection(event: MotionEvent, axis: Int): Int = direction(event.getAxisValue(axis)) + + private fun direction(value: Float): Int = when { + value <= -AXIS_PRESS_THRESHOLD -> -1 + value >= AXIS_PRESS_THRESHOLD -> 1 + else -> 0 + } + + private fun isButtonTransition(event: KeyEvent): Boolean = + event.action == KeyEvent.ACTION_DOWN || event.action == KeyEvent.ACTION_UP + + private fun emulatorGamepadLabel(keyCode: Int): String? = EMULATOR_JS_KEYS[keyCode] + + private fun domKeyCode(keyCode: Int): Int? = when (keyCode) { + in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> 65 + keyCode - KeyEvent.KEYCODE_A + in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> 48 + keyCode - KeyEvent.KEYCODE_0 + KeyEvent.KEYCODE_TAB -> 9 + KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> 13 + KeyEvent.KEYCODE_DEL -> 8 + KeyEvent.KEYCODE_FORWARD_DEL -> 46 + KeyEvent.KEYCODE_SPACE -> 32 + KeyEvent.KEYCODE_ESCAPE -> 27 + KeyEvent.KEYCODE_MINUS -> 189 + KeyEvent.KEYCODE_EQUALS -> 187 + KeyEvent.KEYCODE_LEFT_BRACKET -> 219 + KeyEvent.KEYCODE_RIGHT_BRACKET -> 221 + KeyEvent.KEYCODE_BACKSLASH -> 220 + KeyEvent.KEYCODE_SEMICOLON -> 186 + KeyEvent.KEYCODE_APOSTROPHE -> 222 + KeyEvent.KEYCODE_COMMA -> 188 + KeyEvent.KEYCODE_PERIOD -> 190 + KeyEvent.KEYCODE_SLASH -> 191 + KeyEvent.KEYCODE_GRAVE -> 192 + in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12 -> 112 + keyCode - KeyEvent.KEYCODE_F1 + else -> null + } + + private companion object { + const val AXIS_PRESS_THRESHOLD = 0.5f + const val BACK = "BACK" + const val LEFT_BOTTOM_SHOULDER = "LEFT_BOTTOM_SHOULDER" + const val RIGHT_BOTTOM_SHOULDER = "RIGHT_BOTTOM_SHOULDER" + val DPAD_LABELS = setOf("DPAD_UP", "DPAD_DOWN", "DPAD_LEFT", "DPAD_RIGHT") + val DPAD_KEY_CODES = setOf( + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT, + ) + val EMULATOR_JS_KEYS = mapOf( + KeyEvent.KEYCODE_BACK to BACK, + KeyEvent.KEYCODE_DPAD_UP to "DPAD_UP", + KeyEvent.KEYCODE_DPAD_DOWN to "DPAD_DOWN", + KeyEvent.KEYCODE_DPAD_LEFT to "DPAD_LEFT", + KeyEvent.KEYCODE_DPAD_RIGHT to "DPAD_RIGHT", + KeyEvent.KEYCODE_DPAD_CENTER to "BUTTON_2", + KeyEvent.KEYCODE_ENTER to "BUTTON_2", + KeyEvent.KEYCODE_BUTTON_A to "BUTTON_2", + KeyEvent.KEYCODE_BUTTON_B to "BUTTON_1", + KeyEvent.KEYCODE_BUTTON_X to "BUTTON_4", + KeyEvent.KEYCODE_BUTTON_Y to "BUTTON_0", + KeyEvent.KEYCODE_BUTTON_START to "START", + KeyEvent.KEYCODE_BUTTON_SELECT to "SELECT", + KeyEvent.KEYCODE_BUTTON_L1 to "BUTTON_6", + KeyEvent.KEYCODE_BUTTON_R1 to "BUTTON_7", + KeyEvent.KEYCODE_BUTTON_L2 to LEFT_BOTTOM_SHOULDER, + KeyEvent.KEYCODE_BUTTON_R2 to RIGHT_BOTTOM_SHOULDER, + KeyEvent.KEYCODE_BUTTON_THUMBL to "BUTTON_10", + KeyEvent.KEYCODE_BUTTON_THUMBR to "BUTTON_11", + ) + } +} diff --git a/android/app/src/main/kotlin/org/moonfin/androidtv/LibretroBridge.kt b/android/app/src/main/kotlin/org/moonfin/androidtv/LibretroBridge.kt index dbadc38a7..6c283c541 100644 --- a/android/app/src/main/kotlin/org/moonfin/androidtv/LibretroBridge.kt +++ b/android/app/src/main/kotlin/org/moonfin/androidtv/LibretroBridge.kt @@ -6,6 +6,8 @@ import android.media.AudioTrack import android.os.Build import android.os.Handler import android.os.Looper +import android.os.Process +import android.util.Log import android.view.Surface import androidx.annotation.Keep import io.flutter.embedding.engine.FlutterEngine @@ -20,7 +22,12 @@ import io.flutter.view.TextureRegistry // Kept whole so minification does not rename the JNI entry points or the // onGeometry callback the native side looks up by name. @Keep -class LibretroBridge(flutterEngine: FlutterEngine) { +class LibretroBridge( + flutterEngine: FlutterEngine, + // Lets NativePadInput learn when a session starts/stops without this class + // needing to know it exists. Invoked after isActive flips. + private val onActiveChanged: (Boolean) -> Unit = {}, +) { private val control = MethodChannel( flutterEngine.dartExecutor.binaryMessenger, "moonfin/native_game_control") private val events = EventChannel( @@ -39,6 +46,13 @@ class LibretroBridge(flutterEngine: FlutterEngine) { private var pulseMask = 0 private var touchMask = 0 + // Gates the per-edge "button" EventChannel message: during gameplay the + // overlay is closed and Dart has nothing to do with these, so nothing + // crosses the channel. Only overlay navigation (open pause menu, controller + // mapping capture list, ...) needs them, and that only happens with the + // overlay open. Set by NativePadInput via Dart's setOverlayOpen call. + @Volatile var overlayOpen = false + @Volatile var isActive = false private set @@ -70,7 +84,10 @@ class LibretroBridge(flutterEngine: FlutterEngine) { "start" -> { nativeStart(); result.success(null) } "pause" -> { userPaused = true; nativePause(); result.success(null) } "resume" -> { userPaused = false; nativeResume(); result.success(null) } - "restart" -> { nativeReset(); result.success(null) } + "restart" -> { + if (nativeReset()) result.success(null) + else result.error("restart_unavailable", "The emulator is not running.", null) + } "stop" -> { stop(); result.success(null) } "saveState" -> result.success(nativeSaveState()) "loadState" -> { @@ -159,6 +176,7 @@ class LibretroBridge(flutterEngine: FlutterEngine) { startAudio(av[4].toInt()) isActive = true + onActiveChanged(true) result.success( mapOf( @@ -171,7 +189,16 @@ class LibretroBridge(flutterEngine: FlutterEngine) { )) } - private fun stop() { + // Reachable from three places: the "stop" method call, load() (which calls + // it before nativeLoad() to tear down any prior session), and MainActivity's + // onDestroy() (a running session must not be abandoned if the activity is + // destroyed while the process survives - see the comment there). All three + // routes destroy the native host, so stopAudio() must stay ahead of + // nativeStop(): it is what guarantees no thread is inside nativeReadAudio + // when the host, its ring buffer, and its audio mutex are freed. Safe to + // call repeatedly - isActive/audioTrack/audioThread/surfaceProducer are all + // null-guarded, and nativeStop()'s teardown() no-ops once g_ctx.host is NULL. + fun stop() { isActive = false userPaused = false lastCoreMessage = null @@ -182,16 +209,39 @@ class LibretroBridge(flutterEngine: FlutterEngine) { portMask = 0 pulseMask = 0 touchMask = 0 + overlayOpen = false + onActiveChanged(false) + } + + // Zeroes just the physical-pad contribution to the mask. Called by + // NativePadInput on session activate/deactivate so a direction held at + // teardown (or a stale bit from a just-torn-down session) can never leak + // into the next one. Distinct from stop()'s full reset, which also owns + // pulseMask/touchMask. + fun resetPadMask() { + portMask = 0 + applyMask() } private fun startAudio(sampleRate: Int) { - // Small chunks keep the blocking write's back pressure finer than one - // video frame, and a small device buffer keeps input-to-sound lag low. - val frames = 512 - val bytesPerFrame = 4 + val track = buildAudioTrack(sampleRate) + audioTrack = track + track.play() + + audioRunning = true + val thread = Thread { runAudioLoop(track) } + thread.name = "moonfin.game.audio" + audioThread = thread + thread.start() + } + + private fun buildAudioTrack(sampleRate: Int): AudioTrack { + // A small device buffer keeps input-to-sound lag low, while still holding + // several of the AUDIO_CHUNK_FRAMES writes the loop below issues. + val bytesPerFrame = 2 * BYTES_PER_SAMPLE val bufferBytes = AudioTrack.getMinBufferSize( sampleRate, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT) - .coerceAtLeast(4 * frames * bytesPerFrame) + .coerceAtLeast(4 * AUDIO_CHUNK_FRAMES * bytesPerFrame) val builder = AudioTrack.Builder() .setAudioAttributes( AudioAttributes.Builder() @@ -209,58 +259,111 @@ class LibretroBridge(flutterEngine: FlutterEngine) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { builder.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY) } - val track = builder.build() - audioTrack = track - track.play() + return builder.build() + } - audioRunning = true - val buffer = ShortArray(frames * 2) - val thread = Thread { + // Runs on the "moonfin.game.audio" thread for the life of one session. It is + // the only caller of nativeReadAudio, and stopAudio() joins it before the + // native host is destroyed, so the host pointer it reads through stays live. + private fun runAudioLoop(track: AudioTrack) { + // The emulation and blit threads are native pthreads created from the main + // thread, so they inherit its elevated nice (-10). This one is a Java + // Thread, which does not inherit it and starts at the default 0 -- leaving + // the one thread feeding AudioTrack as the lowest-priority worker in the + // process, and the first descheduled under load. That shows up as + // "AudioTrack: disabled due to previous underrun" and audible crackle. + // The buffer is already built for PERFORMANCE_MODE_LOW_LATENCY; this is the + // scheduling half of the same intent. + Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO) + // Small chunks keep the blocking write's back pressure finer than one + // video frame. + val buffer = ShortArray(AUDIO_CHUNK_FRAMES * 2) + try { while (audioRunning) { - val read = nativeReadAudio(buffer, frames) + val read = nativeReadAudio(buffer, AUDIO_CHUNK_FRAMES) // Write only what the ring had, since padding silence would pop. On a // short read the emulator is priming or paused, so give it a moment. if (read > 0) track.write(buffer, 0, read * 2) - if (read < frames) Thread.sleep(2) + if (read < AUDIO_CHUNK_FRAMES) Thread.sleep(2) } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } catch (e: IllegalStateException) { + // The track was released underneath an in-flight write. stopAudio() is + // ordered so this should not happen; if it ever does, losing audio for a + // session we are tearing down anyway beats an uncaught exception on a + // non-UI thread, which kills the process. + Log.w(TAG, "audio loop stopped: track no longer usable", e) } - thread.name = "moonfin.game.audio" - audioThread = thread - thread.start() } private fun stopAudio() { audioRunning = false - audioThread?.join(500) + // stop() first: it unblocks any in-flight blocking MODE_STREAM write so the + // join below cannot hang. The join must then be unbounded - a bounded join + // lets the audio thread survive into nativeStop()'s teardown and call + // lh_read_audio against a destroyed mutex and a freed ring buffer. The + // render thread's pthread_join in teardown() is unbounded for this reason. + // Release only after the join, so no write can outlive the track. + audioTrack?.let { runCatching { it.stop() } } + audioThread?.join() audioThread = null - audioTrack?.let { - it.stop() - it.release() - } + audioTrack?.let { runCatching { it.release() } } audioTrack = null } private fun applyMask() { - // Start (bit 3) is withheld from the physical pad mask because Dart owns - // it: a quick press is pulsed back down, a hold opens the in game menu. - // The pulse and Dart masks keep the bit so those paths still work. - val startBit = 1 shl 3 - nativeSetMask(0, (portMask and startBit.inv()) or pulseMask or touchMask) + // Start now reaches the core directly through portMask: NativePadInput + // owns the short-press-vs-hold gesture natively (see its handleStart), + // pulsing bit 3 itself instead of Dart stripping and re-injecting it. + nativeSetMask(0, portMask or pulseMask or touchMask) } - // Called from MainActivity's key dispatch on the UI thread. - fun onButton(index: Int, pressed: Boolean) { - if (index < 0 || index >= 16) return - val bit = 1 shl index - portMask = if (pressed) portMask or bit else portMask and bit.inv() + // Called from NativePadInput (native RetroPad path) on the UI thread. Only + // sends the EventChannel message while the overlay is open: during + // gameplay Dart has nothing to do with a button edge, so nothing crosses + // the channel for it. + /** + * Takes the whole RetroPad state at once, so one input event costs one JNI + * call no matter how many bits it moved. + * + * [NativePadInput] already holds the port's state as a mask, and + * lh_set_input takes a mask, so forwarding one bit at a time made + * a diagonal cost two crossings and a full release up to sixteen. The XOR + * below recovers the individual edges, and only when the overlay is open -- + * during gameplay nothing crosses the channel at all. + */ + fun onPad(mask: Int) { + val changed = portMask xor mask + if (changed == 0) return + portMask = mask applyMask() - eventSink?.success(mapOf("event" to "button", "index" to index, "pressed" to pressed)) + if (!overlayOpen) return + var remaining = changed + while (remaining != 0) { + val bit = remaining and -remaining + remaining = remaining and bit.inv() + val index = Integer.numberOfTrailingZeros(bit) + eventSink?.success( + mapOf("event" to "button", "index" to index, "pressed" to (mask and bit != 0)), + ) + } } fun onMenu() { eventSink?.success(mapOf("event" to "menuPressed")) } + // Called from JNI on the host run-loop thread when the emulation thread is + // about to die from an unrecoverable error (e.g. the core failed to + // restart). Dart shows this instead of leaving a frozen frame with no + // explanation. + fun onError(message: String) { + mainHandler.post { + eventSink?.success(mapOf("event" to "error", "message" to message)) + } + } + private fun pulseButton(index: Int, durationMs: Int) { if (index < 0 || index >= 16) return val bit = 1 shl index @@ -308,6 +411,10 @@ class LibretroBridge(flutterEngine: FlutterEngine) { } private fun parseOptions(): List> { + // Array is only sound because nativeOptions trims its result to the + // entries it actually filled: a core restart can shrink the option list + // mid-enumeration, and the JNI side used to leave a null in the gap, which + // this non-null element type turns into an NPE on the platform thread. return nativeOptions().mapNotNull { entry -> val parts = entry.split("\t") if (parts.size < 3) return@mapNotNull null @@ -327,7 +434,7 @@ class LibretroBridge(flutterEngine: FlutterEngine) { private external fun nativeStart() private external fun nativePause() private external fun nativeResume() - private external fun nativeReset() + private external fun nativeReset(): Boolean private external fun nativeStop() private external fun nativeSetFastForward(factor: Int) private external fun nativeSetMask(port: Int, mask: Int) @@ -338,6 +445,13 @@ class LibretroBridge(flutterEngine: FlutterEngine) { private external fun nativeSetOption(id: String, value: String) companion object { + private const val TAG = "LibretroBridge" + + // Frames pulled from the native ring per write. Stereo, so the short + // buffer is twice this. + private const val AUDIO_CHUNK_FRAMES = 512 + private const val BYTES_PER_SAMPLE = 2 + init { System.loadLibrary("moonfin_libretro") } diff --git a/android/app/src/main/kotlin/org/moonfin/androidtv/MainActivity.kt b/android/app/src/main/kotlin/org/moonfin/androidtv/MainActivity.kt index 18322554a..3479365f1 100644 --- a/android/app/src/main/kotlin/org/moonfin/androidtv/MainActivity.kt +++ b/android/app/src/main/kotlin/org/moonfin/androidtv/MainActivity.kt @@ -28,7 +28,6 @@ import android.os.Process import android.os.PowerManager import android.util.Rational import android.view.Display -import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent import androidx.mediarouter.media.MediaRouteSelector @@ -58,6 +57,10 @@ import org.flame_engine.gamepads_android.GamepadsCompatibleActivity class MainActivity : AudioServiceActivity(), GamepadsCompatibleActivity { + // Never invoked: kept only so the gamepads_android plugin's + // GamepadsCompatibleActivity.registerKeyEventHandler call below has + // somewhere to store its callback. See dispatchKeyEvent's comment for why + // calling it per key event was removed. private var keyHandler: ((KeyEvent) -> Boolean)? = null private var motionHandler: ((MotionEvent) -> Boolean)? = null @@ -90,20 +93,23 @@ class MainActivity : AudioServiceActivity(), GamepadsCompatibleActivity { private var externalPlayerPendingResult: MethodChannel.Result? = null private var gamepadChannel: MethodChannel? = null private var libretroBridge: LibretroBridge? = null + // Native path: constructed alongside libretroBridge in configureFlutterEngine + // (it needs that instance to reach nativeSetMask). Nullable rather than + // lateinit since dispatchKeyEvent can run before the engine is configured. + private var nativePad: NativePadInput? = null + private val gameInputRouter = GameInputRouter(object : GameInputRouter.Callbacks { + override fun onEmulatorButton(label: String, pressed: Boolean, device: Map?) { + sendGamepadButton(label, pressed, device) + } + + override fun onEmulatorKeyboard(keyCode: Int) = sendEmulatorKeyboardKey(keyCode) + + override fun onNavigate(axis: String, direction: String) = sendGamepadNavigate(axis, direction) + }) private var watchNextChannel: MethodChannel? = null private var watchNextPublisher: WatchNextPublisher? = null private var previewChannelPublisher: PreviewChannelPublisher? = null private var pendingDeepLink: String? = null - private var gameActive = false - private var hatX = 0 - private var hatY = 0 - - // Kept separate from hatX/hatY on purpose. Sharing them would let a - // UI-navigation value leak into the emulator's edge detector, which would - // then read the first in-game press as a hold already in progress and - // never send its matching button-up. - private var navX = 0 - private var navY = 0 private var pipEnabled = false private val handler = Handler(Looper.getMainLooper()) private var dismissRunnable: Runnable? = null @@ -234,7 +240,17 @@ class MainActivity : AudioServiceActivity(), GamepadsCompatibleActivity { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - libretroBridge = LibretroBridge(flutterEngine) + val bridge = LibretroBridge(flutterEngine) { active -> nativePad?.setActive(active) } + libretroBridge = bridge + nativePad = NativePadInput( + bridge, + handler, + this, + object : NativePadInput.Callbacks { + override fun onControllerMappingKey(keyCode: Int, device: Map) = + sendControllerMappingKey(keyCode, device) + }, + ) MethodChannel( flutterEngine.dartExecutor.binaryMessenger, @@ -284,16 +300,29 @@ class MainActivity : AudioServiceActivity(), GamepadsCompatibleActivity { gamepadChannel?.setMethodCallHandler { call, result -> when (call.method) { "setActive" -> { - gameActive = call.argument("active") ?: false - // Reset on every transition, in both directions. Otherwise - // the first press after the switch reads as a hold already - // in progress and gets dropped. - hatX = 0 - hatY = 0 - navX = 0 - navY = 0 + gameInputRouter.setGameActive(call.argument("active") ?: false) + result.success(true) + } + "setEmulatorControlsActive" -> { + gameInputRouter.setEmulatorControlsActive(call.argument("active") ?: false) + result.success(true) + } + "setControllerMapping" -> { + nativePad?.setControllerMappings(call.argument("mapping") ?: "{}") + result.success(true) + } + "setControllerMappingCapture" -> { + nativePad?.setCapture( + call.argument("active") ?: false, + call.argument("deviceId"), + ) + result.success(true) + } + "setOverlayOpen" -> { + libretroBridge?.overlayOpen = call.argument("open") ?: false result.success(true) } + "getGamepadDevices" -> result.success(gameInputRouter.gamepadDevices()) else -> result.notImplemented() } } @@ -623,129 +652,42 @@ class MainActivity : AudioServiceActivity(), GamepadsCompatibleActivity { } } - // While a game is running, the emulator lives in a WebView that owns key focus and the - // System WebView does not reliably expose the Gamepad API. Capture the pad at the Activity - // level (top of the dispatch chain, before any view) and forward RetroPad buttons to Dart, - // which injects them into the core. Consumed only while active so app navigation is intact - // otherwise; button events are also consumed in-game so they never reach Flutter focus. + // Native libretro sessions are checked first and own the pad end-to-end: + // no binder IPC, no boxing, no Dart channel crossing per event (see + // NativePadInput). Everything else -- home screen navigation and + // EmulatorJS's WebView, which owns key focus but can't reliably read the + // Gamepad API -- goes through GameInputRouter, captured here at the top + // of the dispatch chain before any view sees it. override fun dispatchKeyEvent(event: KeyEvent): Boolean { - if (gameActive || libretroBridge?.isActive == true) { - if (libretroBridge?.isActive == true && - event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 && - (event.keyCode == KeyEvent.KEYCODE_MENU || - event.keyCode == KeyEvent.KEYCODE_BUTTON_MODE) - ) { - libretroBridge?.onMenu() - return true - } - val index = retroPadIndex(event.keyCode) - if (index != null) { - if (event.repeatCount == 0 && - (event.action == KeyEvent.ACTION_DOWN || event.action == KeyEvent.ACTION_UP) - ) { - sendGamepadButton(index, event.action == KeyEvent.ACTION_DOWN) - } - return true - } - } - keyHandler?.invoke(event) + val pad = nativePad + if (pad != null && pad.active && pad.onKey(event)) return true + if (gameInputRouter.onKeyEvent(event)) return true + // keyHandler is the gamepads_android plugin's registration + // (GamepadsCompatibleActivity.registerKeyEventHandler), which forwards + // to the xyz.luan/gamepads channel's Gamepads.normalizedEvents stream. + // Verified before deleting the call: nothing in this app subscribes to + // that stream on Android -- GamepadNavigationScope.isSupported and + // native_game_player_screen's _readsGamepadsInDart both exclude + // Android (that path is Windows/Linux only; Android reads pad buttons + // as real key events here and the stick via registerMotionEventHandler + // below, whose return value IS consulted). Its own return value was + // already discarded unconditionally, so invoking it per key event was + // pure dead work. The registration method itself stays: the plugin + // casts this Activity to GamepadsCompatibleActivity and calls it + // unconditionally, so the interface member must remain implemented. return super.dispatchKeyEvent(event) } override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { - if ((gameActive || libretroBridge?.isActive == true) && - event.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK && - event.action == MotionEvent.ACTION_MOVE - ) { - val x = axisDirection(event, MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_X) - if (x != hatX) { - if (hatX == -1) sendGamepadButton(6, false) - if (hatX == 1) sendGamepadButton(7, false) - if (x == -1) sendGamepadButton(6, true) - if (x == 1) sendGamepadButton(7, true) - hatX = x - } - val y = axisDirection(event, MotionEvent.AXIS_HAT_Y, MotionEvent.AXIS_Y) - if (y != hatY) { - if (hatY == -1) sendGamepadButton(4, false) - if (hatY == 1) sendGamepadButton(5, false) - if (y == -1) sendGamepadButton(4, true) - if (y == 1) sendGamepadButton(5, true) - hatY = y - } - return true - } - - // Outside a game the left stick drives UI focus. The d-pad needs no - // help, because Android already turns the hat into KEYCODE_DPAD_* key - // events. Only the stick is invisible to Flutter, arriving as motion. - if (!gameActive && - event.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK && - event.action == MotionEvent.ACTION_MOVE - ) { - val x = stickDirection(event, MotionEvent.AXIS_X) - if (x != navX) { - navX = x - sendGamepadNavigate("h", if (x == -1) "left" else if (x == 1) "right" else "none") - } - val y = stickDirection(event, MotionEvent.AXIS_Y) - if (y != navY) { - navY = y - sendGamepadNavigate("v", if (y == -1) "up" else if (y == 1) "down" else "none") - } - // Not consumed, since other views may still want the motion event. - } + val pad = nativePad + if (pad != null && pad.active && pad.onMotion(event)) return true + if (gameInputRouter.onMotionEvent(event)) return true if (motionHandler?.invoke(event) == true) { return true } return super.dispatchGenericMotionEvent(event) } - // Hardware keycode -> libretro RetroPad index. Face-button positions map by layout - // (bottom=B0, right=A8, left=Y1, top=X9). D-pad keycodes and keyboard arrows both count so - // controllers and keyboards drive gameplay. - private fun retroPadIndex(keyCode: Int): Int? = when (keyCode) { - KeyEvent.KEYCODE_DPAD_UP -> 4 - KeyEvent.KEYCODE_DPAD_DOWN -> 5 - KeyEvent.KEYCODE_DPAD_LEFT -> 6 - KeyEvent.KEYCODE_DPAD_RIGHT -> 7 - // Remote OK button; acts as the primary action in-game and select in the overlay. - KeyEvent.KEYCODE_DPAD_CENTER -> 0 - KeyEvent.KEYCODE_ENTER -> 0 - KeyEvent.KEYCODE_BUTTON_A -> 0 - KeyEvent.KEYCODE_BUTTON_B -> 8 - KeyEvent.KEYCODE_BUTTON_X -> 1 - KeyEvent.KEYCODE_BUTTON_Y -> 9 - KeyEvent.KEYCODE_BUTTON_START -> 3 - KeyEvent.KEYCODE_BUTTON_SELECT -> 2 - KeyEvent.KEYCODE_BUTTON_L1 -> 10 - KeyEvent.KEYCODE_BUTTON_R1 -> 11 - KeyEvent.KEYCODE_BUTTON_THUMBL -> 14 - KeyEvent.KEYCODE_BUTTON_THUMBR -> 15 - else -> null - } - - // -1 / 0 / +1 from a HAT axis, falling back to the analog stick past a deadzone. - private fun axisDirection(event: MotionEvent, hatAxis: Int, stickAxis: Int): Int { - val hat = event.getAxisValue(hatAxis) - if (hat <= -0.5f) return -1 - if (hat >= 0.5f) return 1 - val stick = event.getAxisValue(stickAxis) - if (stick <= -0.5f) return -1 - if (stick >= 0.5f) return 1 - return 0 - } - - // -1 / 0 / +1 from an analog stick axis alone. Separate from axisDirection, - // which falls back from the hat to the stick. UI navigation has to ignore - // the hat, or every d-pad press would move focus twice. - private fun stickDirection(event: MotionEvent, stickAxis: Int): Int { - val stick = event.getAxisValue(stickAxis) - if (stick <= -0.5f) return -1 - if (stick >= 0.5f) return 1 - return 0 - } - // The axis is "h" or "v" so Dart can tell which one a "none" recentre came // from. Without it, releasing a diagonal is ambiguous. private fun sendGamepadNavigate(axis: String, direction: String) { @@ -757,14 +699,34 @@ class MainActivity : AudioServiceActivity(), GamepadsCompatibleActivity { } } - private fun sendGamepadButton(index: Int, pressed: Boolean) { - val bridge = libretroBridge - if (bridge?.isActive == true) { - bridge.onButton(index, pressed) - return + private fun sendGamepadButton(label: String, pressed: Boolean, device: Map?) { + runOnUiThread { + gamepadChannel?.invokeMethod( + "onButton", + mapOf( + "label" to label, + "pressed" to pressed, + "device" to device, + ), + ) + } + } + + private fun sendControllerMappingKey(keyCode: Int, device: Map) { + runOnUiThread { + gamepadChannel?.invokeMethod( + "onControllerMappingKey", + mapOf( + "keyCode" to keyCode, + "device" to device, + ), + ) } + } + + private fun sendEmulatorKeyboardKey(keyCode: Int) { runOnUiThread { - gamepadChannel?.invokeMethod("onButton", mapOf("index" to index, "pressed" to pressed)) + gamepadChannel?.invokeMethod("onKeyboard", mapOf("keyCode" to keyCode)) } } @@ -867,6 +829,16 @@ class MainActivity : AudioServiceActivity(), GamepadsCompatibleActivity { override fun onDestroy() { val shouldTerminateProcess = isFinishing && !isChangingConfigurations + // A native game may still be running. When the process also dies below + // this is redundant but harmless; when it doesn't - e.g. the system + // destroys this stopped activity to reclaim memory while a foreground + // service (AudioService) keeps the process alive, or "Don't keep + // activities" is on - stop() is the only path that reaches teardown() + // and joins the render/audio threads. Without it they leak into the + // next activity instance. stop() is idempotent, so calling it here even + // when nothing is loaded, or when killProcess() runs moments later, is + // safe. + libretroBridge?.stop() dismissRunnable?.let { handler.removeCallbacks(it) } pendingCastTimeout?.let { handler.removeCallbacks(it) } val castContext = runCatching { CastContext.getSharedInstance(this) }.getOrNull() diff --git a/android/app/src/main/kotlin/org/moonfin/androidtv/NativePadInput.kt b/android/app/src/main/kotlin/org/moonfin/androidtv/NativePadInput.kt new file mode 100644 index 000000000..5595be17d --- /dev/null +++ b/android/app/src/main/kotlin/org/moonfin/androidtv/NativePadInput.kt @@ -0,0 +1,533 @@ +package org.moonfin.androidtv + +import android.content.Context +import android.hardware.input.InputManager +import android.os.Handler +import android.util.SparseArray +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import org.json.JSONObject + +/** + * Owns the native libretro input path end-to-end: physical gamepad/keyboard + * events go straight from [MainActivity.dispatchKeyEvent] / + * [MainActivity.dispatchGenericMotionEvent] through here to + * [LibretroBridge.onPad] with no binder IPC, no boxing, and no Dart channel + * crossing on the hot path. + * + * A custom binding on a device simply overwrites that keycode's slot in its + * preflattened [IntArray] (built once, on mapping change or first sight of a + * device) -- so a remap beating the fixed D-pad/button layout is structural, + * not a guard clause (see task B2, preserved from [GameInputRouter]'s old + * `handlePhysicalDpadKey`). + * + * D-pad/trigger state from physical keys and from the analog HAT/trigger axes + * are two independent bit sources ([keyMask] / [motionMask]) that are OR'd + * together per RetroPad bit before being forwarded -- releasing the HAT while + * the digital key is still held (or vice versa) cannot clobber the other + * source's held bit. The per-frame OR-latch on the native side (added + * alongside this change) guarantees a same-frame press+release is still + * observed by the core, so no reconciliation beyond that OR is needed. + * + * Controller-mapping capture (the pause menu's "press a button to bind it" + * flow) runs concurrently with an active native session -- the mapping panel + * is opened *from* the pause menu of a running game, so `active == true` and + * capture-armed are simultaneously true, not exclusive states. [onKey] checks + * [captureActive] before Start/Menu/table dispatch specifically so capture + * wins: a key that would otherwise be swallowed as gameplay input, or + * special-cased as Start, is captured as a binding instead while armed. + * + * [event.deviceId][KeyEvent.getDeviceId] is a per-connection int that + * changes when a controller disconnects and reconnects; persisted bindings + * are keyed by the stable vendor/product/descriptor hash from + * [AndroidGamepadIdentity] instead. [buildTable] is the one place that + * resolves deviceId -> stable id (one [InputDevice.getDevice] call), and it + * only runs on a [deviceTables] cache miss -- first sight of a deviceId, + * right after [setControllerMappings] invalidates the cache, or after a + * device add/remove/change invalidates just that id. Every other event is a + * plain array index. + */ +internal class NativePadInput( + private val bridge: LibretroBridge, + private val handler: Handler, + context: Context, + private val callbacks: Callbacks, +) { + internal interface Callbacks { + fun onControllerMappingKey(keyCode: Int, device: Map) + } + + private val inputManager = context.getSystemService(Context.INPUT_SERVICE) as? InputManager + + init { + // Reconnecting a controller (or hot-plugging a second one) can hand + // out a new/changed deviceId; drop just that id's cached table so the + // next event resolves it fresh against the stable identity rather + // than reusing a table built for whatever device previously held + // that int. Registered without a Handler, so callbacks land on this + // thread (the UI thread, same as onKey/onMotion). + inputManager?.registerInputDeviceListener( + object : InputManager.InputDeviceListener { + // A pad that re-enumerates arrives here under a new id, and any + // direction still latched from the old one is orphaned: the + // axis that asserted it belongs to a device that no longer + // exists, so no motion event can ever clear it. Releasing the + // motion bits (not the key bits, which recover on their own via + // releaseLostHolds) is what stops that from reading as a + // direction held for ever. + override fun onInputDeviceAdded(deviceId: Int) { + invalidateDevice(deviceId) + releaseMotionInputs() + } + + // Only a removal releases held bits. onInputDeviceChanged fires + // for unrelated reasons -- a keyboard-layout reconfiguration + // raises it for every device -- and releasing on those would + // cut a genuinely held direction short mid-game. + override fun onInputDeviceRemoved(deviceId: Int) { + invalidateDevice(deviceId) + releaseAllInputs() + } + + override fun onInputDeviceChanged(deviceId: Int) { + invalidateDevice(deviceId) + } + }, + null, + ) + } + + private fun invalidateDevice(deviceId: Int) { + deviceTables.remove(deviceId) + } + + /** + * Drops every latched bit and tells the core, so no press can outlive the + * device that made it. + * + * A wireless pad that drops its link mid-press never sends the matching + * ACTION_UP, so the bit stays set in [keyMask] and [sentMask] keeps saying + * "already forwarded as pressed". The core then sees that button held for + * ever -- a stuck direction reads as the pad having stopped responding -- + * and, worse, the next real press of it computes pressed == was and is + * swallowed by [publishMask]'s change check without ever reaching the core. + * + * Releasing everything on any device change can cut a genuinely held button + * short, but device churn is rare and a dropped hold recovers on the next + * press, whereas a stuck one does not recover at all. + */ + private fun releaseAllInputs() { + keyMask = 0 + motionMask = 0 + motionDeviceId = -1 + // One send: publishMask recomputes from the now-clear masks, so every + // held bit is released in a single crossing rather than one each. + publishMask() + } + + /** True while a native session is loaded; checked first in dispatch. */ + @Volatile var active = false + private set + + // Independent bit sources for the RetroPad mask, OR'd together in + // [publishMask]. sentMask is what was last forwarded to the bridge, so + // publish only calls out on an actual change. + private var keyMask = 0 + private var motionMask = 0 + private var sentMask = 0 + + private val deviceTables = SparseArray() + private var customMappings: Map> = emptyMap() + + private var captureActive = false + private var captureDeviceId: String? = null + + private var startTimer: Runnable? = null + private var startConsumed = false + private var motionDeviceId = -1 + + fun setActive(value: Boolean) { + active = value + keyMask = 0 + motionMask = 0 + sentMask = 0 + motionDeviceId = -1 + deviceTables.clear() + cancelStartTimer() + startConsumed = false + bridge.resetPadMask() + } + + fun setControllerMappings(json: String) { + customMappings = parseControllerMappings(json) + deviceTables.clear() + } + + fun setCapture(active: Boolean, deviceId: String?) { + captureActive = active + captureDeviceId = deviceId.takeIf { active } + } + + /** Returns true when this key was consumed by the native pad path. */ + fun onKey(event: KeyEvent): Boolean { + val keyCode = event.keyCode + if (keyCode == KeyEvent.KEYCODE_BACK || isVolumeKey(keyCode)) return false + // Repeats carry no new information: the level is already latched from + // the initial DOWN and stays latched until UP. Consume and drop them. + if (event.repeatCount != 0) return true + + // Capture takes priority over every other interpretation of the key, + // including Start/Menu: the whole point is binding *any* physical key + // (Start included) to whichever RetroPad slot the mapping screen has + // selected. Falls through to normal handling for a non-matching + // device, same as the rest of this method would for a stray event. + if (captureActive && event.action == KeyEvent.ACTION_DOWN && tryCapture(event)) { + return true + } + + // Escape joins Menu here rather than being handled up in Flutter. A USB + // or Bluetooth keyboard is a real Android TV accessory, and Escape is + // the obvious "let me out" key on one, but it is not a game key -- so + // it belongs on the same native path as Menu. Handling it in a Flutter + // Focus instead would put the framework's key pipeline in front of + // every gameplay key that falls through to it, and each of those then + // waits on a platform -> Dart -> platform round trip before the event + // is acknowledged. + if (keyCode == KeyEvent.KEYCODE_MENU || + keyCode == KeyEvent.KEYCODE_BUTTON_MODE || + keyCode == KeyEvent.KEYCODE_ESCAPE + ) { + if (event.action == KeyEvent.ACTION_DOWN) bridge.onMenu() + return true + } + + if (keyCode == KeyEvent.KEYCODE_BUTTON_START) { + handleStart(event.action == KeyEvent.ACTION_DOWN) + return true + } + + if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) { + return true + } + + val index = indexFor(event.deviceId, keyCode) + when (index) { + NONE -> return false + SWALLOW -> return true + else -> { + val bit = 1 shl index + if (event.action == KeyEvent.ACTION_DOWN) { + releaseLostHolds(index, bit) + keyMask = keyMask or bit + } else { + keyMask = keyMask and bit.inv() + } + publishMask() + } + } + return true + } + + /** Returns true when this motion event was gameplay-shaped and consumed. */ + fun onMotion(event: MotionEvent): Boolean { + if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK || + event.action != MotionEvent.ACTION_MOVE + ) { + return false + } + // Bits latched by a different pad cannot be cleared by this one's + // axes, so drop them before this event's values are applied. Catches a + // re-enumeration even when the listener above never reports it. + if (motionMask != 0 && motionDeviceId != -1 && event.deviceId != motionDeviceId) { + releaseMotionInputs() + } + motionDeviceId = event.deviceId + val hatX = axisDirection(event, MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_X) + val hatY = axisDirection(event, MotionEvent.AXIS_HAT_Y, MotionEvent.AXIS_Y) + applyMotionBit(RETRO_LEFT, hatX == -1) + applyMotionBit(RETRO_RIGHT, hatX == 1) + applyMotionBit(RETRO_UP, hatY == -1) + applyMotionBit(RETRO_DOWN, hatY == 1) + applyMotionBit(RETRO_L2, event.getAxisValue(MotionEvent.AXIS_LTRIGGER) >= AXIS_THRESHOLD) + applyMotionBit(RETRO_R2, event.getAxisValue(MotionEvent.AXIS_RTRIGGER) >= AXIS_THRESHOLD) + publishMask() + return true + } + + /** + * Repairs [keyMask] when a pad's ACTION_UP was lost, which a wireless link + * does often enough to be felt: the bit stays set, the core sees that + * direction held for ever, and [publishMask]'s change check then swallows the + * next press of it because pressed == was. + * + * Two facts make a lost release provable rather than guessed at. A key + * cannot go down twice without an intervening up -- auto-repeat carries a + * non-zero repeatCount and returned earlier -- and a d-pad cannot hold + * left and right, or up and down, at the same time. Reaching either state + * is therefore always the error, never real input. + * + * Only [keyMask] is touched. A direction still asserted by [motionMask] is + * a hat or stick genuinely being held, and that path repairs itself: every + * ACTION_MOVE recomputes all four directions from the current axis values. + */ + private fun releaseLostHolds(index: Int, bit: Int) { + val opposite = if (index in OPPOSITE.indices) OPPOSITE[index] else NONE + if (opposite != NONE) { + val oppositeBit = 1 shl opposite + if (keyMask and oppositeBit != 0) { + keyMask = keyMask and oppositeBit.inv() + } + } + if (keyMask and bit == 0) return + // Same key down twice. Clearing here restores the invariant so the + // ACTION_UP that follows releases correctly. The core will not observe + // a distinct re-press: a release and a press published between two + // polls collapse in the host's OR-latch. Holding the re-press back for + // a frame would make it visible, but that delay would land on every + // rapid press, which is a worse trade than a missing re-trigger. + keyMask = keyMask and bit.inv() + } + + /** + * Releases every direction/trigger bit held by the analog path. + * + * The key path can prove a lost release (see [releaseLostHolds]); this one + * cannot. Motion events are edge-triggered, so once a bit is latched, + * "no event" means "unchanged" rather than "released" -- a timeout could + * not tell a dropped centring event from a direction genuinely being held. + * The only sound trigger is therefore an external one: the device that was + * asserting the axis went away or was replaced. + * + * [keyMask] is deliberately untouched, so a button held through the event + * keeps working; [publishMask] recomputes from both masks. + */ + private fun releaseMotionInputs() { + if (motionMask == 0) return + motionMask = 0 + motionDeviceId = -1 + publishMask() + } + + private fun applyMotionBit(index: Int, pressed: Boolean) { + val bit = 1 shl index + val was = motionMask and bit != 0 + if (was == pressed) return + motionMask = if (pressed) motionMask or bit else motionMask and bit.inv() + } + + /** + * Sends the port's whole state when it differs from what was last sent. + * + * Both sources are already bitmasks, so the current state is one OR and + * the change test is one comparison -- no per-bit work, and one JNI call + * per input event however many bits moved. + */ + private fun publishMask() { + val desired = keyMask or motionMask + if (desired == sentMask) return + sentMask = desired + bridge.onPad(desired) + } + + // Start is deferred so it can double as the menu gesture: a quick + // press/release reaches the game as a brief pulse, holding past the + // threshold opens (or steps back through) the overlay -- exactly once per + // gesture, not per edge. While the overlay is already open any press + // closes/steps it back immediately, matching the old Dart behaviour. + private fun handleStart(pressed: Boolean) { + if (pressed) { + if (bridge.overlayOpen) { + startConsumed = true + bridge.onMenu() + return + } + startConsumed = false + cancelStartTimer() + val timer = Runnable { + startTimer = null + startConsumed = true + bridge.onMenu() + } + startTimer = timer + handler.postDelayed(timer, START_HOLD_MS) + } else { + cancelStartTimer() + val consumed = startConsumed + startConsumed = false + if (!consumed) pulseStart() + } + } + + private fun cancelStartTimer() { + startTimer?.let(handler::removeCallbacks) + startTimer = null + } + + private fun pulseStart() { + val bit = 1 shl RETRO_START + keyMask = keyMask or bit + publishMask() + handler.postDelayed({ + keyMask = keyMask and bit.inv() + publishMask() + }, START_PULSE_MS) + } + + private fun tryCapture(event: KeyEvent): Boolean { + val device = event.device ?: return false + val identity = AndroidGamepadIdentity.of(device) + if (identity.getValue("id") != captureDeviceId) return false + captureActive = false + captureDeviceId = null + callbacks.onControllerMappingKey(event.keyCode, identity) + return true + } + + private fun indexFor(deviceId: Int, keyCode: Int): Int { + if (keyCode < 0 || keyCode >= TABLE_SIZE) return NONE + return tableFor(deviceId)[keyCode] + } + + private fun tableFor(deviceId: Int): IntArray { + deviceTables.get(deviceId)?.let { return it } + val table = buildTable(deviceId) + deviceTables.put(deviceId, table) + return table + } + + private fun buildTable(deviceId: Int): IntArray { + if (customMappings.isEmpty()) return DEFAULT_TABLE + val overrides = InputDevice.getDevice(deviceId)?.let { device -> + customMappings[AndroidGamepadIdentity.of(device).getValue("id")] + } + if (overrides.isNullOrEmpty()) return DEFAULT_TABLE + val table = DEFAULT_TABLE.copyOf() + for ((keyCode, index) in overrides) { + if (keyCode in 0 until TABLE_SIZE && index in 0..15) table[keyCode] = index + } + return table + } + + private fun isVolumeKey(keyCode: Int): Boolean = keyCode == KeyEvent.KEYCODE_VOLUME_UP || + keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + keyCode == KeyEvent.KEYCODE_VOLUME_MUTE + + private fun axisDirection(event: MotionEvent, hatAxis: Int, stickAxis: Int): Int = + direction(event.getAxisValue(hatAxis)).takeIf { it != 0 } + ?: direction(event.getAxisValue(stickAxis)) + + private fun direction(value: Float): Int = when { + value <= -AXIS_THRESHOLD -> -1 + value >= AXIS_THRESHOLD -> 1 + else -> 0 + } + + private fun parseControllerMappings(json: String): Map> = try { + val root = JSONObject(json) + buildMap { + val deviceIds = root.keys() + while (deviceIds.hasNext()) { + val deviceId = deviceIds.next() + val rawMapping = root.optJSONObject(deviceId) ?: continue + val mapping = mutableMapOf() + val keycodes = rawMapping.keys() + while (keycodes.hasNext()) { + val keycodeText = keycodes.next() + val keycode = keycodeText.toIntOrNull() ?: continue + val button = rawMapping.optInt(keycodeText, -1) + if (button in 0..15) mapping[keycode] = button + } + put(deviceId, mapping) + } + } + } catch (_: Exception) { + emptyMap() + } + + private companion object { + const val AXIS_THRESHOLD = 0.5f + const val START_HOLD_MS = 1500L + // Two frames at 60Hz: long enough for the per-frame OR-latch to + // guarantee visibility to the core, short enough to read as a tap. + const val START_PULSE_MS = 34L + const val TABLE_SIZE = 256 + const val NONE = -1 + const val SWALLOW = -2 + + const val RETRO_A = 0 + const val RETRO_X = 1 + const val RETRO_SELECT = 2 + const val RETRO_START = 3 + const val RETRO_UP = 4 + const val RETRO_DOWN = 5 + const val RETRO_LEFT = 6 + const val RETRO_RIGHT = 7 + const val RETRO_B = 8 + const val RETRO_Y = 9 + const val RETRO_L1 = 10 + const val RETRO_R1 = 11 + const val RETRO_L2 = 12 + const val RETRO_R2 = 13 + const val RETRO_L3 = 14 + const val RETRO_R3 = 15 + + // Physically exclusive pairs: no d-pad can assert both ends of an axis, + // so a press of one proves the other is no longer held. NONE for every + // other slot -- two face buttons carry no such relationship. + val OPPOSITE = IntArray(16) { NONE }.apply { + this[RETRO_UP] = RETRO_DOWN + this[RETRO_DOWN] = RETRO_UP + this[RETRO_LEFT] = RETRO_RIGHT + this[RETRO_RIGHT] = RETRO_LEFT + } + + // Keycodes Android defines for generic gamepad buttons that RetroPad + // has no default slot for. Left unmapped they must still be swallowed + // here, or they leak into Flutter's focus system as exactly the + // unfinished events InputDispatcher warns about (defect #5). + val SWALLOWED_KEYCODES = intArrayOf( + KeyEvent.KEYCODE_BUTTON_C, + KeyEvent.KEYCODE_BUTTON_Z, + KeyEvent.KEYCODE_BUTTON_1, + KeyEvent.KEYCODE_BUTTON_2, + KeyEvent.KEYCODE_BUTTON_3, + KeyEvent.KEYCODE_BUTTON_4, + KeyEvent.KEYCODE_BUTTON_5, + KeyEvent.KEYCODE_BUTTON_6, + KeyEvent.KEYCODE_BUTTON_7, + KeyEvent.KEYCODE_BUTTON_8, + KeyEvent.KEYCODE_BUTTON_9, + KeyEvent.KEYCODE_BUTTON_10, + KeyEvent.KEYCODE_BUTTON_11, + KeyEvent.KEYCODE_BUTTON_12, + KeyEvent.KEYCODE_BUTTON_13, + KeyEvent.KEYCODE_BUTTON_14, + KeyEvent.KEYCODE_BUTTON_15, + KeyEvent.KEYCODE_BUTTON_16, + ) + + val DEFAULT_TABLE: IntArray = IntArray(TABLE_SIZE) { NONE }.also { table -> + for (keyCode in SWALLOWED_KEYCODES) { + if (keyCode in 0 until TABLE_SIZE) table[keyCode] = SWALLOW + } + table[KeyEvent.KEYCODE_DPAD_UP] = RETRO_UP + table[KeyEvent.KEYCODE_DPAD_DOWN] = RETRO_DOWN + table[KeyEvent.KEYCODE_DPAD_LEFT] = RETRO_LEFT + table[KeyEvent.KEYCODE_DPAD_RIGHT] = RETRO_RIGHT + table[KeyEvent.KEYCODE_DPAD_CENTER] = RETRO_A + table[KeyEvent.KEYCODE_ENTER] = RETRO_A + table[KeyEvent.KEYCODE_BUTTON_A] = RETRO_A + table[KeyEvent.KEYCODE_BUTTON_B] = RETRO_B + table[KeyEvent.KEYCODE_BUTTON_X] = RETRO_X + table[KeyEvent.KEYCODE_BUTTON_Y] = RETRO_Y + table[KeyEvent.KEYCODE_BUTTON_SELECT] = RETRO_SELECT + table[KeyEvent.KEYCODE_BUTTON_L1] = RETRO_L1 + table[KeyEvent.KEYCODE_BUTTON_R1] = RETRO_R1 + table[KeyEvent.KEYCODE_BUTTON_L2] = RETRO_L2 + table[KeyEvent.KEYCODE_BUTTON_R2] = RETRO_R2 + table[KeyEvent.KEYCODE_BUTTON_THUMBL] = RETRO_L3 + table[KeyEvent.KEYCODE_BUTTON_THUMBR] = RETRO_R3 + } + } +} From 13b56481ce2acbe335ef0599b1bedeb103d32ad6 Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Thu, 6 Aug 2026 23:17:50 -0400 Subject: [PATCH 3/9] feat(input): controller remapping capture and UI across Android and desktop --- .../native_controller_mapping_screen.dart | 363 ++++++++++++++++++ .../gamepad/android_gamepad_channel.dart | 77 +++- .../gamepad/controller_mapping_capture.dart | 149 +++++++ lib/util/native_controller_mapping.dart | 148 +++++++ ...native_controller_mapping_screen_test.dart | 82 ++++ test/util/native_controller_mapping_test.dart | 62 +++ 6 files changed, 875 insertions(+), 6 deletions(-) create mode 100644 lib/ui/screens/playback/native_controller_mapping_screen.dart create mode 100644 lib/util/focus/gamepad/controller_mapping_capture.dart create mode 100644 lib/util/native_controller_mapping.dart create mode 100644 test/ui/screens/playback/native_controller_mapping_screen_test.dart create mode 100644 test/util/native_controller_mapping_test.dart diff --git a/lib/ui/screens/playback/native_controller_mapping_screen.dart b/lib/ui/screens/playback/native_controller_mapping_screen.dart new file mode 100644 index 000000000..a78f71ae3 --- /dev/null +++ b/lib/ui/screens/playback/native_controller_mapping_screen.dart @@ -0,0 +1,363 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:gamepads/gamepads.dart'; + +import '../../../util/focus/gamepad/controller_mapping_capture.dart'; +import '../../../util/native_controller_mapping.dart'; + +/// A privacy-safe physical controller identifier. On Android it comes from the +/// gamepad channel and is stable across reconnects and never a raw descriptor; +/// on Windows and Linux it is a namespaced gamepads-package controller id (see +/// [desktopControllerDeviceId]). +class NativeControllerDevice { + const NativeControllerDevice({required this.id, required this.name}); + + final String id; + final String name; + + factory NativeControllerDevice.fromMap(Map map) { + return NativeControllerDevice( + id: map['id']?.toString() ?? '', + name: map['name']?.toString() ?? 'Android gamepad', + ); + } +} + +/// D-pad-navigable native RetroPad remapping panel. +/// +/// The surrounding native game overlay owns pause/resume and forwards its +/// libretro button events through [handleButton]. Android sends a separate raw +/// key event only while this widget is waiting to capture a binding. +class NativeControllerMappingScreen extends StatefulWidget { + const NativeControllerMappingScreen({ + super.key, + required this.devices, + required this.mappings, + required this.onMappingChanged, + required this.onClose, + }); + + final List devices; + final Map mappings; + final Future Function(String deviceId, NativeControllerMapping mapping) + onMappingChanged; + final VoidCallback onClose; + + @override + State createState() => + NativeControllerMappingScreenState(); +} + +class NativeControllerMappingScreenState + extends State { + static const _rowExtent = 58.0; + + final ScrollController _scroll = ScrollController(); + int _selected = 0; + int _deviceIndex = 0; + RetroPadButton? _capturing; + late NativeControllerMapping _mapping; + final ControllerMappingCapture? _capture = + ControllerMappingCapture.forPlatform(); + + NativeControllerDevice? get _device => + widget.devices.isEmpty ? null : widget.devices[_deviceIndex]; + int get _rowCount => 1 + RetroPadButton.values.length + 1; + + @override + void initState() { + super.initState(); + _loadSelectedDevice(); + _capture?.attach(_onCaptured); + } + + @override + void dispose() { + _capture?.dispose(); + _scroll.dispose(); + super.dispose(); + } + + /// The device list can now change while this panel is open: Android's + /// InputDeviceListener invalidates it on connect/disconnect, and a + /// controller can drop mid-remap. Keep [_deviceIndex] valid, and if the + /// device the user had selected is still present (just moved), follow it + /// rather than resetting to whatever is now first. If it's gone, clamp to + /// a neighbouring index — the row 0 label re-renders with the new device's + /// name, so the switch is visible rather than silent. + @override + void didUpdateWidget(covariant NativeControllerMappingScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (identical(oldWidget.devices, widget.devices)) return; + + final previousDevice = _deviceIndex < oldWidget.devices.length + ? oldWidget.devices[_deviceIndex] + : null; + final matchedIndex = previousDevice == null + ? -1 + : widget.devices.indexWhere((d) => d.id == previousDevice.id); + if (matchedIndex != -1) { + _deviceIndex = matchedIndex; + } else if (widget.devices.isEmpty) { + _deviceIndex = 0; + } else { + _deviceIndex = _deviceIndex.clamp(0, widget.devices.length - 1); + } + _loadSelectedDevice(); + } + + void _loadSelectedDevice() { + _mapping = _device == null + ? NativeControllerMapping.empty + : widget.mappings[_device!.id] ?? NativeControllerMapping.empty; + } + + /// Called by the native player screen for standard RetroPad overlay input. + void handleButton(int index, bool pressed) { + if (!pressed || _capturing != null) return; + switch (index) { + case 4: + _move(-1); + case 5: + _move(1); + case 6: + _changeDevice(-1); + case 7: + _changeDevice(1); + case 0: + _activateSelected(); + case 8: + widget.onClose(); + } + } + + Future _onCaptured(String deviceId, int code) async { + final capturing = _capturing; + // The device can be switched, or the panel closed, between arming capture + // and the press arriving; binding to whatever is selected now would attach + // the press to a controller the user never touched. + if (capturing == null || deviceId != _device?.id) return; + + final mapping = _mapping.withBinding(code, capturing); + setState(() { + _mapping = mapping; + _capturing = null; + }); + await _capture?.end(); + await widget.onMappingChanged(deviceId, mapping); + } + + void _move(int delta) { + final next = ((_selected + delta) % _rowCount + _rowCount) % _rowCount; + setState(() => _selected = next); + if (!_scroll.hasClients) return; + final target = (next * _rowExtent) + .clamp(0.0, _scroll.position.maxScrollExtent) + .toDouble(); + _scroll.animateTo( + target, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + ); + } + + void _changeDevice(int delta) { + if (widget.devices.length < 2) return; + final next = + ((_deviceIndex + delta) % widget.devices.length + + widget.devices.length) % + widget.devices.length; + setState(() { + _deviceIndex = next; + _loadSelectedDevice(); + }); + } + + void _activateSelected() { + if (_selected == 0) { + _changeDevice(1); + return; + } + if (_selected == _rowCount - 1) { + _reset(); + return; + } + final device = _device; + final capture = _capture; + if (device == null || capture == null) return; + final button = RetroPadButton.values[_selected - 1]; + setState(() => _capturing = button); + unawaited(capture.begin(device.id)); + } + + void _reset() { + final device = _device; + if (device == null) return; + setState(() => _mapping = NativeControllerMapping.empty); + unawaited( + widget.onMappingChanged(device.id, NativeControllerMapping.empty), + ); + } + + /// How a stored binding is described back to the user. + /// + /// A bare "Key code 97" is the best that can be said of an Android keycode, + /// but a desktop binding was captured as a known button and deserves its + /// name. [desktopGamepadButtonsByCode] returning null is what distinguishes + /// the two, so no platform check is needed here. + String _bindingLabel(int? code) { + if (code == null) return 'Default layout'; + final button = desktopGamepadButtonsByCode[code]; + if (button != null) return _desktopButtonLabel(button); + return 'Key code $code'; + } + + String _desktopButtonLabel(GamepadButton button) => switch (button) { + GamepadButton.a => 'A / Cross', + GamepadButton.b => 'B / Circle', + GamepadButton.x => 'X / Square', + GamepadButton.y => 'Y / Triangle', + GamepadButton.leftBumper => 'Left bumper', + GamepadButton.rightBumper => 'Right bumper', + GamepadButton.leftTrigger => 'Left trigger', + GamepadButton.rightTrigger => 'Right trigger', + GamepadButton.back => 'Back / Select', + GamepadButton.start => 'Start / Menu', + GamepadButton.home => 'Guide', + GamepadButton.leftStick => 'Left stick click', + GamepadButton.rightStick => 'Right stick click', + GamepadButton.dpadUp => 'D-pad Up', + GamepadButton.dpadDown => 'D-pad Down', + GamepadButton.dpadLeft => 'D-pad Left', + GamepadButton.dpadRight => 'D-pad Right', + GamepadButton.touchpad => 'Touchpad', + }; + + int? _keycodeFor(RetroPadButton button) { + for (final entry in _mapping.keycodeToButton.entries) { + if (entry.value == button) return entry.key; + } + return null; + } + + @override + Widget build(BuildContext context) { + if (widget.devices.isEmpty) { + return const Padding( + padding: EdgeInsets.all(16), + child: Text( + 'Connect a physical controller to change its mapping.', + style: TextStyle(color: Colors.white70, fontSize: 18), + ), + ); + } + + if (_capturing case final button?) { + return Padding( + padding: const EdgeInsets.all(20), + child: Text( + 'Press the physical button to bind to ${button.label}.', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white, fontSize: 20), + ), + ); + } + + return Flexible( + child: ListView.builder( + controller: _scroll, + shrinkWrap: true, + itemExtent: _rowExtent, + itemCount: _rowCount, + itemBuilder: (context, index) { + if (index == 0) { + return _row( + 'Controller: ${_device!.name}', + index, + trailing: Icons.swap_horiz, + onTap: () { + setState(() => _selected = index); + _changeDevice(1); + }, + ); + } + if (index == _rowCount - 1) { + return _row( + 'Reset to defaults', + index, + trailing: Icons.restart_alt, + onTap: () { + setState(() => _selected = index); + _reset(); + }, + ); + } + final button = RetroPadButton.values[index - 1]; + final keycode = _keycodeFor(button); + return _row( + button.label, + index, + subtitle: _bindingLabel(keycode), + trailing: Icons.chevron_right, + onTap: () { + setState(() => _selected = index); + _activateSelected(); + }, + ); + }, + ), + ); + } + + Widget _row( + String label, + int index, { + String? subtitle, + IconData? trailing, + required VoidCallback onTap, + }) { + final selected = index == _selected; + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: selected ? const Color(0x333F8CFF) : Colors.transparent, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: selected ? const Color(0xFF3F8CFF) : Colors.transparent, + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 18), + ), + if (subtitle != null) + Text( + subtitle, + style: const TextStyle( + color: Colors.white54, + fontSize: 13, + ), + ), + ], + ), + ), + if (trailing != null) Icon(trailing, color: Colors.white70), + ], + ), + ), + ); + } +} diff --git a/lib/util/focus/gamepad/android_gamepad_channel.dart b/lib/util/focus/gamepad/android_gamepad_channel.dart index 06b5fea70..df44aeaf5 100644 --- a/lib/util/focus/gamepad/android_gamepad_channel.dart +++ b/lib/util/focus/gamepad/android_gamepad_channel.dart @@ -18,7 +18,11 @@ class AndroidGamepadChannel { 'org.moonfin.androidtv/gamepad', ); - static Future Function(MethodCall)? _buttonHandler; + // Handles both onButton (native RetroPad input) and onKeyboard (physical + // keyboard forwarded from the overlay) — despite the name implied by the + // old field, it is not button-only. + static Future Function(MethodCall)? _emulatorInputHandler; + static Future Function(MethodCall)? _controllerMappingKeyHandler; static AndroidStickNavigator? _navigator; static bool _installed = false; @@ -30,10 +34,11 @@ class AndroidGamepadChannel { _navigator = AndroidStickNavigator(); } - /// Register the emulator's button handler for the lifetime of a game. - /// Pass null on teardown. - static void setButtonHandler(Future Function(MethodCall)? handler) => - _buttonHandler = handler; + /// Register the emulator's input handler (onButton and onKeyboard) for the + /// lifetime of a game. Pass null on teardown. + static void setEmulatorInputHandler( + Future Function(MethodCall)? handler, + ) => _emulatorInputHandler = handler; /// Tell the native side whether a game currently owns the pad. static Future setGameActive(bool active) async { @@ -42,10 +47,70 @@ class AndroidGamepadChannel { await _channel.invokeMethod('setActive', {'active': active}); } + static Future setEmulatorControlsActive(bool active) async { + if (!PlatformDetection.isAndroid) return; + await _channel.invokeMethod('setEmulatorControlsActive', { + 'active': active, + }); + } + + /// Applies per-controller native RetroPad overrides for the active session. + static Future setControllerMapping(String mappingJson) async { + if (!PlatformDetection.isAndroid) return; + await _channel.invokeMethod('setControllerMapping', { + 'mapping': mappingJson, + }); + } + + /// Captures the next physical button from [deviceId] instead of forwarding + /// it to libretro. Used only while the native mapping overlay is rebinding. + static Future setControllerMappingCapture( + bool active, { + String? deviceId, + }) async { + if (!PlatformDetection.isAndroid) return; + await _channel.invokeMethod('setControllerMappingCapture', { + 'active': active, + 'deviceId': ?deviceId, + }); + } + + static void setControllerMappingKeyHandler( + Future Function(MethodCall)? handler, + ) => _controllerMappingKeyHandler = handler; + + /// Tells NativePadInput whether the in-game pause overlay is showing. + /// Native RetroPad Start uses this to decide between its short-press + /// pulse/long-press-hold gesture (overlay closed) and closing/stepping + /// back through the overlay on any press (overlay open); LibretroBridge + /// uses it to gate the "button" EventChannel message to overlay navigation + /// only, so nothing crosses the channel during gameplay. + static Future setOverlayOpen(bool open) async { + if (!PlatformDetection.isAndroid) return; + await _channel.invokeMethod('setOverlayOpen', {'open': open}); + } + + /// Physical pads known to Android. The native side returns privacy-safe, + /// stable profile ids rather than raw device descriptors. + static Future>> getEmulatorGamepads() async { + if (!PlatformDetection.isAndroid) return const []; + final result = await _channel.invokeListMethod( + 'getGamepadDevices', + ); + return result + ?.whereType() + .map((value) => value.cast()) + .toList(growable: false) ?? + const []; + } + static Future _dispatch(MethodCall call) async { switch (call.method) { case 'onButton': - return _buttonHandler?.call(call); + case 'onKeyboard': + return _emulatorInputHandler?.call(call); + case 'onControllerMappingKey': + return _controllerMappingKeyHandler?.call(call); case 'onNavigate': final args = (call.arguments as Map).cast(); _navigator?.handle( diff --git a/lib/util/focus/gamepad/controller_mapping_capture.dart b/lib/util/focus/gamepad/controller_mapping_capture.dart new file mode 100644 index 000000000..0253dcf27 --- /dev/null +++ b/lib/util/focus/gamepad/controller_mapping_capture.dart @@ -0,0 +1,149 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:gamepads/gamepads.dart'; + +import '../../native_controller_mapping.dart'; +import '../../platform_detection.dart'; +import 'android_gamepad_channel.dart'; + +/// Reads the next raw button press from a specific controller so the remapping +/// panel can bind it. +/// +/// This exists because the two platforms that support remapping learn about a +/// button press in completely different ways. Android routes an unfiltered +/// KeyEvent up through its gamepad channel, and only while capture is armed -- +/// the rest of the time those events belong to the running core. Windows and +/// Linux never involve native code at all: the gamepads package is already +/// streaming every controller event into Dart, so capture is a matter of +/// watching the stream that is running anyway. +/// +/// The panel only needs "tell me the next button from this device", so that is +/// the whole interface. Keeping the difference behind it is what lets one +/// remapping screen serve both instead of growing platform branches. +abstract class ControllerMappingCapture { + /// The capture implementation for the current platform, or null where + /// remapping is unsupported (Apple platforms bind their buttons in Swift). + static ControllerMappingCapture? forPlatform() { + if (PlatformDetection.isAndroid) return _AndroidControllerMappingCapture(); + if (PlatformDetection.isWindows || PlatformDetection.isLinux) { + return _GamepadsControllerMappingCapture(); + } + return null; + } + + /// Registers the sink for captured bindings. [code] is an Android keycode or + /// a [desktopGamepadButtonCodes] value depending on the implementation; it is + /// only ever handed back to a [NativeControllerMapping] for the same device, + /// so the caller never has to care which. + void attach(void Function(String deviceId, int code) onCaptured); + + /// Starts listening for the next button from [deviceId]. + Future begin(String deviceId); + + /// Stops listening. Safe to call when capture was never armed. + Future end(); + + /// Releases anything [attach] or [begin] set up. + void dispose(); +} + +/// Android: arms the native channel's capture mode and reads the raw KeyEvent +/// it sends back. +class _AndroidControllerMappingCapture implements ControllerMappingCapture { + void Function(String deviceId, int code)? _onCaptured; + String? _armedDeviceId; + + @override + void attach(void Function(String deviceId, int code) onCaptured) { + _onCaptured = onCaptured; + AndroidGamepadChannel.ensureInstalled(); + AndroidGamepadChannel.setControllerMappingKeyHandler(_onRawKey); + } + + Future _onRawKey(MethodCall call) async { + if (call.method != 'onControllerMappingKey') return; + final armed = _armedDeviceId; + if (armed == null) return; + final args = (call.arguments as Map).cast(); + final keycode = (args['keyCode'] as num?)?.toInt(); + final device = (args['device'] as Map?)?.cast(); + // A press from a controller other than the one being remapped is not the + // binding the user is making, and must not be swallowed as one. + if (keycode == null || device?['id'] != armed) return; + _onCaptured?.call(armed, keycode); + } + + @override + Future begin(String deviceId) async { + _armedDeviceId = deviceId; + await AndroidGamepadChannel.setControllerMappingCapture( + true, + deviceId: deviceId, + ); + } + + @override + Future end() async { + _armedDeviceId = null; + await AndroidGamepadChannel.setControllerMappingCapture(false); + } + + @override + void dispose() { + _armedDeviceId = null; + unawaited(AndroidGamepadChannel.setControllerMappingCapture(false)); + AndroidGamepadChannel.setControllerMappingKeyHandler(null); + _onCaptured = null; + } +} + +/// Windows and Linux: watches the gamepads stream the player screen is already +/// consuming. +class _GamepadsControllerMappingCapture implements ControllerMappingCapture { + void Function(String deviceId, int code)? _onCaptured; + StreamSubscription? _subscription; + String? _armedDeviceId; + + @override + void attach(void Function(String deviceId, int code) onCaptured) { + _onCaptured = onCaptured; + } + + @override + Future begin(String deviceId) async { + _armedDeviceId = deviceId; + // Subscribed per capture rather than for the panel's lifetime so a pad + // being waggled while the user reads the list can never be mistaken for a + // binding. + await _subscription?.cancel(); + _subscription = Gamepads.normalizedEvents.listen(_onEvent); + } + + void _onEvent(NormalizedGamepadEvent event) { + final armed = _armedDeviceId; + final button = event.button; + // Axis events carry a null button, and the release edge of a press would + // otherwise bind whichever button the user just let go of. + if (armed == null || button == null || event.value == 0) return; + if (desktopControllerDeviceId(event.gamepadId) != armed) return; + final code = desktopGamepadButtonCodes[button]; + if (code == null) return; + _onCaptured?.call(armed, code); + } + + @override + Future end() async { + _armedDeviceId = null; + await _subscription?.cancel(); + _subscription = null; + } + + @override + void dispose() { + _armedDeviceId = null; + unawaited(_subscription?.cancel()); + _subscription = null; + _onCaptured = null; + } +} diff --git a/lib/util/native_controller_mapping.dart b/lib/util/native_controller_mapping.dart new file mode 100644 index 000000000..385800014 --- /dev/null +++ b/lib/util/native_controller_mapping.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; + +import 'package:gamepads/gamepads.dart'; +import 'package:server_core/server_core.dart'; + +/// The RetroPad semantic indices accepted by the native libretro bridge. +enum RetroPadButton { + a(0, 'A'), + x(1, 'X'), + select(2, 'Select'), + start(3, 'Start'), + up(4, 'D-pad Up'), + down(5, 'D-pad Down'), + left(6, 'D-pad Left'), + right(7, 'D-pad Right'), + b(8, 'B'), + y(9, 'Y'), + l1(10, 'L1'), + r1(11, 'R1'), + l2(12, 'L2'), + r2(13, 'R2'), + l3(14, 'L3'), + r3(15, 'R3'); + + const RetroPadButton(this.retroPadIndex, this.label); + + final int retroPadIndex; + final String label; +} + +/// Custom physical-keycode bindings for one controller. Missing bindings keep +/// MainActivity's established RetroPad layout as their default. +class NativeControllerMapping { + const NativeControllerMapping(this.keycodeToButton); + + static const empty = NativeControllerMapping({}); + + final Map keycodeToButton; + + factory NativeControllerMapping.fromJson(String json) { + try { + final decoded = jsonDecode(json); + if (decoded is! Map) return empty; + final bindings = {}; + for (final entry in decoded.entries) { + final keycode = int.tryParse(entry.key.toString()); + final buttonIndex = entry.value is num + ? (entry.value as num).toInt() + : int.tryParse(entry.value.toString()); + if (keycode == null || buttonIndex == null) continue; + final button = RetroPadButton.values + .where((candidate) => candidate.retroPadIndex == buttonIndex) + .firstOrNull; + if (button != null) bindings[keycode] = button; + } + return NativeControllerMapping(Map.unmodifiable(bindings)); + } catch (_) { + return empty; + } + } + + String toJson() => jsonEncode({ + for (final entry in keycodeToButton.entries) + entry.key.toString(): entry.value.retroPadIndex, + }); + + NativeControllerMapping withBinding(int keycode, RetroPadButton button) { + final next = Map.from(keycodeToButton); + // One physical key and one semantic button each have exactly one binding. + next.removeWhere((_, current) => current == button); + next[keycode] = button; + return NativeControllerMapping(Map.unmodifiable(next)); + } +} + +/// Persisted binding codes for the gamepads package's normalized buttons. +/// +/// Windows and Linux read controllers in Dart, so they have no keycode to key a +/// [NativeControllerMapping] by the way Android does. These codes fill that +/// role. They are written out one by one rather than taken from +/// `GamepadButton.index` on purpose: the enum belongs to a third-party package, +/// and inserting or reordering a member there would silently rebind every saved +/// desktop mapping to the wrong button, with nothing in this repo changing to +/// hint at it. They start at 1000 so a value that ever reaches the Android path +/// is obviously not an Android keycode. +const Map desktopGamepadButtonCodes = { + GamepadButton.a: 1000, + GamepadButton.b: 1001, + GamepadButton.x: 1002, + GamepadButton.y: 1003, + GamepadButton.leftBumper: 1004, + GamepadButton.rightBumper: 1005, + GamepadButton.leftTrigger: 1006, + GamepadButton.rightTrigger: 1007, + GamepadButton.back: 1008, + GamepadButton.start: 1009, + GamepadButton.home: 1010, + GamepadButton.leftStick: 1011, + GamepadButton.rightStick: 1012, + GamepadButton.dpadUp: 1013, + GamepadButton.dpadDown: 1014, + GamepadButton.dpadLeft: 1015, + GamepadButton.dpadRight: 1016, + GamepadButton.touchpad: 1017, +}; + +/// The reverse of [desktopGamepadButtonCodes], for turning a stored binding +/// back into the button that produced it. +final Map desktopGamepadButtonsByCode = Map.unmodifiable({ + for (final entry in desktopGamepadButtonCodes.entries) entry.value: entry.key, +}); + +/// Namespaces a gamepads-package controller id for use as a save id. +/// +/// Android device ids are hashes; the gamepads package hands out short platform +/// ids that can be as plain as "0". Prefixing keeps a desktop pad from ever +/// colliding with an Android controller's saved mapping in the same account, +/// and makes which platform wrote a save obvious from the id alone. +String desktopControllerDeviceId(String gamepadId) => 'pad:$gamepadId'; + +String _controllerMappingSaveId(String deviceHash) => + 'moonfin-native-controller-$deviceHash'; + +Future loadControllerMapping( + GamesApi games, + String deviceHash, +) async { + try { + final blob = await games.getSave( + _controllerMappingSaveId(deviceHash), + kind: 'settings', + ); + if (blob == null || blob.isEmpty) return NativeControllerMapping.empty; + return NativeControllerMapping.fromJson(utf8.decode(blob)); + } catch (_) { + return NativeControllerMapping.empty; + } +} + +Future saveControllerMapping( + GamesApi games, + String deviceHash, + NativeControllerMapping mapping, +) => games.putSave( + _controllerMappingSaveId(deviceHash), + utf8.encode(mapping.toJson()), + kind: 'settings', +); diff --git a/test/ui/screens/playback/native_controller_mapping_screen_test.dart b/test/ui/screens/playback/native_controller_mapping_screen_test.dart new file mode 100644 index 000000000..ca7c6140b --- /dev/null +++ b/test/ui/screens/playback/native_controller_mapping_screen_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:moonfin/ui/screens/playback/native_controller_mapping_screen.dart'; + +void main() { + const deviceA = NativeControllerDevice(id: 'a', name: 'Pad A'); + const deviceB = NativeControllerDevice(id: 'b', name: 'Pad B'); + const deviceC = NativeControllerDevice(id: 'c', name: 'Pad C'); + + Widget harness( + List devices, + GlobalKey key, + ) { + return MaterialApp( + home: Scaffold( + body: Column( + children: [ + NativeControllerMappingScreen( + key: key, + devices: devices, + mappings: const {}, + onMappingChanged: (_, _) async {}, + onClose: () {}, + ), + ], + ), + ), + ); + } + + testWidgets( + 'rebuild with a shorter device list clamps selection instead of throwing', + (tester) async { + final key = GlobalKey(); + await tester.pumpWidget(harness([deviceA, deviceB, deviceC], key)); + key.currentState!.handleButton(7, true); // -> b + await tester.pump(); + key.currentState!.handleButton(7, true); // -> c + await tester.pump(); + expect(find.text('Controller: ${deviceC.name}'), findsOneWidget); + + // deviceC (currently selected) is gone; list is now shorter than the + // stored index. Must not throw RangeError. + await tester.pumpWidget(harness([deviceA], key)); + await tester.pump(); + + expect(find.text('Controller: ${deviceA.name}'), findsOneWidget); + }, + ); + + testWidgets( + 'rebuild with an empty device list renders the empty-state message', + (tester) async { + final key = GlobalKey(); + await tester.pumpWidget(harness([deviceA, deviceB], key)); + await tester.pumpWidget(harness(const [], key)); + await tester.pump(); + + expect( + find.text('Connect a physical controller to change its mapping.'), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'rebuild preserves the selected device when it moves to a different index', + (tester) async { + final key = GlobalKey(); + await tester.pumpWidget(harness([deviceA, deviceB, deviceC], key)); + key.currentState!.handleButton(7, true); // -> b + await tester.pump(); + expect(find.text('Controller: ${deviceB.name}'), findsOneWidget); + + // Same device, new position in the list. + await tester.pumpWidget(harness([deviceC, deviceA, deviceB], key)); + await tester.pump(); + + expect(find.text('Controller: ${deviceB.name}'), findsOneWidget); + }, + ); +} diff --git a/test/util/native_controller_mapping_test.dart b/test/util/native_controller_mapping_test.dart new file mode 100644 index 000000000..1b77ff236 --- /dev/null +++ b/test/util/native_controller_mapping_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gamepads/gamepads.dart'; +import 'package:moonfin/util/native_controller_mapping.dart'; + +void main() { + test('round-trips through JSON', () { + const mapping = NativeControllerMapping({ + 96: RetroPadButton.a, + 97: RetroPadButton.b, + }); + + final restored = NativeControllerMapping.fromJson(mapping.toJson()); + + expect(restored.keycodeToButton, mapping.keycodeToButton); + }); + + test('withBinding replaces any existing binding of the same button', () { + const mapping = NativeControllerMapping({96: RetroPadButton.a}); + + final rebound = mapping.withBinding(97, RetroPadButton.a); + + expect(rebound.keycodeToButton[96], isNull); + expect(rebound.keycodeToButton[97], RetroPadButton.a); + }); + + test('withBinding replaces any existing binding of the same key', () { + const mapping = NativeControllerMapping({96: RetroPadButton.a}); + + final rebound = mapping.withBinding(96, RetroPadButton.b); + + expect(rebound.keycodeToButton[96], RetroPadButton.b); + }); + + test('ignores malformed persisted bindings', () { + final restored = NativeControllerMapping.fromJson('{"96": 99, "bad": 0}'); + + expect(restored.keycodeToButton, isEmpty); + }); + + // These codes are persisted in users' saved desktop mappings, so the table + // has to stay both complete and stable across gamepads-package upgrades. A + // new button arriving upstream is silently unmappable without the first + // check; a duplicated code would make two physical buttons share one + // binding. Neither shows up as a compile error. + test('every normalized gamepad button has a unique persisted code', () { + expect( + desktopGamepadButtonCodes.keys.toSet(), + GamepadButton.values.toSet(), + reason: 'gamepads upgrade changed GamepadButton; update the code table', + ); + expect( + desktopGamepadButtonCodes.values.toSet().length, + GamepadButton.values.length, + reason: 'two buttons share a persisted code', + ); + expect(desktopGamepadButtonsByCode.length, GamepadButton.values.length); + }); + + test('desktop device ids are namespaced away from Android hashes', () { + expect(desktopControllerDeviceId('0'), 'pad:0'); + }); +} From de7983909a961244abfb2f5b4067c0e5488f9669 Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Thu, 6 Aug 2026 23:17:51 -0400 Subject: [PATCH 4/9] feat(games): add retro playback screens and rework the artwork pipeline Artwork moves to server-side thumbnails: cards drive their own transfers and cancel on dispose, replacing the row-band scheduler. Playback marks itself active so the screensaver cannot cover a running game. --- assets/licenses/cores/fbneo.txt | 165 +++ ios/game_host/cores/README.md | 2 +- ios/game_host/fetch_cores.sh | 6 +- lib/app.dart | 14 +- .../retro_artwork_activity_gate.dart | 181 +++ .../retro_artwork/retro_artwork_cache.dart | 165 +++ .../retro_artwork_data_source.dart | 853 ++++++++++++++ .../retro_artwork_disk_cache.dart | 2 + .../retro_artwork_disk_cache_io.dart | 452 +++++++ .../retro_artwork_disk_cache_stub.dart | 17 + .../retro_artwork_transport.dart | 513 ++++++++ .../game_system_browse_view_model.dart | 4 +- lib/di/modules/app_module.dart | 18 +- lib/main.dart | 1 + lib/playback/native_game_player.dart | 8 +- lib/ui/navigation/app_router.dart | 93 +- lib/ui/navigation/destinations.dart | 9 + lib/ui/screens/games/game_detail_screen.dart | 1042 ++++++++++++++++- lib/ui/screens/games/game_library_screen.dart | 122 +- lib/ui/screens/games/game_system_screen.dart | 840 +++++++++---- .../playback/game_emulator_screen.dart | 881 ++++++++++---- lib/ui/screens/playback/game_playback_ui.dart | 111 ++ .../playback/native_game_player_screen.dart | 974 ++++++++++++--- lib/ui/widgets/bounded_network_image.dart | 21 +- .../game/game_artwork_load_scheduler.dart | 151 --- lib/ui/widgets/game/game_poster_card.dart | 40 +- lib/ui/widgets/game/game_poster_rail.dart | 95 +- lib/ui/widgets/game/game_system_card.dart | 129 +- lib/ui/widgets/game/retro_artwork_image.dart | 275 +++++ lib/util/game_artwork_cache.dart | 82 ++ lib/util/game_core_licenses.dart | 8 +- lib/util/game_cores.dart | 384 ++++-- lib/util/tv_image_cache_io.dart | 263 ++++- lib/util/tv_image_cache_stub.dart | 6 + .../retro_artwork_data_source_test.dart | 391 +++++++ .../retro_artwork_disk_cache_test.dart | 152 +++ .../retro_artwork_transport_test.dart | 377 ++++++ .../game_system_browse_view_model_test.dart | 62 +- test/playback/native_game_player_test.dart | 65 + .../player_route_observer_test.dart | 75 ++ .../games/game_detail_screen_test.dart | 981 ++++++++++++++++ .../games/game_library_screen_test.dart | 72 ++ .../games/game_system_screen_test.dart | 323 +++++ .../playback/game_emulator_screen_test.dart | 137 +++ .../playback/game_playback_ui_test.dart | 67 ++ .../native_game_player_screen_test.dart | 345 ++++++ .../game_artwork_load_scheduler_test.dart | 103 -- .../widgets/game/game_system_card_test.dart | 221 +++- test/util/game_artwork_scope_sweep_test.dart | 119 ++ test/util/game_cores_test.dart | 353 ++++++ tvos/scripts/cores/fetch_cores.sh | 6 +- 51 files changed, 10579 insertions(+), 1197 deletions(-) create mode 100644 assets/licenses/cores/fbneo.txt create mode 100644 lib/data/services/retro_artwork/retro_artwork_activity_gate.dart create mode 100644 lib/data/services/retro_artwork/retro_artwork_cache.dart create mode 100644 lib/data/services/retro_artwork/retro_artwork_data_source.dart create mode 100644 lib/data/services/retro_artwork/retro_artwork_disk_cache.dart create mode 100644 lib/data/services/retro_artwork/retro_artwork_disk_cache_io.dart create mode 100644 lib/data/services/retro_artwork/retro_artwork_disk_cache_stub.dart create mode 100644 lib/data/services/retro_artwork/retro_artwork_transport.dart create mode 100644 lib/ui/screens/playback/game_playback_ui.dart delete mode 100644 lib/ui/widgets/game/game_artwork_load_scheduler.dart create mode 100644 lib/ui/widgets/game/retro_artwork_image.dart create mode 100644 lib/util/game_artwork_cache.dart create mode 100644 test/data/services/retro_artwork_data_source_test.dart create mode 100644 test/data/services/retro_artwork_disk_cache_test.dart create mode 100644 test/data/services/retro_artwork_transport_test.dart create mode 100644 test/playback/native_game_player_test.dart create mode 100644 test/ui/screens/games/game_detail_screen_test.dart create mode 100644 test/ui/screens/games/game_library_screen_test.dart create mode 100644 test/ui/screens/games/game_system_screen_test.dart create mode 100644 test/ui/screens/playback/game_emulator_screen_test.dart create mode 100644 test/ui/screens/playback/game_playback_ui_test.dart create mode 100644 test/ui/screens/playback/native_game_player_screen_test.dart delete mode 100644 test/ui/widgets/game/game_artwork_load_scheduler_test.dart create mode 100644 test/util/game_artwork_scope_sweep_test.dart create mode 100644 test/util/game_cores_test.dart diff --git a/assets/licenses/cores/fbneo.txt b/assets/licenses/cores/fbneo.txt new file mode 100644 index 000000000..8360647e9 --- /dev/null +++ b/assets/licenses/cores/fbneo.txt @@ -0,0 +1,165 @@ +You may freely use, modify, and distribute both the FB Neo source code and binary, however the following restrictions apply to the FB Neo original material (see below for a list of libraries with differing licenses, please consult their respective documentation for more information): + + - You may not sell, lease, rent or otherwise seek to gain monetary profit from FB Neo; + - You must make public any changes you make to the source code; + - You must include, verbatim, the full text of this license; + - You may not distribute FB Neo with ROM images unless you have the legal right to distribute them; + - You may not ask for donations to support your work on any project that uses the FB Neo source code. + +FB Neo can currently be obtained from https://neo-source.com. + +FB Neo would not exist without a lot of code from the MAME project. The MAME project is subject to its own license, which can be found at https://raw.githubusercontent.com/mamedev/mame/5cef4e1f91010f0d573bbd662208979fa39e73ec/docs/mamelicense.txt. Due to the use of MAME code in FB Neo, FB Neo is also subject to the terms of the MAME license. + +FB Neo is based on Final Burn (formally at http://www.finalburn.com), see additional text below. +Musashi MC68000/MC68010/MC68EC020 CPU core by Karl Stenerud (http://www.mamedev.org). +A68K MC68000 CPU core by Mike Coates & Darren Olafson (http://www.mamedev.org). +Z80 CPU core by Juergen Buchmueller (http://www.mamedev.org). +ARM CPU core by Bryan McPhail, Phil Stroffolino (http://www.mamedev.org). +ARM7 CPU core by Steve Ellenoff (http://www.mamedev.org). +H6280 CPU core by Brian McPhail (http://www.mamedev.org). +HD6309 CPU core by John Butler, Tim Lindner (http://www.mamedev.org). +I8039 CPU core by Mirko Buffoni (http://www.mamedev.org). +Konami CPU core by MAMEdev (http://www.mamedev.org). +M6502 CPU core by Juergen Buchmueller (http://www.mamedev.org). +M6800/M6801/M6802/M6803/M6808/HD63701/NSC8105 CPU core by MAMEdev (http://www.mamedev.org). +M6805 CPU core by MAMEdev (http://www.mamedev.org). +M6809 CPU core by John Bulter (http://www.mamedev.org). +NEC V20/V30/V33 CPU core by MAMEdev (http://www.mamedev.org). +PIC16C5X CPU core by Tony La Porta (http://www.mamedev.org). +S2650 CPU core by Juergen Buchmueller (http://www.mamedev.org). +SH-2 CPU core by Juergen Buchmueller (http://www.mamedev.org). +TLCS90 CPU core by Luca Elia (http://www.mamedev.org). +ADSP21XX CPU core by Aaron Giles (http://www.mamedev.org). +AY8910/YM2149 sound core by various authors (http://www.mamedev.org). +C6280 sound core by Charles MacDonald (http://cgfm2.emuviews.com). +DAC sound core by MAMEdev (http://www.mamedev.org). +ES8712 sound core by MAMEdev (http://www.mamedev.org). +ICS2115 sound core by O.Galibert, El-Semi (http://www.mamedev.org). +IREM GA20 sound core by MAMEdev (http://www.mamedev.org). +K005289 sound core by Brian McPhail (http://www.mamedev.org). +K007232 sound core by MAMEdev (http://www.mamedev.org). +K051649 sound core by Brian McPhail (http://www.mamedev.org). +K053260 sound core by MAMEdev (http://www.mamedev.org). +K054539 sound core by MAMEdev (http://www.mamedev.org). +MSM5205 sound core by Aaron Giles (http://www.mamedev.org). +MSM5232 sound core by MAMEdev (http://www.mamedev.org). +RF5C68 sound core by MAMEdev (http://www.mamedev.org). +SAA1099 sound core by Juergen Buchmueller, Manuel Abadia (http://www.mamedev.org). +Sega PCM sound core by MAMEdev (http://www.mamedev.org). +SN76496 sound core by Nicola Salmoria (http://www.mamedev.org). +UPD7759 sound core by Juergen Buchmueller, Mike Balfour, Howie Cohen, Olivier Galibert, Aaron Giles (http://www.mamedev.org). +VLM5030 sound core by Tatsuyuki Satoh (http://www.mamedev.org). +X1010 sound core by Luca Elia, Manbow-J (http://www.mamedev.org). +Y8950/YM3526/YM3812 sound core by Jarek Burczynski & Tatsuyuki Satoh (http://www.mamedev.org). +YM2151 sound core by Jarek Burczynski (http://www.mamedev.org). +YM2203/YM2608/YM2610/YM2612 sound cores by Jarek Burczynski & Tatsuyuki Satoh (http://www.mamedev.org). +YM2413 sound core by Jarek Burczynski (http://www.mamedev.org). +YMF278B sound core by R. Belmont & O.Galibert (http://www.mamedev.org). + +Uses SMS Plus by Charles MacDonald (http://www.techno-junk.org). + +7Z functionality provided by LZMA SDK (http://www.7-zip.org/sdk.html). +PNG functionality provided by libspng (https://libspng.org/). +Zip functionality provided by zlib (http://www.zlib.net). +Zip functionality also provided by kuba zip (https://github.com/kuba--/zip) +FLAC and MP3 functionality provided by dr_libs (https://github.com/mackron/dr_libs). + +Uses Xbyak (JIT assembler for x86/x64) by Herumi (https://github.com/herumi/xbyak) + +Some graphics effects provided by the Scale2x, 2xPM, Eagle Graphics, 2xSaI, hq2x/hq3x/hq4x, hq2xS/hq3xS/SuperEagle/2xSaI (VBA), hq2xS/hq3xS/hq2xBold/hq3xBold/EPXB/EPXC (SNES9X ReRecording) and SuperScale libraries (http://scale2x.sourceforge.net, http://2xpm.freeservers.com, http://retrofx.com, http://elektron.its.tudelft.nl/~dalikifa, http://www.hiend3d.com, http://code.google.com/p/vba-rerecording, http://code.google.com/p/snes9x151-rerecording, http://nebula.emulatronia.com). + +IntegerScaling library for pixel-perfect integer-ratio scaling by Marat Tanalin (https://github.com/Marat-Tanalin/integer-scaling) + +Miscellaneous other components from various sources. Copyright and license information are contained in the relevant parts of the source code. + +All material not covered above © 2004-2025 Team FB Neo. + +DISCLAIMER: The authors of FB Neo don't guarantee its fitness for any purpose, implied or otherwise, and do not accept responsibility for any damages whatsoever that might occur when using FB Neo. All games emulated by FB Neo, including any images and sounds therein, are copyrighted by their respective copyright holders. FB Neo DOES NOT INCLUDE any ROM images of emulated games. + +The following information and license conditions accompanied the original Final Burn emulator. They also apply to FB Neo: + +"Copyright (c)2001 Dave (formally of www.finalburn.com), all rights reserved. This refers to all code except where stated otherwise (e.g. unzip and zlib code)." + +"You can use, modify and redistribute this code freely as long as you don't do so commercially. This copyright notice must remain with the code. If your program uses this code, you must either distribute or link to the source code. If you modify or improve this code, you must distribute the source code improvements." + +"Dave" +"Former Homepage: www.finalburn.com" +"E-mail: dave@finalburn.com" + + +Portions Copyright � 1997-2022 MAMEdev and contributors +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Portions Copyright � 1997-2015 Nicola Salmoria and the MAME team +Unless otherwise explicitly stated, all code in MAME is released under the +following license: + +Copyright Nicola Salmoria and the MAME team +All rights reserved. + +Redistribution and use of this code or any derivative works are permitted +provided that the following conditions are met: + +* Redistributions may not be sold, nor may they be used in a commercial +product or activity. + +* Redistributions that are modified from the original source must include the +complete source code, including the source code for all components used by a +binary built from the modified sources. However, as a special exception, the +source code distributed need not include anything that is normally distributed +(in either source or binary form) with the major components (compiler, kernel, +and so on) of the operating system on which the executable runs, unless that +component itself accompanies the executable. + +* Redistributions must reproduce the above copyright notice, this list of +conditions and the following disclaimer in the documentation and/or other +materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + + +libspng: + +BSD 2-Clause License + +Copyright (c) 2018-2023, Randy +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH diff --git a/ios/game_host/cores/README.md b/ios/game_host/cores/README.md index 3cebfced2..7ac3867d2 100644 --- a/ios/game_host/cores/README.md +++ b/ios/game_host/cores/README.md @@ -10,4 +10,4 @@ app. The binaries are not committed. Populate it before a build: Both leave `*.framework` bundles here, which `moonfin_game_host.podspec` embeds. The set matches `appleBundledCores` in `lib/util/game_cores.dart`: -fceumm, snes9x, gambatte, mgba, genesis_plus_gx, pcsx_rearmed. +fceumm, snes9x, gambatte, mgba, genesis_plus_gx, pcsx_rearmed, fbneo. diff --git a/ios/game_host/fetch_cores.sh b/ios/game_host/fetch_cores.sh index 00733ec79..d938e00ac 100755 --- a/ios/game_host/fetch_cores.sh +++ b/ios/game_host/fetch_cores.sh @@ -10,7 +10,11 @@ set -euo pipefail CORES=("$@") if [ ${#CORES[@]} -eq 0 ]; then - CORES=(fceumm snes9x gambatte mgba genesis_plus_gx pcsx_rearmed) + # fbneo carries arcade support; interpreter-only, so it clears the no-JIT + # gate. ~53 MB unpacked, by far the largest core here. Keep in sync by hand + # with tvos/scripts/cores/fetch_cores.sh and appleBundledCores in + # lib/util/game_cores.dart. + CORES=(fceumm snes9x gambatte mgba genesis_plus_gx pcsx_rearmed fbneo) fi BUILDBOT="https://buildbot.libretro.com/nightly/apple/ios-arm64/latest" diff --git a/lib/app.dart b/lib/app.dart index 31dc24982..38e47c832 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -21,6 +21,7 @@ import 'data/services/connectivity_service.dart'; import 'data/services/download_service.dart'; import 'data/services/seerr_notification_service.dart'; import 'data/services/plugin_sync_service.dart'; +import 'data/services/retro_artwork/retro_artwork_data_source.dart'; import 'data/services/theme_music_service.dart'; import 'data/services/topshelf_service.dart'; import 'data/services/watch_next_service.dart'; @@ -142,11 +143,13 @@ class _MoonfinAppState extends State { void _onGlassQualityChanged(GlassQuality from, GlassQuality to) { GlassCapability.onAdaptiveQualityChanged(from, to); - unawaited(_prefs.set(UserPreferences.glassSettledQuality, switch (to) { - GlassQuality.minimal => GlassSettledQuality.minimal, - GlassQuality.standard => GlassSettledQuality.standard, - GlassQuality.premium => GlassSettledQuality.premium, - })); + unawaited( + _prefs.set(UserPreferences.glassSettledQuality, switch (to) { + GlassQuality.minimal => GlassSettledQuality.minimal, + GlassQuality.standard => GlassSettledQuality.standard, + GlassQuality.premium => GlassSettledQuality.premium, + }), + ); // GlassBackdrop reads GlassSettings.cheapBackdrop in build, so a // throttle step needs a shell rebuild to collapse or restore the bloom. if (mounted) setState(() {}); @@ -955,6 +958,7 @@ class _ConnectivityListenerState extends ConsumerState<_ConnectivityListener> _backgroundAwareSession?.onAppBackgrounded(); } else if (state == AppLifecycleState.resumed) { coordinator.appDidBecomeActive(); + RetroArtworkDataSourceFactory.notifyAppResumed(); _backgroundAwareSession?.onAppResumed(); if (GetIt.instance.isRegistered()) { GetIt.instance().onAppResumed(); diff --git a/lib/data/services/retro_artwork/retro_artwork_activity_gate.dart b/lib/data/services/retro_artwork/retro_artwork_activity_gate.dart new file mode 100644 index 000000000..bbab7dc03 --- /dev/null +++ b/lib/data/services/retro_artwork/retro_artwork_activity_gate.dart @@ -0,0 +1,181 @@ +import 'dart:async'; + +enum RetroArtworkBlocker { gameplay, coveredRoute } + +enum RetroArtworkActivityKind { + transfer, + provider, + decode, + manifestRefresh, + priorityCall, +} + +/// Cooperative cancellation shared by artwork HTTP, decode, and metadata work. +/// +/// Dio requests can stop immediately through a `CancelToken`. Flutter image +/// decoding is not itself cancellable, so decode callers must also check this +/// signal after the codec future completes and discard a cancelled result. +class RetroArtworkCancellationSignal { + final Set _listeners = {}; + final Completer _cancelledCompleter = Completer(); + + bool _isCancelled = false; + Object? _reason; + + bool get isCancelled => _isCancelled; + Object? get reason => _reason; + Future get whenCancelled => _cancelledCompleter.future; + + void addListener(void Function() listener) { + if (_isCancelled) { + listener(); + return; + } + _listeners.add(listener); + } + + void removeListener(void Function() listener) { + _listeners.remove(listener); + } + + void throwIfCancelled() { + if (_isCancelled) { + throw RetroArtworkCancelledException(_reason); + } + } + + void _cancel(Object? reason) { + if (_isCancelled) return; + _isCancelled = true; + _reason = reason; + _cancelledCompleter.complete(); + final listeners = List.of(_listeners); + _listeners.clear(); + for (final listener in listeners) { + listener(); + } + } +} + +class RetroArtworkCancelledException implements Exception { + const RetroArtworkCancelledException([this.reason]); + + final Object? reason; + + @override + String toString() => reason == null + ? 'Retro artwork activity was cancelled' + : 'Retro artwork activity was cancelled: $reason'; +} + +class RetroArtworkActivityPermit { + RetroArtworkActivityPermit._(this._gate, this.kind, this.signal); + + final RetroArtworkActivityGate _gate; + final RetroArtworkActivityKind kind; + final RetroArtworkCancellationSignal signal; + bool _disposed = false; + + void cancel([Object? reason]) { + signal._cancel(reason); + } + + void dispose() { + if (_disposed) return; + _disposed = true; + _gate._release(this); + } +} + +/// Single switchboard for all retro-artwork activity. +/// +/// Entering gameplay or covering the artwork route cancels every issued +/// permit. While either blocker remains active, no new permits are issued. +class RetroArtworkActivityGate { + static final Object _defaultRouteCoverageOwner = Object(); + + final Set _blockers = {}; + final Set _permits = + {}; + final Set _listeners = {}; + final Set _routeCoverageOwners = {}; + int _gameplayOwners = 0; + + bool get isOpen => _blockers.isEmpty; + bool get isGameplayActive => _blockers.contains(RetroArtworkBlocker.gameplay); + bool get isRouteCovered => + _blockers.contains(RetroArtworkBlocker.coveredRoute); + Set get blockers => Set.unmodifiable(_blockers); + + void setGameplayActive(bool active) { + if (active) { + _gameplayOwners++; + } else if (_gameplayOwners > 0) { + _gameplayOwners--; + } + _setBlocked(RetroArtworkBlocker.gameplay, _gameplayOwners > 0); + } + + /// Keeps coverage idempotent per owner so one route lifecycle cannot reopen + /// the gate while another still considers artwork hidden. + void setRouteCovered(bool covered, {Object? owner}) { + final coverageOwner = owner ?? _defaultRouteCoverageOwner; + if (covered) { + _routeCoverageOwners.add(coverageOwner); + } else { + _routeCoverageOwners.remove(coverageOwner); + } + _setBlocked( + RetroArtworkBlocker.coveredRoute, + _routeCoverageOwners.isNotEmpty, + ); + } + + RetroArtworkActivityPermit? tryAcquire(RetroArtworkActivityKind kind) { + if (!isOpen) return null; + final permit = RetroArtworkActivityPermit._( + this, + kind, + RetroArtworkCancellationSignal(), + ); + _permits.add(permit); + + // Keep acquisition correct if a synchronous listener closed the gate. + if (!isOpen) { + permit.cancel(_blockers.first); + permit.dispose(); + return null; + } + return permit; + } + + void addListener(void Function() listener) { + _listeners.add(listener); + } + + void removeListener(void Function() listener) { + _listeners.remove(listener); + } + + void _setBlocked(RetroArtworkBlocker blocker, bool blocked) { + final changed = blocked + ? _blockers.add(blocker) + : _blockers.remove(blocker); + if (!changed) return; + + if (blocked) { + final permits = List.of(_permits); + for (final permit in permits) { + permit.cancel(blocker); + } + } + + for (final listener in List.of(_listeners)) { + listener(); + } + } + + void _release(RetroArtworkActivityPermit permit) { + _permits.remove(permit); + } +} diff --git a/lib/data/services/retro_artwork/retro_artwork_cache.dart b/lib/data/services/retro_artwork/retro_artwork_cache.dart new file mode 100644 index 000000000..be41e884f --- /dev/null +++ b/lib/data/services/retro_artwork/retro_artwork_cache.dart @@ -0,0 +1,165 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +const retroArtworkCacheBudgetBytes = 150 * 1024 * 1024; + +String normalizeRetroArtworkServerIdentity(String baseUrl) { + final uri = Uri.parse(baseUrl); + final normalizedPath = uri.path.replaceFirst(RegExp(r'/+$'), ''); + final defaultPort = + (uri.scheme.toLowerCase() == 'http' && uri.port == 80) || + (uri.scheme.toLowerCase() == 'https' && uri.port == 443); + return Uri( + scheme: uri.scheme.toLowerCase(), + host: uri.host.toLowerCase(), + port: defaultPort + ? null + : uri.hasPort + ? uri.port + : null, + path: normalizedPath, + ).toString(); +} + +/// Credential-free identity for one immutable protocol-2 artwork revision. +/// +/// [serverIdentity] is an authenticated server-record ID when available. A +/// normalized base URL without query/user-info is an acceptable fallback. +class RetroArtworkCacheKey { + const RetroArtworkCacheKey({ + required this.serverIdentity, + required this.libraryId, + required this.gameId, + required this.role, + required this.revision, + }); + + final String serverIdentity; + final String libraryId; + final String gameId; + final String role; + final String revision; + + /// Safe for a future disk backend: neither credentials nor raw identifiers + /// appear in the persisted filename. + String get storageKey { + final components = [ + serverIdentity, + libraryId, + gameId, + role, + revision, + ]; + final canonical = components + .map((component) => '${utf8.encode(component).length}:$component') + .join('|'); + return sha256.convert(utf8.encode(canonical)).toString(); + } + + @override + bool operator ==(Object other) => + other is RetroArtworkCacheKey && + other.serverIdentity == serverIdentity && + other.libraryId == libraryId && + other.gameId == gameId && + other.role == role && + other.revision == revision; + + @override + int get hashCode => + Object.hash(serverIdentity, libraryId, gameId, role, revision); +} + +abstract interface class RetroArtworkByteCache { + int get maxBytes; + int get currentBytes; + int get length; + + FutureOr get(RetroArtworkCacheKey key); + FutureOr put(RetroArtworkCacheKey key, Uint8List bytes); + FutureOr remove(RetroArtworkCacheKey key); + FutureOr clear(); + + /// Releases the cache. The disk implementation debounces its index writes, so + /// this is what flushes any pending bookkeeping; in-memory implementations + /// have nothing to do. + FutureOr dispose(); +} + +/// In-process compressed-byte LRU used by tests and as an optional disk-cache +/// front layer. Production persistence is provided by the disk implementation. +class RetroArtworkByteLruCache implements RetroArtworkByteCache { + RetroArtworkByteLruCache({this.maxBytes = retroArtworkCacheBudgetBytes}) { + if (maxBytes <= 0) { + throw ArgumentError.value(maxBytes, 'maxBytes', 'Must be positive'); + } + } + + // Bounded by bytes alone, deliberately. An entry here is a *compressed* image + // -- ~42 KB for a server-derived thumbnail -- so the 16 MB front budget already + // caps this at a few hundred entries and a handful of megabytes. The memory + // that actually matters is the decoded copy in Flutter's imageCache, which is + // roughly 18x larger per image and is budgeted separately in main.dart. A + // count cap here would shrink the cheap cache while leaving the expensive one + // untouched. + @override + final int maxBytes; + final LinkedHashMap _entries = + LinkedHashMap(); + int _currentBytes = 0; + + @override + int get currentBytes => _currentBytes; + @override + int get length => _entries.length; + Iterable get keys => + List.unmodifiable(_entries.keys); + + @override + Uint8List? get(RetroArtworkCacheKey key) { + final bytes = _entries.remove(key); + if (bytes == null) return null; + _entries[key] = bytes; + return bytes; + } + + bool containsKey(RetroArtworkCacheKey key) => _entries.containsKey(key); + + @override + bool put(RetroArtworkCacheKey key, Uint8List bytes) { + remove(key); + if (bytes.lengthInBytes > maxBytes) return false; + + _entries[key] = bytes; + _currentBytes += bytes.lengthInBytes; + while (_currentBytes > maxBytes && _entries.isNotEmpty) { + remove(_entries.keys.first); + } + return true; + } + + @override + Uint8List? remove(RetroArtworkCacheKey key) { + final removed = _entries.remove(key); + if (removed != null) { + _currentBytes -= removed.lengthInBytes; + } + return removed; + } + + @override + void clear() { + _entries.clear(); + _currentBytes = 0; + } + + @override + void dispose() { + // Nothing persists here, so releasing the entries is all there is to do. + clear(); + } +} diff --git a/lib/data/services/retro_artwork/retro_artwork_data_source.dart b/lib/data/services/retro_artwork/retro_artwork_data_source.dart new file mode 100644 index 000000000..0aae5e915 --- /dev/null +++ b/lib/data/services/retro_artwork/retro_artwork_data_source.dart @@ -0,0 +1,853 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:server_core/server_core.dart'; + +import 'retro_artwork_activity_gate.dart'; +import 'retro_artwork_cache.dart'; +import 'retro_artwork_transport.dart'; + +/// Selects the artwork protocol once for a particular authenticated server +/// session. A new login, user, token, or server URL deliberately gets a fresh +/// capability lookup even when the [MediaServerClient] object is reused. +class RetroArtworkDataSourceFactory { + RetroArtworkDataSourceFactory._(); + + static final Expando<_CapabilityCacheEntry> _capabilityCache = + Expando<_CapabilityCacheEntry>('retroArtworkCapabilities'); + static final Set _activeDataSources = + {}; + + static void notifyAppResumed() { + for (final dataSource in List.of( + _activeDataSources, + )) { + dataSource.onAppResumed(); + } + } + + static T _track(T dataSource) { + _activeDataSources.add(dataSource); + return dataSource; + } + + static void _untrack(RetroArtworkDataSource dataSource) { + _activeDataSources.remove(dataSource); + } + + static Future create({ + required MediaServerClient client, + required RetroArtworkActivityGate activityGate, + required RetroArtworkTransport transport, + Duration priorityDebounce = const Duration(milliseconds: 180), + }) async { + final gamesApi = client.gamesApi; + if (gamesApi == null) return null; + + final capabilities = await _capabilitiesFor(client, gamesApi); + if (capabilities.supportsManifest && capabilities.supportsVersionedAssets) { + return _track( + ManifestArtworkAdapter( + gamesApi: gamesApi, + serverIdentity: normalizeRetroArtworkServerIdentity(client.baseUrl), + activityGate: activityGate, + transport: transport, + supportsPriorityHints: capabilities.supportsPriorityHints, + priorityDebounce: priorityDebounce, + ), + ); + } + + return _track(LegacyArtworkAdapter(gamesApi: gamesApi)); + } + + static Future _capabilitiesFor( + MediaServerClient client, + GamesApi gamesApi, + ) { + final sessionKey = + '${client.baseUrl}\u0000${client.userId}\u0000${client.accessToken}'; + final cached = _capabilityCache[client]; + if (cached?.sessionKey == sessionKey) return cached!.capabilities; + + final capabilities = _readCapabilities(gamesApi); + _capabilityCache[client] = _CapabilityCacheEntry(sessionKey, capabilities); + return capabilities; + } + + static Future _readCapabilities(GamesApi api) async { + try { + return await api.getArtworkCapabilities() ?? + const GameArtworkCapabilities(); + } catch (_) { + // A capability probe must not make established protocol-1 artwork + // unavailable during a transient server failure. + return const GameArtworkCapabilities(); + } + } +} + +class _CapabilityCacheEntry { + const _CapabilityCacheEntry(this.sessionKey, this.capabilities); + + final String sessionKey; + final Future capabilities; +} + +enum RetroArtworkProtocol { manifest, legacy } + +typedef RetroArtworkSnapshotListener = void Function(); + +/// A renderable image reference. A null value from [imageFor] means artwork +/// is missing or pending, and callers must not create an image provider. +class RetroArtworkImageReference { + const RetroArtworkImageReference._({this.source, this.legacyUrl}) + : assert(source != null || legacyUrl != null); + + const RetroArtworkImageReference.manifest(RetroArtworkSource source) + : this._(source: source); + + const RetroArtworkImageReference.legacy(String legacyUrl) + : this._(legacyUrl: legacyUrl); + + final RetroArtworkSource? source; + final String? legacyUrl; + + bool get isManifest => source != null; +} + +/// Current server state for the one system owned by a screen. +class RetroArtworkSystemSnapshot { + RetroArtworkSystemSnapshot({ + required this.libraryId, + required this.systemId, + required this.generation, + Map> descriptors = const {}, + Map> images = const {}, + }) : _descriptors = _freeze(descriptors), + _images = _freeze(images); + + final String libraryId; + final String systemId; + final String? generation; + final Map> _descriptors; + final Map> _images; + + GameArtworkDescriptor? descriptorFor(String gameId, String role) => + _descriptors[gameId]?[role]; + + RetroArtworkImageReference? imageFor( + String gameId, { + String role = 'boxart', + }) => _images[gameId]?[role]; + + List pendingPriorityItems( + Iterable orderedGameIds, { + required Iterable roles, + }) { + final items = []; + final seenGames = {}; + for (final gameId in orderedGameIds) { + if (!seenGames.add(gameId)) continue; + final descriptors = _descriptors[gameId]; + if (descriptors == null) continue; + final pendingRoles = [ + for (final role in roles) + if (descriptors[role]?.state == 'pending') role, + ]; + if (pendingRoles.isNotEmpty) { + items.add(GameArtworkPriorityItem(gameId: gameId, roles: pendingRoles)); + } + } + return items; + } + + static Map> _freeze( + Map> value, + ) => Map>.unmodifiable({ + for (final entry in value.entries) + entry.key: Map.unmodifiable(entry.value), + }); +} + +/// The screen-owned surface shared by manifest and legacy artwork protocols. +abstract interface class RetroArtworkDataSource { + RetroArtworkProtocol get protocol; + RetroArtworkSystemSnapshot? get snapshot; + + void addSnapshotListener(RetroArtworkSnapshotListener listener); + void removeSnapshotListener(RetroArtworkSnapshotListener listener); + + /// Fetches only [systemId]'s manifest. Legacy returns an empty snapshot and + /// never contacts a manifest endpoint. + Future refreshSystem({ + required String libraryId, + required String systemId, + }); + + RetroArtworkImageReference? imageFor(String gameId, {String role = 'boxart'}); + + /// Gives the manifest protocol one ordered, generation-bound chance to + /// promote pending artifacts in the active row band. Legacy is a no-op. + Future submitActiveBandPriority( + Iterable orderedGameIds, { + Iterable roles = const ['boxart'], + int? planGeneration, + }); + + /// The UI reports image outcomes here because ImageProvider does not expose + /// its HTTP status to the adapter automatically. Protocol-1 suppresses 404s + /// for the screen's lifetime and retries transient failures only at the + /// lifecycle boundaries below; protocol-2 grants one bounded, backed-off + /// retry per descriptor revision and then latches the same way. + void reportImageFailure( + String gameId, { + String role = 'boxart', + int? statusCode, + }); + + void reportImageLoaded(String gameId, {String role = 'boxart'}); + + /// Transient failures become eligible for another attempt at these + /// explicit lifecycle boundaries. Beyond its single bounded retry, + /// protocol-2 state remains owned by its manifest refresh. + void onRouteCovered(); + void onRouteReentered(); + void onAppResumed(); + void dispose(); +} + +/// Protocol-2 adapter. It maps role-keyed descriptors but only creates a +/// transport source for a ready descriptor, never for pending or missing art. +class ManifestArtworkAdapter implements RetroArtworkDataSource { + ManifestArtworkAdapter({ + required GamesApi gamesApi, + required String serverIdentity, + required RetroArtworkActivityGate activityGate, + required RetroArtworkTransport transport, + required bool supportsPriorityHints, + this.priorityDebounce = const Duration(milliseconds: 180), + this.imageRetryBackoff = const Duration(milliseconds: 750), + }) : _gamesApi = gamesApi, + _serverIdentity = serverIdentity, + _activityGate = activityGate, + _transport = transport, + _supportsPriorityHints = supportsPriorityHints { + _activityGate.addListener(_onActivityChanged); + } + + final GamesApi _gamesApi; + final String _serverIdentity; + final RetroArtworkActivityGate _activityGate; + final RetroArtworkTransport _transport; + final bool _supportsPriorityHints; + final Duration priorityDebounce; + + /// A transient image-transfer failure (HTTP blip, not a pending/missing + /// descriptor) gets exactly one retry after this backoff. The manifest's + /// own refresh/retry metadata still owns everything past that single + /// attempt — see the comment in [_submitPriority]. + final Duration imageRetryBackoff; + + @override + RetroArtworkProtocol get protocol => RetroArtworkProtocol.manifest; + + @override + RetroArtworkSystemSnapshot? get snapshot => _snapshot; + + RetroArtworkSystemSnapshot? _snapshot; + Timer? _refreshTimer; + Timer? _priorityTimer; + bool _disposed = false; + bool _routeCovered = false; + bool _resumeRefreshScheduled = false; + int _refreshEpoch = 0; + int _priorityEpoch = 0; + int? _priorityPlanGeneration; + bool _priorityDrainActive = false; + _QueuedArtworkPriority? _activePriority; + final Queue<_QueuedArtworkPriority> _priorityQueue = + Queue<_QueuedArtworkPriority>(); + final Object _manifestRequestOwner = Object(); + final Object _priorityRequestOwner = Object(); + final Set _listeners = + {}; + + /// Images currently withheld from [imageFor] while their single bounded + /// retry is in flight, keyed by `gameId\x00role`. Withholding (rather than + /// re-emitting an equal [RetroArtworkSource]) is what actually makes the + /// consumer widget restart the transfer: an unchanged descriptor maps to a + /// value-equal source, so a plain rebuild would not otherwise be noticed. + final Set _withheldImages = {}; + + /// Counts the bounded retry spent per image key so a second failure on the + /// same descriptor revision latches instead of retrying again. + final Map _imageRetryAttempts = {}; + final Map _imageRetryTimers = {}; + + @override + void addSnapshotListener(RetroArtworkSnapshotListener listener) => + _listeners.add(listener); + + @override + void removeSnapshotListener(RetroArtworkSnapshotListener listener) => + _listeners.remove(listener); + + @override + Future refreshSystem({ + required String libraryId, + required String systemId, + }) async { + final existing = _snapshot; + final sameSystem = + existing?.libraryId == libraryId && existing?.systemId == systemId; + if (_disposed || _routeCovered || !_activityGate.isOpen) { + return sameSystem + ? existing! + : RetroArtworkSystemSnapshot( + libraryId: libraryId, + systemId: systemId, + generation: null, + ); + } + + final permit = _activityGate.tryAcquire( + RetroArtworkActivityKind.manifestRefresh, + ); + if (permit == null) { + return sameSystem + ? existing! + : RetroArtworkSystemSnapshot( + libraryId: libraryId, + systemId: systemId, + generation: null, + ); + } + final epoch = ++_refreshEpoch; + try { + final GameArtworkManifest? manifest; + try { + manifest = await _gamesApi.getArtworkManifest( + libraryId, + systemId: systemId, + knownGeneration: sameSystem ? existing?.generation : null, + cancellationOwner: _manifestRequestOwner, + ); + } catch (_) { + if (_disposed || + _routeCovered || + epoch != _refreshEpoch || + permit.signal.isCancelled || + !_activityGate.isOpen) { + return sameSystem + ? existing! + : RetroArtworkSystemSnapshot( + libraryId: libraryId, + systemId: systemId, + generation: null, + ); + } + rethrow; + } + if (_disposed || + permit.signal.isCancelled || + !_activityGate.isOpen || + epoch != _refreshEpoch) { + return sameSystem + ? existing! + : RetroArtworkSystemSnapshot( + libraryId: libraryId, + systemId: systemId, + generation: null, + ); + } + if (manifest != null) { + final mapped = _mapManifest(libraryId, systemId, manifest); + if (_snapshot?.generation != mapped.generation) { + _clearPriorityQueue(cancelActive: true); + } + _snapshot = mapped; + // A fresh manifest carries fresh descriptor revisions, so any bounded + // retry latch from a previous failure no longer applies. + _clearImageRetryState(); + _notifySnapshotChanged(); + } + final current = + _snapshot ?? + RetroArtworkSystemSnapshot( + libraryId: libraryId, + systemId: systemId, + generation: null, + ); + _snapshot = current; + _scheduleManifestRefresh(current); + return current; + } finally { + permit.dispose(); + } + } + + RetroArtworkSystemSnapshot _mapManifest( + String libraryId, + String systemId, + GameArtworkManifest manifest, + ) { + final descriptors = >{}; + final images = >{}; + for (final entry in manifest.entries) { + if (entry.gameId.isEmpty) continue; + descriptors[entry.gameId] = Map.from( + entry.artwork, + ); + for (final artwork in entry.artwork.entries) { + final descriptor = artwork.value; + if (!descriptor.isRenderable) continue; + RetroArtworkSource? source; + try { + source = RetroArtworkSource.fromDescriptor( + serverIdentity: _serverIdentity, + libraryId: libraryId, + gameId: entry.gameId, + role: artwork.key, + descriptor: descriptor, + ); + } on FormatException { + source = null; + } + if (source == null) continue; + _transport.adoptSource(source); + (images[entry.gameId] ??= + {})[artwork.key] = + RetroArtworkImageReference.manifest(source); + } + } + return RetroArtworkSystemSnapshot( + libraryId: libraryId, + systemId: systemId, + generation: manifest.generation.isEmpty ? null : manifest.generation, + descriptors: descriptors, + images: images, + ); + } + + @override + RetroArtworkImageReference? imageFor( + String gameId, { + String role = 'boxart', + }) { + if (_withheldImages.contains(_imageKey(gameId, role))) return null; + return _snapshot?.imageFor(gameId, role: role); + } + + @override + Future submitActiveBandPriority( + Iterable orderedGameIds, { + Iterable roles = const ['boxart'], + int? planGeneration, + }) { + if (_disposed || + _routeCovered || + !_supportsPriorityHints || + !_activityGate.isOpen) { + return Future.value(); + } + final current = _snapshot; + if (current == null || current.generation == null) { + return Future.value(); + } + if (planGeneration != null && planGeneration != _priorityPlanGeneration) { + _priorityPlanGeneration = planGeneration; + _clearPriorityQueue(cancelActive: true); + } + final request = _QueuedArtworkPriority( + List.unmodifiable(orderedGameIds), + List.unmodifiable(roles), + ); + _priorityQueue.addLast(request); + _schedulePriorityDrain(); + return request.completion.future; + } + + void _schedulePriorityDrain() { + if (_priorityDrainActive || + _priorityTimer != null || + _priorityQueue.isEmpty) { + return; + } + final epoch = _priorityEpoch; + _priorityTimer = Timer(priorityDebounce, () { + _priorityTimer = null; + unawaited(_drainPriority(epoch)); + }); + } + + Future _drainPriority(int epoch) async { + if (_priorityDrainActive) return; + _priorityDrainActive = true; + try { + while (!_disposed && + !_routeCovered && + _activityGate.isOpen && + epoch == _priorityEpoch && + _priorityQueue.isNotEmpty) { + final request = _priorityQueue.removeFirst(); + _activePriority = request; + try { + await _submitPriority(epoch, request.gameIds, request.roles); + } finally { + if (identical(_activePriority, request)) { + _activePriority = null; + } + request.complete(); + } + } + } finally { + _priorityDrainActive = false; + _schedulePriorityDrain(); + } + } + + Future _submitPriority( + int epoch, + List orderedGameIds, + List roles, + ) async { + final current = _snapshot; + if (_disposed || + epoch != _priorityEpoch || + _routeCovered || + current == null || + current.generation == null || + !_activityGate.isOpen) { + return; + } + final items = current.pendingPriorityItems(orderedGameIds, roles: roles); + if (items.isEmpty) return; + final permit = _activityGate.tryAcquire( + RetroArtworkActivityKind.priorityCall, + ); + if (permit == null) return; + try { + await _gamesApi.submitArtworkPriority( + current.libraryId, + GameArtworkPriorityRequest( + systemId: current.systemId, + knownGeneration: current.generation!, + items: items, + ), + cancellationOwner: _priorityRequestOwner, + ); + // The endpoint has no response state to apply. Checking cancellation + // still makes late completions observationally inert. + if (permit.signal.isCancelled || !_activityGate.isOpen) return; + } catch (_) { + // Manifest retry/refresh metadata, not blind client retries, controls + // the next protocol-2 attempt. + } finally { + permit.dispose(); + } + } + + void _scheduleManifestRefresh(RetroArtworkSystemSnapshot current) { + _refreshTimer?.cancel(); + _refreshTimer = null; + final delays = []; + for (final game in current._descriptors.values) { + for (final descriptor in game.values) { + for (final seconds in [ + descriptor.retryAfterSeconds, + descriptor.refreshAfterSeconds, + ]) { + if (seconds != null && seconds > 0) { + delays.add(Duration(seconds: seconds)); + } + } + } + } + if (delays.isEmpty) return; + delays.sort((left, right) => left.compareTo(right)); + _refreshTimer = Timer(delays.first, () { + _refreshTimer = null; + if (_disposed || _routeCovered || !_activityGate.isOpen) return; + unawaited( + refreshSystem(libraryId: current.libraryId, systemId: current.systemId), + ); + }); + } + + void _onActivityChanged() { + if (_activityGate.isOpen) { + if (!_routeCovered) _scheduleResumeRefresh(); + return; + } + ++_refreshEpoch; + _refreshTimer?.cancel(); + _refreshTimer = null; + _clearPriorityQueue(cancelActive: true); + _gamesApi.cancelArtworkRequests(cancellationOwner: _manifestRequestOwner); + } + + void _clearPriorityQueue({required bool cancelActive}) { + ++_priorityEpoch; + _priorityTimer?.cancel(); + _priorityTimer = null; + for (final request in _priorityQueue) { + request.complete(); + } + _priorityQueue.clear(); + _activePriority?.complete(); + if (cancelActive) { + _gamesApi.cancelArtworkRequests(cancellationOwner: _priorityRequestOwner); + } + } + + void _scheduleResumeRefresh() { + if (_resumeRefreshScheduled || _disposed || _routeCovered) return; + final current = _snapshot; + if (current == null) return; + _resumeRefreshScheduled = true; + scheduleMicrotask(() { + _resumeRefreshScheduled = false; + if (_disposed || _routeCovered || !_activityGate.isOpen) return; + unawaited( + refreshSystem(libraryId: current.libraryId, systemId: current.systemId), + ); + }); + } + + void _notifySnapshotChanged() { + for (final listener in List.of(_listeners)) { + listener(); + } + } + + @override + void reportImageFailure( + String gameId, { + String role = 'boxart', + int? statusCode, + }) { + if (_disposed || _routeCovered) return; + final key = _imageKey(gameId, role); + final wasNewlyWithheld = _withheldImages.add(key); + if ((_imageRetryAttempts[key] ?? 0) > 0) { + // The bounded retry for this descriptor revision is already spent. + // Manifest retry/refresh metadata, not a second blind client retry, + // owns recovery from here — see [_submitPriority]. Stay latched + // (withheld) rather than scheduling another attempt. + _imageRetryTimers.remove(key)?.cancel(); + if (wasNewlyWithheld) _notifySnapshotChanged(); + return; + } + _imageRetryAttempts[key] = 1; + if (wasNewlyWithheld) _notifySnapshotChanged(); + _imageRetryTimers[key]?.cancel(); + _imageRetryTimers[key] = Timer(imageRetryBackoff, () { + _imageRetryTimers.remove(key); + if (_disposed || _routeCovered) return; + _withheldImages.remove(key); + _notifySnapshotChanged(); + }); + } + + @override + void reportImageLoaded(String gameId, {String role = 'boxart'}) { + _imageRetryAttempts.remove(_imageKey(gameId, role)); + } + + @override + void onRouteCovered() { + if (_disposed || _routeCovered) return; + _routeCovered = true; + ++_refreshEpoch; + _refreshTimer?.cancel(); + _refreshTimer = null; + _clearPriorityQueue(cancelActive: true); + _gamesApi.cancelArtworkRequests(cancellationOwner: _manifestRequestOwner); + for (final timer in _imageRetryTimers.values) { + timer.cancel(); + } + _imageRetryTimers.clear(); + } + + @override + void onRouteReentered() { + _routeCovered = false; + final hadWithheldImages = _withheldImages.isNotEmpty; + _clearImageRetryState(); + if (hadWithheldImages) _notifySnapshotChanged(); + if (_activityGate.isOpen) _scheduleResumeRefresh(); + } + + @override + void onAppResumed() { + if (!_routeCovered) onRouteReentered(); + } + + @override + void dispose() { + if (_disposed) return; + _disposed = true; + ++_refreshEpoch; + _refreshTimer?.cancel(); + _clearPriorityQueue(cancelActive: true); + _gamesApi.cancelArtworkRequests(cancellationOwner: _manifestRequestOwner); + _activityGate.removeListener(_onActivityChanged); + _listeners.clear(); + _clearImageRetryState(); + RetroArtworkDataSourceFactory._untrack(this); + } + + void _clearImageRetryState() { + if (_imageRetryAttempts.isEmpty && + _withheldImages.isEmpty && + _imageRetryTimers.isEmpty) { + return; + } + _imageRetryAttempts.clear(); + _withheldImages.clear(); + for (final timer in _imageRetryTimers.values) { + timer.cancel(); + } + _imageRetryTimers.clear(); + } + + // NUL separates the parts because it cannot occur in a game id or a role, so no pair of + // inputs can collide onto one key. Written as an escape, not a literal byte: a raw 0x00 in + // the source makes git treat this whole file as binary and stop rendering its diffs. + String _imageKey(String gameId, String role) => '$gameId\u0000$role'; +} + +/// Protocol-1 adapter. It only creates the established Thumb endpoint URLs. +/// Per-screen 404 knowledge is permanent; transient errors become eligible for +/// one retry only when the route re-enters or the app resumes. +class LegacyArtworkAdapter implements RetroArtworkDataSource { + LegacyArtworkAdapter({required GamesApi gamesApi}) : _gamesApi = gamesApi; + + final GamesApi _gamesApi; + final Set _missing = {}; + final Set _transientFailures = {}; + bool _routeCovered = false; + RetroArtworkSystemSnapshot? _snapshot; + final Set _listeners = + {}; + + @override + void addSnapshotListener(RetroArtworkSnapshotListener listener) => + _listeners.add(listener); + + @override + void removeSnapshotListener(RetroArtworkSnapshotListener listener) => + _listeners.remove(listener); + + @override + RetroArtworkProtocol get protocol => RetroArtworkProtocol.legacy; + + @override + RetroArtworkSystemSnapshot? get snapshot => _snapshot; + + @override + Future refreshSystem({ + required String libraryId, + required String systemId, + }) async { + return _snapshot = RetroArtworkSystemSnapshot( + libraryId: libraryId, + systemId: systemId, + generation: null, + ); + } + + @override + RetroArtworkImageReference? imageFor( + String gameId, { + String role = 'boxart', + }) { + final current = _snapshot; + if (current == null) return null; + final key = _key(gameId, role); + if (_missing.contains(key) || _transientFailures.contains(key)) return null; + return RetroArtworkImageReference.legacy( + _gamesApi.thumbUrl( + libraryId: current.libraryId, + gameId: gameId, + kind: role, + ), + ); + } + + @override + Future submitActiveBandPriority( + Iterable orderedGameIds, { + Iterable roles = const ['boxart'], + int? planGeneration, + }) => Future.value(); + + @override + void reportImageFailure( + String gameId, { + String role = 'boxart', + int? statusCode, + }) { + final key = _key(gameId, role); + if (statusCode == 404) { + _missing.add(key); + _transientFailures.remove(key); + } else { + _transientFailures.add(key); + } + _notifySnapshotChanged(); + } + + @override + void reportImageLoaded(String gameId, {String role = 'boxart'}) { + _transientFailures.remove(_key(gameId, role)); + } + + @override + void onRouteCovered() { + _routeCovered = true; + } + + @override + void onRouteReentered() { + _routeCovered = false; + if (_transientFailures.isEmpty) return; + _transientFailures.clear(); + _notifySnapshotChanged(); + } + + @override + void onAppResumed() { + if (!_routeCovered) onRouteReentered(); + } + + @override + void dispose() { + _missing.clear(); + _transientFailures.clear(); + _snapshot = null; + _listeners.clear(); + RetroArtworkDataSourceFactory._untrack(this); + } + + void _notifySnapshotChanged() { + for (final listener in List.of(_listeners)) { + listener(); + } + } + + String _key(String gameId, String role) => '$gameId\u0000$role'; +} + +class _QueuedArtworkPriority { + _QueuedArtworkPriority(this.gameIds, this.roles); + + final List gameIds; + final List roles; + final Completer completion = Completer(); + + void complete() { + if (!completion.isCompleted) completion.complete(); + } +} diff --git a/lib/data/services/retro_artwork/retro_artwork_disk_cache.dart b/lib/data/services/retro_artwork/retro_artwork_disk_cache.dart new file mode 100644 index 000000000..e76838a49 --- /dev/null +++ b/lib/data/services/retro_artwork/retro_artwork_disk_cache.dart @@ -0,0 +1,2 @@ +export 'retro_artwork_disk_cache_stub.dart' + if (dart.library.io) 'retro_artwork_disk_cache_io.dart'; diff --git a/lib/data/services/retro_artwork/retro_artwork_disk_cache_io.dart b/lib/data/services/retro_artwork/retro_artwork_disk_cache_io.dart new file mode 100644 index 000000000..7711d7f7b --- /dev/null +++ b/lib/data/services/retro_artwork/retro_artwork_disk_cache_io.dart @@ -0,0 +1,452 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'retro_artwork_cache.dart'; + +const _cacheDirectoryName = 'retro_game_art_cache'; + +/// Where [openDefaultRetroArtworkDiskCache] stores protocol-2 artwork. +/// +/// Lives under the OS temporary/cache directory like every other image cache +/// in the app (movie/TV/music artwork, the legacy per-system game artwork +/// cache), just in its own dedicated subdirectory with its own budget. This +/// keeps it reachable by the platform's own "Clear Cache" action, not just +/// [clearImageDiskCache]'s explicit sweep. +Future defaultRetroArtworkDiskCacheDirectory() async { + final temp = await getTemporaryDirectory(); + return Directory(p.join(temp.path, _cacheDirectoryName)); +} + +const _indexFileName = 'index.json'; +const _indexVersion = 1; +const _frontCacheBudgetBytes = 16 * 1024 * 1024; + +/// How long a `lastAccess` bump waits, coalesced with any other bumps, before +/// it is written to disk. Long enough to absorb a burst of cache hits from +/// fast scrolling (dozens per second) into a single write; short enough that +/// the on-disk index does not drift far from reality between explicit +/// flushes (put/remove/clear/eviction/dispose, which are never debounced). +const _indexFlushDebounce = Duration(milliseconds: 400); + +Future openDefaultRetroArtworkDiskCache({ + int maxBytes = retroArtworkCacheBudgetBytes, +}) async { + final directory = await defaultRetroArtworkDiskCacheDirectory(); + return openRetroArtworkDiskCacheAt(directory.path, maxBytes: maxBytes); +} + +Future openRetroArtworkDiskCacheAt( + String directoryPath, { + int maxBytes = retroArtworkCacheBudgetBytes, +}) async { + return RetroArtworkDiskLruCache.open( + Directory(directoryPath), + maxBytes: maxBytes, + ); +} + +class RetroArtworkDiskLruCache implements RetroArtworkByteCache { + RetroArtworkDiskLruCache._(this._directory, {required this.maxBytes}) + : _front = RetroArtworkByteLruCache( + maxBytes: maxBytes < _frontCacheBudgetBytes + ? maxBytes + : _frontCacheBudgetBytes, + ); + + static Future open( + Directory directory, { + int maxBytes = retroArtworkCacheBudgetBytes, + }) async { + if (maxBytes <= 0) { + throw ArgumentError.value(maxBytes, 'maxBytes', 'Must be positive'); + } + final cache = RetroArtworkDiskLruCache._(directory, maxBytes: maxBytes); + await cache._initialize(); + return cache; + } + + final Directory _directory; + final RetroArtworkByteLruCache _front; + final Map _entries = {}; + Future _operationTail = Future.value(); + int _currentBytes = 0; + int _lastAccess = 0; + + // Debounced `lastAccess` bookkeeping (Task B5). A read hit (front-cache or + // disk) only ever bumps `lastAccess` in memory; it never changes what + // survives eviction or restart. Losing a queued bump on crash only + // perturbs LRU ordering on the next run -- it can never corrupt + // `_currentBytes`/`_entries`, and `_recoverEntryFiles` recomputes sizes + // from the real files regardless. Every operation that *does* affect + // correctness (put, remove, clear, eviction, corrupt-entry cleanup, + // dispose) still flushes synchronously through [_persistIndexNow]. + bool _indexDirty = false; + Timer? _indexFlushTimer; + + /// Number of times the index has actually been written to disk. Exists + /// only so tests can assert the debounce is doing its job; production + /// code never reads it. Testing-only -- do not use for control flow. + int debugIndexWriteCount = 0; + + Directory get _entriesDirectory => + Directory(p.join(_directory.path, 'entries')); + File get _indexFile => File(p.join(_directory.path, _indexFileName)); + + @override + final int maxBytes; + + @override + int get currentBytes => _currentBytes; + + @override + int get length => _entries.length; + + @override + Future get(RetroArtworkCacheKey key) { + return _serialized(() async { + final storageKey = key.storageKey; + final entry = _entries[storageKey]; + if (entry == null) return null; + + final frontBytes = _front.get(key); + if (frontBytes != null) { + // Steady-state scroll hit: bump in memory, coalesce the write. + entry.lastAccess = _nextAccess(); + _markIndexDirty(); + return frontBytes; + } + + final file = _entryFile(storageKey); + try { + final bytes = await file.readAsBytes(); + var sizeCorrected = false; + if (bytes.lengthInBytes != entry.size) { + _currentBytes += bytes.lengthInBytes - entry.size; + entry.size = bytes.lengthInBytes; + sizeCorrected = true; + } + entry.lastAccess = _nextAccess(); + final evictedOthers = await _evictToBudget(); + if (!_entries.containsKey(storageKey)) { + // This entry was itself evicted by the size correction above. + await _persistIndexNow(); + return null; + } + _front.put(key, bytes); + if (sizeCorrected || evictedOthers) { + // Accounting changed, not just `lastAccess` -- flush now. + await _persistIndexNow(); + } else { + _markIndexDirty(); + } + return bytes; + } catch (_) { + _front.remove(key); + await _removeEntry(storageKey); + await _persistIndexNow(); + return null; + } + }); + } + + @override + Future put(RetroArtworkCacheKey key, Uint8List bytes) { + return _serialized(() async { + final storageKey = key.storageKey; + if (bytes.lengthInBytes > maxBytes) { + await _removeEntry(storageKey); + _front.remove(key); + await _persistIndexNow(); + return false; + } + + final previous = _entries[storageKey]; + if (previous != null) { + _currentBytes -= previous.size; + } + try { + await _atomicWriteBytes(_entryFile(storageKey), bytes); + } catch (_) { + if (previous != null) _currentBytes += previous.size; + rethrow; + } + final entry = _DiskEntry( + size: bytes.lengthInBytes, + lastAccess: _nextAccess(), + ); + _entries[storageKey] = entry; + _currentBytes += entry.size; + _front.put(key, bytes); + await _evictToBudget(); + await _persistIndexNow(); + return _entries.containsKey(storageKey); + }); + } + + @override + Future remove(RetroArtworkCacheKey key) { + return _serialized(() async { + final cached = _front.remove(key); + final removed = await _removeEntry(key.storageKey); + if (removed != null) await _persistIndexNow(); + return cached; + }); + } + + @override + Future clear() { + return _serialized(() async { + _front.clear(); + for (final storageKey in _entries.keys.toList(growable: false)) { + await _removeEntry(storageKey); + } + await _persistIndexNow(); + }); + } + + /// Flushes any pending debounced `lastAccess` bookkeeping and cancels the + /// timer. Call this when the cache instance is being torn down so a + /// queued LRU-only update is not silently lost just because no further + /// cache activity happens to trigger the debounce. + @override + Future dispose() { + return _serialized(() async { + _indexFlushTimer?.cancel(); + _indexFlushTimer = null; + if (_indexDirty) { + await _persistIndexNow(); + } + }); + } + + Future _initialize() async { + await _directory.create(recursive: true); + await _entriesDirectory.create(recursive: true); + await _recoverAtomicFile(_indexFile); + await _loadIndex(); + await _recoverEntryFiles(); + await _evictToBudget(); + await _persistIndexNow(); + } + + Future _loadIndex() async { + if (!await _indexFile.exists()) return; + try { + final decoded = jsonDecode(await _indexFile.readAsString()); + if (decoded is! Map || decoded['version'] != _indexVersion) return; + final encodedEntries = decoded['entries']; + if (encodedEntries is! Map) return; + for (final item in encodedEntries.entries) { + if (item.key is! String || item.value is! Map) continue; + final value = item.value as Map; + final size = value['size']; + final lastAccess = value['lastAccess']; + if (size is! num || lastAccess is! num || size < 0) continue; + final entry = _DiskEntry( + size: size.toInt(), + lastAccess: lastAccess.toInt(), + ); + _entries[item.key as String] = entry; + if (entry.lastAccess > _lastAccess) _lastAccess = entry.lastAccess; + } + } catch (_) { + _entries.clear(); + _lastAccess = 0; + } + } + + Future _recoverEntryFiles() async { + await for (final entity in _entriesDirectory.list(followLinks: false)) { + if (entity is! File) continue; + final name = p.basename(entity.path); + if (name.endsWith('.tmp')) { + await _deleteIfPresent(entity); + continue; + } + if (name.endsWith('.bak')) { + final target = File(entity.path.substring(0, entity.path.length - 4)); + if (await target.exists()) { + await _deleteIfPresent(entity); + } else { + await entity.rename(target.path); + } + } + } + + final files = {}; + await for (final entity in _entriesDirectory.list(followLinks: false)) { + if (entity is! File) continue; + final name = p.basename(entity.path); + final match = RegExp(r'^([0-9a-f]{64})\.bin$').firstMatch(name); + if (match != null) files[match.group(1)!] = entity; + } + + _currentBytes = 0; + for (final item in _entries.entries.toList(growable: false)) { + final file = files.remove(item.key); + if (file == null) { + _entries.remove(item.key); + continue; + } + final stat = await file.stat(); + item.value.size = stat.size; + _currentBytes += stat.size; + } + + for (final item in files.entries) { + final stat = await item.value.stat(); + final accessed = stat.modified.microsecondsSinceEpoch; + _entries[item.key] = _DiskEntry(size: stat.size, lastAccess: accessed); + _currentBytes += stat.size; + if (accessed > _lastAccess) _lastAccess = accessed; + } + } + + Future<_DiskEntry?> _removeEntry(String storageKey) async { + final entry = _entries[storageKey]; + if (entry == null) return null; + if (!await _deleteIfPresent(_entryFile(storageKey))) return null; + _entries.remove(storageKey); + _currentBytes -= entry.size; + return entry; + } + + /// Returns whether any entry was evicted. + Future _evictToBudget() async { + if (_currentBytes <= maxBytes) return false; + var evicted = false; + final oldest = _entries.entries.toList(growable: false) + ..sort((a, b) => a.value.lastAccess.compareTo(b.value.lastAccess)); + for (final item in oldest) { + if (_currentBytes <= maxBytes) break; + if (await _removeEntry(item.key) != null) evicted = true; + } + if (evicted) _front.clear(); + return evicted; + } + + /// Marks the index dirty and (re)schedules a short debounced flush. Used + /// only for pure `lastAccess` bumps -- anything that changes what + /// [_entries] contains must go through [_persistIndexNow] instead. + void _markIndexDirty() { + _indexDirty = true; + _indexFlushTimer?.cancel(); + _indexFlushTimer = Timer(_indexFlushDebounce, () { + _indexFlushTimer = null; + unawaited( + _serialized(() async { + if (!_indexDirty) return; + try { + await _persistIndexNow(); + } catch (_) { + // Best-effort background flush of LRU-only bookkeeping. A + // failure just leaves `lastAccess` stale on disk until the + // next put/remove/dispose flush -- never surface it as an + // unhandled async error far from any caller. + } + }), + ); + }); + } + + /// Cancels any pending debounced flush and writes the index immediately. + /// Every operation that changes what survives eviction or restart + /// (put/remove/clear/eviction/corrupt-entry cleanup/dispose) must go + /// through this, not [_markIndexDirty]. + Future _persistIndexNow() async { + _indexFlushTimer?.cancel(); + _indexFlushTimer = null; + _indexDirty = false; + await _persistIndex(); + } + + Future _persistIndex() async { + debugIndexWriteCount++; + final encoded = { + 'version': _indexVersion, + 'entries': { + for (final item in _entries.entries) item.key: item.value.toJson(), + }, + }; + await _atomicWriteBytes( + _indexFile, + Uint8List.fromList(utf8.encode(jsonEncode(encoded))), + ); + } + + File _entryFile(String storageKey) => + File(p.join(_entriesDirectory.path, '$storageKey.bin')); + + int _nextAccess() { + final now = DateTime.now().microsecondsSinceEpoch; + _lastAccess = now > _lastAccess ? now : _lastAccess + 1; + return _lastAccess; + } + + Future _serialized(Future Function() operation) { + final result = _operationTail.then((_) => operation()); + _operationTail = result.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + return result; + } + + static Future _recoverAtomicFile(File target) async { + final temporary = File('${target.path}.tmp'); + final backup = File('${target.path}.bak'); + if (!await target.exists() && await backup.exists()) { + await backup.rename(target.path); + } else if (await backup.exists()) { + await _deleteIfPresent(backup); + } + await _deleteIfPresent(temporary); + } + + static Future _atomicWriteBytes(File target, Uint8List bytes) async { + final temporary = File('${target.path}.tmp'); + final backup = File('${target.path}.bak'); + await target.parent.create(recursive: true); + await _deleteIfPresent(temporary); + await temporary.writeAsBytes(bytes, flush: true); + await _deleteIfPresent(backup); + if (await target.exists()) { + await target.rename(backup.path); + } + try { + await temporary.rename(target.path); + await _deleteIfPresent(backup); + } catch (_) { + if (!await target.exists() && await backup.exists()) { + await backup.rename(target.path); + } + rethrow; + } + } + + static Future _deleteIfPresent(File file) async { + try { + if (await file.exists()) await file.delete(); + return true; + } catch (_) { + return false; + } + } +} + +class _DiskEntry { + _DiskEntry({required this.size, required this.lastAccess}); + + int size; + int lastAccess; + + Map toJson() => { + 'size': size, + 'lastAccess': lastAccess, + }; +} diff --git a/lib/data/services/retro_artwork/retro_artwork_disk_cache_stub.dart b/lib/data/services/retro_artwork/retro_artwork_disk_cache_stub.dart new file mode 100644 index 000000000..10fe4c668 --- /dev/null +++ b/lib/data/services/retro_artwork/retro_artwork_disk_cache_stub.dart @@ -0,0 +1,17 @@ +import 'retro_artwork_cache.dart'; + +Future openDefaultRetroArtworkDiskCache({ + int maxBytes = retroArtworkCacheBudgetBytes, +}) async { + // Browser persistence needs an IndexedDB implementation. Keep the same + // bounded contract without claiming restart persistence on unsupported + // platforms. + return RetroArtworkByteLruCache(maxBytes: maxBytes); +} + +Future openRetroArtworkDiskCacheAt( + String directoryPath, { + int maxBytes = retroArtworkCacheBudgetBytes, +}) async { + return RetroArtworkByteLruCache(maxBytes: maxBytes); +} diff --git a/lib/data/services/retro_artwork/retro_artwork_transport.dart b/lib/data/services/retro_artwork/retro_artwork_transport.dart new file mode 100644 index 000000000..5a84ece00 --- /dev/null +++ b/lib/data/services/retro_artwork/retro_artwork_transport.dart @@ -0,0 +1,513 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:server_core/server_core.dart'; + +import 'retro_artwork_activity_gate.dart'; +import 'retro_artwork_cache.dart'; + +enum RetroArtworkReadyState { originalReady, thumbnailReady } + +class RetroArtworkLogicalKey { + const RetroArtworkLogicalKey({ + required this.serverIdentity, + required this.libraryId, + required this.gameId, + required this.role, + }); + + final String serverIdentity; + final String libraryId; + final String gameId; + final String role; + + @override + bool operator ==(Object other) => + other is RetroArtworkLogicalKey && + other.serverIdentity == serverIdentity && + other.libraryId == libraryId && + other.gameId == gameId && + other.role == role; + + @override + int get hashCode => Object.hash(serverIdentity, libraryId, gameId, role); +} + +/// Pending and missing descriptors deliberately have no representation here; +/// the manifest adapter must not create a source or provider for them. +class RetroArtworkSource { + RetroArtworkSource({ + required this.serverIdentity, + required this.libraryId, + required this.gameId, + required this.role, + required this.revision, + required this.state, + required this.uri, + }); + + static const Set _credentialQueryKeys = { + 'apikey', + 'api_key', + 'token', + 'access_token', + }; + + final String serverIdentity; + final String libraryId; + final String gameId; + final String role; + final String revision; + final RetroArtworkReadyState state; + final Uri uri; + + static RetroArtworkSource? fromDescriptor({ + required String serverIdentity, + required String libraryId, + required String gameId, + required String role, + required GameArtworkDescriptor descriptor, + }) { + final revision = descriptor.revision; + final url = descriptor.url; + if (!descriptor.isRenderable || + revision == null || + revision.isEmpty || + url == null || + url.isEmpty) { + return null; + } + final state = switch (descriptor.state) { + 'originalReady' => RetroArtworkReadyState.originalReady, + 'thumbnailReady' => RetroArtworkReadyState.thumbnailReady, + _ => null, + }; + if (state == null) return null; + return RetroArtworkSource( + serverIdentity: serverIdentity, + libraryId: libraryId, + gameId: gameId, + role: role, + revision: revision, + state: state, + uri: Uri.parse(url), + ); + } + + /// Authentication query values are transport details, not source identity. + String get identityUrl { + final keys = + uri.queryParametersAll.keys + .where((key) => !_credentialQueryKeys.contains(key.toLowerCase())) + .toList(growable: false) + ..sort(); + final query = >{ + for (final key in keys) key: uri.queryParametersAll[key]!, + }; + return '${uri.replace(query: '')}|${Uri(queryParameters: query).query}'; + } + + RetroArtworkLogicalKey get logicalKey => RetroArtworkLogicalKey( + serverIdentity: serverIdentity, + libraryId: libraryId, + gameId: gameId, + role: role, + ); + + RetroArtworkCacheKey get cacheKey => RetroArtworkCacheKey( + serverIdentity: serverIdentity, + libraryId: libraryId, + gameId: gameId, + role: role, + revision: revision, + ); + + @override + bool operator ==(Object other) => + other is RetroArtworkSource && + other.logicalKey == logicalKey && + other.revision == revision && + other.state == state && + other.identityUrl == identityUrl; + + @override + int get hashCode => Object.hash(logicalKey, revision, state, identityUrl); +} + +abstract interface class RetroArtworkHttpClient { + Future getBytes( + Uri uri, { + required RetroArtworkCancellationSignal cancellation, + }); + + void close(); +} + +/// Cancellable, same-server HTTP transport for authenticated artwork assets. +class DioRetroArtworkHttpClient implements RetroArtworkHttpClient { + /// Authenticated artwork requests must not carry credentials across an HTTP + /// redirect. The transport accepts only an exact same-origin initial URL. + static const bool followsRedirects = false; + + DioRetroArtworkHttpClient._(this._dio, this._serverBaseUri); + + factory DioRetroArtworkHttpClient.forServer(MediaServerClient client) { + final baseUri = Uri.parse(client.baseUrl); + final dio = Dio( + BaseOptions( + baseUrl: client.baseUrl, + followRedirects: followsRedirects, + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 30), + ), + ); + configureServerDio(dio); + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + options.headers['Authorization'] = buildServerAuthorizationHeader( + scheme: client.serverType == ServerType.emby + ? 'Emby' + : 'MediaBrowser', + deviceInfo: client.deviceInfo, + accessToken: client.accessToken, + ); + handler.next(options); + }, + ), + ); + return DioRetroArtworkHttpClient._(dio, baseUri); + } + + final Dio _dio; + final Uri _serverBaseUri; + + @override + Future getBytes( + Uri uri, { + required RetroArtworkCancellationSignal cancellation, + }) async { + cancellation.throwIfCancelled(); + final resolved = _serverBaseUri.resolveUri(uri); + if (!_sameOrigin(_serverBaseUri, resolved)) { + throw ArgumentError.value( + uri, + 'uri', + 'Retro artwork must be served by the authenticated server', + ); + } + + final cancelToken = CancelToken(); + void cancelRequest() { + if (!cancelToken.isCancelled) { + cancelToken.cancel(cancellation.reason); + } + } + + cancellation.addListener(cancelRequest); + try { + final response = await _dio.get>( + resolved.toString(), + cancelToken: cancelToken, + options: Options(responseType: ResponseType.bytes), + ); + cancellation.throwIfCancelled(); + return Uint8List.fromList(response.data ?? const []); + } on DioException catch (error) { + if (CancelToken.isCancel(error) || cancellation.isCancelled) { + throw RetroArtworkCancelledException(cancellation.reason); + } + rethrow; + } finally { + cancellation.removeListener(cancelRequest); + } + } + + @override + void close() { + _dio.close(force: true); + } + + static bool _sameOrigin(Uri left, Uri right) => + left.scheme.toLowerCase() == right.scheme.toLowerCase() && + left.host.toLowerCase() == right.host.toLowerCase() && + left.port == right.port; +} + +enum RetroArtworkLoadOutcome { + cacheHit, + downloaded, + suppressed, + staleSource, + cancelled, + failed, +} + +class RetroArtworkLoadResult { + const RetroArtworkLoadResult._(this.outcome, {this.bytes, this.error}); + + const RetroArtworkLoadResult.cacheHit(Uint8List bytes) + : this._(RetroArtworkLoadOutcome.cacheHit, bytes: bytes); + const RetroArtworkLoadResult.downloaded(Uint8List bytes) + : this._(RetroArtworkLoadOutcome.downloaded, bytes: bytes); + const RetroArtworkLoadResult.suppressed() + : this._(RetroArtworkLoadOutcome.suppressed); + const RetroArtworkLoadResult.staleSource() + : this._(RetroArtworkLoadOutcome.staleSource); + const RetroArtworkLoadResult.cancelled() + : this._(RetroArtworkLoadOutcome.cancelled); + const RetroArtworkLoadResult.failed(Object error) + : this._(RetroArtworkLoadOutcome.failed, error: error); + + final RetroArtworkLoadOutcome outcome; + final Uint8List? bytes; + final Object? error; +} + +/// Bounded transfer queue and source registry for retro artwork. +class RetroArtworkTransport { + static const int minConcurrentTransfers = 3; + static const int maximumConcurrentTransfers = 16; + // Server-side thumbnails are ~50KB, so the transfers are short and the cap is + // about not opening a connection storm rather than about pacing bandwidth. + static const int defaultMaxConcurrentTransfers = 10; + + RetroArtworkTransport({ + required RetroArtworkHttpClient httpClient, + required RetroArtworkByteCache cache, + required RetroArtworkActivityGate activityGate, + this.maxConcurrentTransfers = defaultMaxConcurrentTransfers, + }) : _httpClient = httpClient, + _cache = cache, + _activityGate = activityGate { + if (maxConcurrentTransfers < minConcurrentTransfers || + maxConcurrentTransfers > maximumConcurrentTransfers) { + throw ArgumentError.value( + maxConcurrentTransfers, + 'maxConcurrentTransfers', + 'Must be between $minConcurrentTransfers and ' + '$maximumConcurrentTransfers', + ); + } + } + + factory RetroArtworkTransport.forServer({ + required MediaServerClient client, + required RetroArtworkByteCache cache, + required RetroArtworkActivityGate activityGate, + int maxConcurrentTransfers = defaultMaxConcurrentTransfers, + }) { + return RetroArtworkTransport( + httpClient: DioRetroArtworkHttpClient.forServer(client), + cache: cache, + activityGate: activityGate, + maxConcurrentTransfers: maxConcurrentTransfers, + ); + } + + final RetroArtworkHttpClient _httpClient; + final RetroArtworkByteCache _cache; + final RetroArtworkActivityGate _activityGate; + final int maxConcurrentTransfers; + final Queue<_PendingTransfer> _queue = Queue<_PendingTransfer>(); + final Map _transfers = + {}; + final Map _sources = + {}; + final Map> _sourceReplacements = + >{}; + + int _activeTransfers = 0; + bool _disposed = false; + + int get activeTransfers => _activeTransfers; + int get queuedTransfers => _queue.length; + + /// Makes [source] authoritative for its game/role and evicts/cancels the + /// previous revision or readiness state before the replacement can load. + void adoptSource(RetroArtworkSource source) { + if (_disposed) return; + final previous = _sources[source.logicalKey]; + if (previous == source) return; + + _sources[source.logicalKey] = source; + if (previous != null) { + _cancelSource(previous, 'Artwork descriptor was replaced'); + final replacement = Future.sync(() async { + await _cache.remove(previous.cacheKey); + }); + _sourceReplacements[source.logicalKey] = replacement; + unawaited( + replacement.then((_) { + if (identical(_sourceReplacements[source.logicalKey], replacement)) { + _sourceReplacements.remove(source.logicalKey); + } + }, onError: (Object _, StackTrace _) {}), + ); + } + } + + Future load(RetroArtworkSource source) async { + if (_disposed || !_activityGate.isOpen) { + return const RetroArtworkLoadResult.suppressed(); + } + + final currentSource = _sources[source.logicalKey]; + // A null currentSource is the ordinary first load for this logical key -- the system grid + // reaches here without a prior adoptSource() -- and auto-adopting is correct: there is no + // previous revision whose transfer needs cancelling or whose cache entry needs evicting. + // The case that genuinely needs that bookkeeping is a DIFFERENT source already registered + // for the key, and the else-if below is what handles it. + if (currentSource == null) { + adoptSource(source); + } else if (currentSource != source) { + return const RetroArtworkLoadResult.staleSource(); + } + if (_sources[source.logicalKey] != source) { + return const RetroArtworkLoadResult.staleSource(); + } + final replacement = _sourceReplacements[source.logicalKey]; + if (replacement != null) { + try { + await replacement; + } catch (error) { + return RetroArtworkLoadResult.failed(error); + } + } + if (!_activityGate.isOpen) { + return const RetroArtworkLoadResult.suppressed(); + } + + Uint8List? cached; + try { + cached = await _cache.get(source.cacheKey); + } catch (error) { + return RetroArtworkLoadResult.failed(error); + } + if (_sources[source.logicalKey] != source) { + return const RetroArtworkLoadResult.staleSource(); + } + if (!_activityGate.isOpen) { + return const RetroArtworkLoadResult.suppressed(); + } + if (cached != null) { + return RetroArtworkLoadResult.cacheHit(cached); + } + + final existing = _transfers[source]; + if (existing != null) return existing.completer.future; + + final permit = _activityGate.tryAcquire(RetroArtworkActivityKind.transfer); + if (permit == null) { + return const RetroArtworkLoadResult.suppressed(); + } + + final transfer = _PendingTransfer(source, permit); + void onCancelled() { + if (!transfer.started) { + _complete(transfer, const RetroArtworkLoadResult.cancelled()); + } + } + + transfer.onCancelled = onCancelled; + permit.signal.addListener(onCancelled); + _transfers[source] = transfer; + _queue.addLast(transfer); + _drain(); + return transfer.completer.future; + } + + void cancelAll() { + for (final transfer in _transfers.values.toList(growable: false)) { + transfer.permit.cancel('All artwork requests were cancelled'); + } + } + + void dispose() { + if (_disposed) return; + _disposed = true; + cancelAll(); + _sources.clear(); + _sourceReplacements.clear(); + _httpClient.close(); + } + + void _cancelSource(RetroArtworkSource source, Object reason) { + final transfer = _transfers[source]; + transfer?.permit.cancel(reason); + } + + void _drain() { + if (_disposed) return; + while (_activeTransfers < maxConcurrentTransfers && _queue.isNotEmpty) { + // Newest first. A request is enqueued when a card is built and cancelled + // when it is disposed, so the newest waiting transfer is the one most + // likely to still be on screen; after a fling the stale entries ahead of + // it would otherwise be served first. They are dropped by the + // cancellation check below rather than fetched, so this only reorders + // work -- nothing in flight is ever discarded to make room. + final transfer = _queue.removeLast(); + if (transfer.completer.isCompleted) continue; + if (transfer.permit.signal.isCancelled || !_activityGate.isOpen) { + _complete(transfer, const RetroArtworkLoadResult.cancelled()); + continue; + } + unawaited(_run(transfer)); + } + } + + Future _run(_PendingTransfer transfer) async { + transfer.started = true; + _activeTransfers++; + RetroArtworkLoadResult result; + try { + final bytes = await _httpClient.getBytes( + transfer.source.uri, + cancellation: transfer.permit.signal, + ); + if (transfer.permit.signal.isCancelled || + !_activityGate.isOpen || + _sources[transfer.source.logicalKey] != transfer.source) { + result = const RetroArtworkLoadResult.cancelled(); + } else { + await _cache.put(transfer.source.cacheKey, bytes); + result = RetroArtworkLoadResult.downloaded(bytes); + } + } on RetroArtworkCancelledException { + result = const RetroArtworkLoadResult.cancelled(); + } catch (error) { + result = transfer.permit.signal.isCancelled + ? const RetroArtworkLoadResult.cancelled() + : RetroArtworkLoadResult.failed(error); + } finally { + _activeTransfers--; + } + _complete(transfer, result); + _drain(); + } + + void _complete(_PendingTransfer transfer, RetroArtworkLoadResult result) { + if (transfer.completer.isCompleted) return; + _queue.remove(transfer); + _transfers.remove(transfer.source); + final onCancelled = transfer.onCancelled; + if (onCancelled != null) { + transfer.permit.signal.removeListener(onCancelled); + } + transfer.permit.dispose(); + transfer.completer.complete(result); + } +} + +class _PendingTransfer { + _PendingTransfer(this.source, this.permit); + + final RetroArtworkSource source; + final RetroArtworkActivityPermit permit; + final Completer completer = + Completer(); + bool started = false; + void Function()? onCancelled; +} diff --git a/lib/data/viewmodels/game_system_browse_view_model.dart b/lib/data/viewmodels/game_system_browse_view_model.dart index 1888f2a0a..958c5ca36 100644 --- a/lib/data/viewmodels/game_system_browse_view_model.dart +++ b/lib/data/viewmodels/game_system_browse_view_model.dart @@ -75,7 +75,9 @@ class GameSystemBrowseViewModel extends ChangeNotifier { final systemNameFuture = _providedSystemName?.isNotEmpty == true ? null : _resolveSystemName(api); - final loadedGames = await api.getGames(libraryId, system: systemId); + final loadedGames = List.of( + await api.getGames(libraryId, system: systemId), + ); final loadedDisplayTitles = { for (final game in loadedGames) game.id: gameDisplayTitle(game.title, game.fileName), diff --git a/lib/di/modules/app_module.dart b/lib/di/modules/app_module.dart index 4e20120b2..47ccc1c5f 100644 --- a/lib/di/modules/app_module.dart +++ b/lib/di/modules/app_module.dart @@ -29,6 +29,9 @@ import '../../data/services/cast/google_cast_provider.dart'; import '../../data/services/cast/native_cast_channel.dart'; import '../../data/services/cast/remote_session_cast_provider.dart'; import '../../data/services/plugin_sync_service.dart'; +import '../../data/services/retro_artwork/retro_artwork_activity_gate.dart'; +import '../../data/services/retro_artwork/retro_artwork_cache.dart'; +import '../../data/services/retro_artwork/retro_artwork_disk_cache.dart'; import '../../data/services/custom_external_lists_service.dart'; import '../../data/services/row_data_source.dart'; import '../../data/services/socket_handler.dart'; @@ -71,6 +74,11 @@ void resetUserScopedSingletons() { } void registerAppModule() { + _getIt.registerLazySingleton(() => RetroArtworkActivityGate()); + _getIt.registerLazySingletonAsync( + () => openDefaultRetroArtworkDiskCache(), + dispose: (cache) => cache.dispose(), + ); _getIt.registerLazySingleton(() => SocketHandler()); _getIt.registerLazySingleton(() => CustomExternalListsService()); _getIt.registerLazySingleton( @@ -82,7 +90,10 @@ void registerAppModule() { AppUpdateService(_getIt(), _getIt()), ); _getIt.registerLazySingleton( - () => ScreensaverController(_getIt(), _getIt()), + () => ScreensaverController( + _getIt(), + _getIt(), + ), dispose: (controller) => controller.dispose(), ); _getIt.registerLazySingleton(() => const NativeCastChannel()); @@ -156,7 +167,10 @@ void _registerUserScopedSingletons() { () => RowDataSource(_getIt()), ); _getIt.registerLazySingleton( - () => MdbListRepository(_getIt(), _getIt()), + () => MdbListRepository( + _getIt(), + _getIt(), + ), dispose: (repository) => repository.dispose(), ); _getIt.registerLazySingleton( diff --git a/lib/main.dart b/lib/main.dart index f7279833d..ae9e13339 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -342,6 +342,7 @@ Future _detectAndApplyAudioCapabilities(UserPreferences prefs) async { void _sweepImageCache(UserPreferences prefs, {bool throttle = false}) { final mb = prefs.get(UserPreferences.imageCacheLimitMb); unawaited(enforceImageCacheBudget(mb * 1024 * 1024, throttle: throttle)); + unawaited(enforceGameArtworkCacheBudget(throttle: throttle)); } class _ImageCacheSweepObserver with WidgetsBindingObserver { diff --git a/lib/playback/native_game_player.dart b/lib/playback/native_game_player.dart index acbbcb360..5143bb58a 100644 --- a/lib/playback/native_game_player.dart +++ b/lib/playback/native_game_player.dart @@ -146,8 +146,14 @@ class MethodChannelGamePlayer implements NativeGamePlayer { Future pause() => _invoke('pause'); @override Future resume() => _invoke('resume'); + // Unlike the other lifecycle calls below, this one is allowed to throw: the + // native side genuinely raises a `restart_unavailable` PlatformException + // for cores that can't restart, and the UI (native_game_player_screen's + // _restart) has a catch clause specifically for that code. Swallowing it + // here the way _invoke does for fire-and-forget calls would make that + // branch permanently unreachable. @override - Future restart() => _invoke('restart'); + Future restart() => _control.invokeMethod('restart'); @override Future stop() => _invoke('stop'); diff --git a/lib/ui/navigation/app_router.dart b/lib/ui/navigation/app_router.dart index a77e901bc..1ad454d37 100644 --- a/lib/ui/navigation/app_router.dart +++ b/lib/ui/navigation/app_router.dart @@ -6,6 +6,7 @@ import 'package:playback_core/playback_core.dart'; import '../../auth/repositories/session_repository.dart'; import '../../auth/repositories/user_repository.dart'; import '../../data/services/connectivity_service.dart'; +import '../../data/services/retro_artwork/retro_artwork_activity_gate.dart'; import '../../di/injection.dart'; import '../../playback/external_player_policy.dart'; import '../../preference/user_preferences.dart'; @@ -169,6 +170,7 @@ final appRouter = GoRouter( initialLocation: Destinations.startup, observers: [ FocusRouteObserver(), + RetroArtworkRouteObserver.instance, routeLifecycleObserver, PlayerRouteObserver.instance, ], @@ -442,13 +444,15 @@ final appRouter = GoRouter( final gameId = state.pathParameters['gameId']!; final core = state.uri.queryParameters['core'] ?? 'nes'; final startFresh = state.uri.queryParameters['fresh'] == '1'; + final forceEmulatorJs = + state.uri.queryParameters['backend'] == 'emulatorjs'; return _opaqueFullScreenPage( state: state, // Native libretro or the EmulatorJS WebView: forced where only one // backend works (tvOS and Linux native), the user's choice elsewhere, // and per game where native can't play the system but the WebView can // (a PSP or N64 title on the bundled Apple targets). - child: usesNativeGameBackendFor(core) + child: !forceEmulatorJs && usesNativeGameBackendFor(core) ? NativeGamePlayerScreen( libraryId: libraryId, gameId: gameId, @@ -460,6 +464,7 @@ final appRouter = GoRouter( libraryId: libraryId, gameId: gameId, core: core, + romFileName: state.uri.queryParameters['romFile'], biosId: state.uri.queryParameters['bios'], gameName: state.uri.queryParameters['name'], startFresh: startFresh, @@ -524,10 +529,7 @@ final appRouter = GoRouter( channelId: state.uri.queryParameters['channelId'] ?? '', ); } - return _opaqueFullScreenPage( - state: state, - child: child, - ); + return _opaqueFullScreenPage(state: state, child: child); }, ), ], @@ -804,7 +806,6 @@ final appRouter = GoRouter( return SeerrPersonScreen(personId: personId); }, ), - ], ); @@ -858,3 +859,83 @@ class PlayerRouteObserver extends NavigatorObserver { isPlayerActive.value = _playerRoutes.isNotEmpty; } } + +/// Drives the one global artwork coverage blocker from the effective top route. +/// Hidden game screens never clear the blocker themselves, so popping or +/// disposing a lower screen cannot reopen artwork work under gameplay/dialogs. +class RetroArtworkRouteObserver extends NavigatorObserver { + RetroArtworkRouteObserver({RetroArtworkActivityGate? activityGate}) + : _activityGate = activityGate; + + static final instance = RetroArtworkRouteObserver(); + + final RetroArtworkActivityGate? _activityGate; + final List> _routes = >[]; + + RetroArtworkActivityGate? get _gate { + if (_activityGate != null) return _activityGate; + if (!GetIt.instance.isRegistered()) return null; + return GetIt.instance(); + } + + bool _isArtworkRoute(Route? route) { + final name = route?.settings.name; + return name != null && + (name.startsWith('/games/') || name.startsWith('/game/')); + } + + /// Non-opaque routes (dialogs, popups, bottom sheets -- e.g. the "Choose + /// player" picker) sit on top of the screen beneath without hiding it, so + /// they must not flip coverage themselves. Only an opaque route (a real + /// page navigation, including into gameplay) actually covers the artwork + /// screen. + bool _isOpaque(Route route) => + route is! ModalRoute || route.opaque; + + void _sync() { + Route? topmostOpaque; + for (final route in _routes.reversed) { + if (_isOpaque(route)) { + topmostOpaque = route; + break; + } + } + _gate?.setRouteCovered( + topmostOpaque == null || !_isArtworkRoute(topmostOpaque), + owner: this, + ); + } + + @override + void didPush(Route route, Route? previousRoute) { + _routes.add(route); + _sync(); + } + + @override + void didPop(Route route, Route? previousRoute) { + _routes.remove(route); + _sync(); + } + + @override + void didRemove(Route route, Route? previousRoute) { + _routes.remove(route); + _sync(); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + final oldIndex = oldRoute == null ? -1 : _routes.indexOf(oldRoute); + if (oldIndex >= 0) { + if (newRoute == null) { + _routes.removeAt(oldIndex); + } else { + _routes[oldIndex] = newRoute; + } + } else if (newRoute != null) { + _routes.add(newRoute); + } + _sync(); + } +} diff --git a/lib/ui/navigation/destinations.dart b/lib/ui/navigation/destinations.dart index a9cfc20b7..eec5db057 100644 --- a/lib/ui/navigation/destinations.dart +++ b/lib/ui/navigation/destinations.dart @@ -211,19 +211,24 @@ class Destinations { String libraryId, String gameId, { required String core, + String? romFileName, String? biosId, String? name, bool startFresh = false, + bool forceEmulatorJs = false, }) { final base = '/game-player/${Uri.encodeComponent(libraryId)}/${Uri.encodeComponent(gameId)}'; final params = [ 'core=${Uri.encodeQueryComponent(core)}', + if (romFileName != null && romFileName.isNotEmpty) + 'romFile=${Uri.encodeQueryComponent(romFileName)}', if (biosId != null && biosId.isNotEmpty) 'bios=${Uri.encodeQueryComponent(biosId)}', if (name != null && name.isNotEmpty) 'name=${Uri.encodeQueryComponent(name)}', if (startFresh) 'fresh=1', + if (forceEmulatorJs) 'backend=emulatorjs', ]; return '$base?${params.join('&')}'; } @@ -250,6 +255,7 @@ class Destinations { ? '$base?serverId=${Uri.encodeComponent(serverId)}' : base; } + static String collection(String collectionId) => '/collection/$collectionId'; static String musicLibrary(String libraryId) => '/music/$libraryId'; static String bookLibrary(String libraryId, {String? collectionType}) { @@ -258,6 +264,7 @@ class Destinations { ? '$base?collectionType=${Uri.encodeComponent(collectionType)}' : base; } + static String photo(String itemId) => '/player/photo/$itemId'; static String trailer({String? videoId, String? url}) { final params = { @@ -292,6 +299,7 @@ class Destinations { if (isFolderType(type)) return folder(itemId, serverId: serverId); return item(itemId, serverId: serverId); } + static String nextUpFor(String itemId) => '/player/next-up/$itemId'; static String stillWatchingFor(String itemId) => '/player/still-watching/$itemId'; @@ -319,6 +327,7 @@ class Destinations { if (params.isEmpty) return base; return Uri(path: base, queryParameters: params).toString(); } + static String seerrPerson(String personId) => '/seerr/person/$personId'; static String seerrCollection(String collectionId) => '/seerr/collection/$collectionId'; diff --git a/lib/ui/screens/games/game_detail_screen.dart b/lib/ui/screens/games/game_detail_screen.dart index c340a3035..7e1d242c5 100644 --- a/lib/ui/screens/games/game_detail_screen.dart +++ b/lib/ui/screens/games/game_detail_screen.dart @@ -1,20 +1,36 @@ +import 'dart:async'; import 'dart:ui'; -import 'package:cached_network_image/cached_network_image.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart' + show HttpExceptionWithStatus; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:moonfin_design/moonfin_design.dart' show GlassSettings; import 'package:server_core/server_core.dart'; import '../../navigation/destinations.dart'; +import '../../navigation/route_lifecycle_observer.dart'; import '../../../l10n/app_localizations.dart'; import '../../widgets/adaptive/adaptive_glass.dart'; +import '../../widgets/bounded_network_image.dart'; +import '../../widgets/focus/focus_theme.dart'; import '../../widgets/focus/focusable_button.dart'; import '../../widgets/game/game_poster_rail.dart'; +import '../../widgets/game/retro_artwork_image.dart'; +import '../../../data/services/background_service.dart'; +import '../../../data/services/retro_artwork/retro_artwork_activity_gate.dart'; +import '../../../data/services/retro_artwork/retro_artwork_cache.dart'; +import '../../../data/services/retro_artwork/retro_artwork_data_source.dart'; +import '../../../data/services/retro_artwork/retro_artwork_transport.dart'; +import '../../../util/game_artwork_cache.dart'; import '../../../util/game_cores.dart'; import '../../../util/game_library.dart'; import '../../../util/platform_detection.dart'; +import '../../../util/tv_image_cache_stub.dart' + if (dart.library.io) '../../../util/tv_image_cache_io.dart'; /// Premium, responsive game detail screen. Builds a cinematic hero from keyless libretro /// thumbnails (in-game snapshot backdrop + box-art poster), shows the data the games API @@ -25,23 +41,53 @@ class GameDetailScreen extends StatefulWidget { super.key, required this.libraryId, required this.gameId, + @visibleForTesting this.debugArtworkDataSource, + @visibleForTesting this.debugArtworkTransport, + @visibleForTesting this.debugArtworkActivityGate, }); final String libraryId; final String gameId; + /// Test-only seam. When supplied, [_initializeArtworkDataSource] wires + /// these directly instead of resolving the real capability probe and + /// factory chain, so widget tests can drive artwork updates with fully + /// controllable fakes. + @visibleForTesting + final RetroArtworkDataSource? debugArtworkDataSource; + @visibleForTesting + final RetroArtworkTransport? debugArtworkTransport; + @visibleForTesting + final RetroArtworkActivityGate? debugArtworkActivityGate; + @override State createState() => _GameDetailScreenState(); } -class _GameDetailScreenState extends State { +class _GameDetailScreenState extends State with RouteAware { final MediaServerClient _client = GetIt.instance(); + final FocusNode _primaryActionFocusNode = FocusNode( + debugLabel: 'gameDetailPrimaryAction', + ); bool _loading = true; String? _error; GameDetail? _game; bool _hasSave = false; List _related = const []; + String? _artworkScope; + RetroArtworkActivityGate? _retroArtworkActivityGate; + RetroArtworkTransport? _retroArtworkTransport; + RetroArtworkDataSource? _retroArtworkDataSource; + ModalRoute? _observedRoute; + bool _routeIsCovered = false; + bool _artworkRefreshQueued = false; + + /// Number of in-flight `showDialog` calls from this screen. Dialogs push a + /// route too, which fires [didPushNext] the same as navigating away to the + /// gameplay screen — without this guard, opening "Choose player" wipes the + /// backdrop artwork even though the screen is still fully visible under it. + int _ownDialogDepth = 0; @override void initState() { @@ -49,6 +95,153 @@ class _GameDetailScreenState extends State { _load(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route == null || route == _observedRoute) return; + if (_observedRoute != null) { + routeLifecycleObserver.unsubscribe(this); + } + _observedRoute = route; + routeLifecycleObserver.subscribe(this, route); + } + + @override + void didPushNext() { + super.didPushNext(); + if (_ownDialogDepth > 0) return; + _routeIsCovered = true; + // This screen stays mounted under gameplay, so dispose does not run. + // The root observer owns the shared activity gate; this page only cancels + // work created by its own transport. + _retroArtworkDataSource?.onRouteCovered(); + _retroArtworkTransport?.cancelAll(); + } + + @override + void didPopNext() { + super.didPopNext(); + _routeIsCovered = false; + _retroArtworkDataSource?.onRouteReentered(); + if (mounted) setState(() {}); + } + + @override + void dispose() { + if (_observedRoute != null) { + routeLifecycleObserver.unsubscribe(this); + _observedRoute = null; + } + _retroArtworkDataSource?.removeSnapshotListener(_onArtworkChanged); + _retroArtworkDataSource?.dispose(); + _retroArtworkTransport?.dispose(); + if (_artworkScope case final scope?) { + releaseGameArtworkCacheScope(scope); + } + _primaryActionFocusNode.dispose(); + super.dispose(); + } + + void _onArtworkChanged() { + if (!mounted || _artworkRefreshQueued) return; + _artworkRefreshQueued = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _artworkRefreshQueued = false; + if (mounted) setState(() {}); + }); + // addPostFrameCallback only fires once a frame is already scheduled. If + // artwork completes while the app is otherwise idle there isn't one, so + // ask for it explicitly or the callback above queues forever. + WidgetsBinding.instance.scheduleFrame(); + } + + Future _initializeArtworkDataSource(GameDetail game) async { + final games = _client.gamesApi; + if (games == null || _retroArtworkDataSource != null) return; + + final debugDataSource = widget.debugArtworkDataSource; + if (debugDataSource != null) { + debugDataSource.addSnapshotListener(_onArtworkChanged); + _retroArtworkActivityGate = widget.debugArtworkActivityGate; + _retroArtworkTransport = widget.debugArtworkTransport; + _retroArtworkDataSource = debugDataSource; + if (_routeIsCovered) debugDataSource.onRouteCovered(); + await debugDataSource.refreshSystem( + libraryId: widget.libraryId, + systemId: game.system, + ); + if (mounted) setState(() {}); + return; + } + + // Tests and minimal app embeddings may not register the protocol-2 + // transport foundations. The legacy adapter still preserves established + // Thumb URLs and screen-lifetime missing suppression in that case. + if (!GetIt.instance.isRegistered() || + !GetIt.instance.isRegistered()) { + final dataSource = LegacyArtworkAdapter(gamesApi: games); + dataSource.addSnapshotListener(_onArtworkChanged); + _retroArtworkDataSource = dataSource; + if (_routeIsCovered) dataSource.onRouteCovered(); + await dataSource.refreshSystem( + libraryId: widget.libraryId, + systemId: game.system, + ); + if (mounted) setState(() {}); + return; + } + + final cache = await GetIt.instance.getAsync(); + if (!mounted || _retroArtworkDataSource != null) return; + final gate = GetIt.instance(); + final transport = RetroArtworkTransport.forServer( + client: _client, + cache: cache, + activityGate: gate, + ); + final dataSource = await RetroArtworkDataSourceFactory.create( + client: _client, + activityGate: gate, + transport: transport, + ); + if (!mounted) { + dataSource?.dispose(); + transport.dispose(); + return; + } + if (dataSource == null) { + transport.dispose(); + return; + } + dataSource.addSnapshotListener(_onArtworkChanged); + _retroArtworkActivityGate = gate; + _retroArtworkTransport = transport; + _retroArtworkDataSource = dataSource; + if (_routeIsCovered) dataSource.onRouteCovered(); + try { + // Detail owns exactly the current game's system snapshot. It never + // expands this request to the library or related-game systems. + await dataSource.refreshSystem( + libraryId: widget.libraryId, + systemId: game.system, + ); + } catch (_) { + // Existing detail metadata remains usable when a manifest refresh fails. + } finally { + if (mounted) setState(() {}); + } + } + + KeyEventResult _handleAppBarKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.arrowDown) { + _primaryActionFocusNode.requestFocus(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + Future _load() async { final games = _client.gamesApi; if (games == null) { @@ -70,11 +263,16 @@ class _GameDetailScreenState extends State { return; } + final artworkScope = gameArtworkScope(widget.libraryId, game.system); + _artworkScope = artworkScope; + unawaited(retainGameArtworkCacheScope(artworkScope)); setState(() { _game = game; _loading = false; }); + unawaited(_initializeArtworkDataSource(game)); + // Save state and related games are enrichments; a failure here must not block the // screen that already has its core data. _loadSave(games, game); @@ -90,7 +288,12 @@ class _GameDetailScreenState extends State { Future _loadSave(GamesApi games, GameDetail game) async { try { - final save = await games.getSave(gameStateKey(game.id, game.core)); + final save = await loadGameStateWithMigration( + games, + game.id, + game.core, + forceEmulatorJs: _usesEmulatorJsOverride(game), + ); if (!mounted) return; setState(() => _hasSave = save != null && save.isNotEmpty); } catch (_) {} @@ -127,13 +330,72 @@ class _GameDetailScreenState extends State { widget.libraryId, game.id, core: game.core, + romFileName: game.fileName, biosId: biosId, name: game.title, startFresh: fresh, + forceEmulatorJs: _usesEmulatorJsOverride(game), ), ); } + Future _selectCore() async { + final game = _game; + if (game == null || !_canSelectCore(game)) return; + + _ownDialogDepth++; + final _CoreSelection? selection; + try { + selection = await showDialog<_CoreSelection>( + context: context, + builder: (context) => _CorePickerDialog(game: game), + ); + } finally { + _ownDialogDepth--; + } + if (selection == null) return; + + final games = _client.gamesApi; + if (games == null) return; + var updated = game; + var didUpdate = false; + void applyUpdate(GameDetail detail) { + updated = detail; + didUpdate = true; + if (!mounted) return; + setState(() { + _game = detail; + _hasSave = false; + }); + } + + try { + if (selection.setBackend) { + final response = await games.setGameBackendOverride( + widget.libraryId, + game.id, + backend: selection.backend, + ); + if (response != null) applyUpdate(response); + } + if (selection.setCore) { + final response = await games.setGameCoreOverride( + widget.libraryId, + game.id, + core: selection.core, + ); + if (response != null) applyUpdate(response); + } + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Could not change player: $e'))); + } finally { + if (didUpdate && mounted) _loadSave(games, updated); + } + } + void _openGame(GameSummary game) { context.push(Destinations.gameDetailOf(widget.libraryId, game.id)); } @@ -146,6 +408,12 @@ class _GameDetailScreenState extends State { backgroundColor: Colors.transparent, elevation: 0, foregroundColor: Colors.white, + leading: Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: _handleAppBarKeyEvent, + child: const BackButton(), + ), ), body: _buildBody(), ); @@ -165,28 +433,51 @@ class _GameDetailScreenState extends State { } final game = _game!; + final artworkScope = _artworkScope!; return LayoutBuilder( builder: (context, constraints) { final tv = PlatformDetection.isTV; final landscape = tv || constraints.maxWidth >= 720; return landscape ? _LandscapeBody( - libraryId: widget.libraryId, game: game, + artworkScope: artworkScope, + artworkDataSource: _routeIsCovered + ? null + : _retroArtworkDataSource, + retroArtworkTransport: _routeIsCovered + ? null + : _retroArtworkTransport, + retroArtworkActivityGate: _routeIsCovered + ? null + : _retroArtworkActivityGate, hasSave: _hasSave, related: _related, tv: tv, + primaryActionFocusNode: _primaryActionFocusNode, onPlay: () => _play(fresh: false), onRestart: () => _play(fresh: true), + onSelectCore: _selectCore, onOpenGame: _openGame, ) : _PortraitBody( - libraryId: widget.libraryId, game: game, + artworkScope: artworkScope, + artworkDataSource: _routeIsCovered + ? null + : _retroArtworkDataSource, + retroArtworkTransport: _routeIsCovered + ? null + : _retroArtworkTransport, + retroArtworkActivityGate: _routeIsCovered + ? null + : _retroArtworkActivityGate, hasSave: _hasSave, related: _related, + primaryActionFocusNode: _primaryActionFocusNode, onPlay: () => _play(fresh: false), onRestart: () => _play(fresh: true), + onSelectCore: _selectCore, onOpenGame: _openGame, ); }, @@ -196,21 +487,31 @@ class _GameDetailScreenState extends State { class _PortraitBody extends StatelessWidget { const _PortraitBody({ - required this.libraryId, required this.game, + required this.artworkScope, + required this.artworkDataSource, + required this.retroArtworkTransport, + required this.retroArtworkActivityGate, required this.hasSave, required this.related, + required this.primaryActionFocusNode, required this.onPlay, required this.onRestart, + required this.onSelectCore, required this.onOpenGame, }); - final String libraryId; final GameDetail game; + final String artworkScope; + final RetroArtworkDataSource? artworkDataSource; + final RetroArtworkTransport? retroArtworkTransport; + final RetroArtworkActivityGate? retroArtworkActivityGate; final bool hasSave; final List related; + final FocusNode primaryActionFocusNode; final VoidCallback onPlay; final VoidCallback onRestart; + final VoidCallback onSelectCore; final ValueChanged onOpenGame; @override @@ -224,13 +525,27 @@ class _PortraitBody extends StatelessWidget { child: Stack( fit: StackFit.expand, children: [ - _Backdrop(libraryId: libraryId, game: game, landscape: false), + _Backdrop( + game: game, + artworkScope: artworkScope, + artworkDataSource: artworkDataSource, + retroArtworkTransport: retroArtworkTransport, + retroArtworkActivityGate: retroArtworkActivityGate, + landscape: false, + ), Padding( padding: const EdgeInsets.fromLTRB(18, 0, 18, 16), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - _Poster(libraryId: libraryId, game: game, width: 104), + _Poster( + game: game, + artworkScope: artworkScope, + artworkDataSource: artworkDataSource, + retroArtworkTransport: retroArtworkTransport, + retroArtworkActivityGate: retroArtworkActivityGate, + width: 104, + ), const SizedBox(width: 14), Expanded( child: Padding( @@ -251,11 +566,14 @@ class _PortraitBody extends StatelessWidget { Padding( padding: const EdgeInsets.fromLTRB(18, 16, 18, 0), child: _ActionRow( + game: game, hasSave: hasSave, tv: false, fullWidthPrimary: true, + primaryFocusNode: primaryActionFocusNode, onPlay: onPlay, onRestart: onRestart, + onSelectCore: onSelectCore, ), ), if (game.overview != null && game.overview!.isNotEmpty) @@ -265,15 +583,22 @@ class _PortraitBody extends StatelessWidget { ), Padding( padding: const EdgeInsets.fromLTRB(18, 18, 18, 0), - child: _DetailsPanel(game: game, hasSave: hasSave), + child: _DetailsPanel( + game: game, + hasSave: hasSave, + onSelectCore: onSelectCore, + ), ), if (related.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 22, bottom: 24), child: GamePosterRail( title: 'More in ${game.system}', - libraryId: libraryId, games: related, + artworkScope: artworkScope, + artworkDataSource: artworkDataSource, + retroArtworkTransport: retroArtworkTransport, + retroArtworkActivityGate: retroArtworkActivityGate, onTapGame: onOpenGame, ), ) @@ -287,23 +612,33 @@ class _PortraitBody extends StatelessWidget { class _LandscapeBody extends StatelessWidget { const _LandscapeBody({ - required this.libraryId, required this.game, + required this.artworkScope, + required this.artworkDataSource, + required this.retroArtworkTransport, + required this.retroArtworkActivityGate, required this.hasSave, required this.related, required this.tv, + required this.primaryActionFocusNode, required this.onPlay, required this.onRestart, + required this.onSelectCore, required this.onOpenGame, }); - final String libraryId; final GameDetail game; + final String artworkScope; + final RetroArtworkDataSource? artworkDataSource; + final RetroArtworkTransport? retroArtworkTransport; + final RetroArtworkActivityGate? retroArtworkActivityGate; final bool hasSave; final List related; final bool tv; + final FocusNode primaryActionFocusNode; final VoidCallback onPlay; final VoidCallback onRestart; + final VoidCallback onSelectCore; final ValueChanged onOpenGame; @override @@ -311,7 +646,14 @@ class _LandscapeBody extends StatelessWidget { return Stack( fit: StackFit.expand, children: [ - _Backdrop(libraryId: libraryId, game: game, landscape: true), + _Backdrop( + game: game, + artworkScope: artworkScope, + artworkDataSource: artworkDataSource, + retroArtworkTransport: retroArtworkTransport, + retroArtworkActivityGate: retroArtworkActivityGate, + landscape: true, + ), SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(40, 24, 40, 32), @@ -321,7 +663,14 @@ class _LandscapeBody extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _Poster(libraryId: libraryId, game: game, width: tv ? 168 : 150), + _Poster( + game: game, + artworkScope: artworkScope, + artworkDataSource: artworkDataSource, + retroArtworkTransport: retroArtworkTransport, + retroArtworkActivityGate: retroArtworkActivityGate, + width: tv ? 168 : 150, + ), const SizedBox(width: 26), Expanded( child: ConstrainedBox( @@ -334,18 +683,25 @@ class _LandscapeBody extends StatelessWidget { _MetaPills(game: game, hasSave: hasSave), const SizedBox(height: 20), _ActionRow( + game: game, hasSave: hasSave, tv: tv, fullWidthPrimary: false, + primaryFocusNode: primaryActionFocusNode, onPlay: onPlay, onRestart: onRestart, + onSelectCore: onSelectCore, ), if (game.overview != null && game.overview!.isNotEmpty) ...[ const SizedBox(height: 22), _OverviewBlock(text: game.overview!), ], const SizedBox(height: 22), - _DetailsPanel(game: game, hasSave: hasSave), + _DetailsPanel( + game: game, + hasSave: hasSave, + onSelectCore: onSelectCore, + ), ], ), ), @@ -357,8 +713,11 @@ class _LandscapeBody extends StatelessWidget { padding: const EdgeInsets.only(top: 28), child: GamePosterRail( title: 'More in ${game.system}', - libraryId: libraryId, games: related, + artworkScope: artworkScope, + artworkDataSource: artworkDataSource, + retroArtworkTransport: retroArtworkTransport, + retroArtworkActivityGate: retroArtworkActivityGate, onTapGame: onOpenGame, ), ), @@ -375,13 +734,19 @@ class _LandscapeBody extends StatelessWidget { /// box art, and finally a seeded color gradient, with scrims for text legibility. class _Backdrop extends StatelessWidget { const _Backdrop({ - required this.libraryId, required this.game, + required this.artworkScope, + required this.artworkDataSource, + required this.retroArtworkTransport, + required this.retroArtworkActivityGate, required this.landscape, }); - final String libraryId; final GameDetail game; + final String artworkScope; + final RetroArtworkDataSource? artworkDataSource; + final RetroArtworkTransport? retroArtworkTransport; + final RetroArtworkActivityGate? retroArtworkActivityGate; final bool landscape; @override @@ -390,12 +755,9 @@ class _Backdrop extends StatelessWidget { final base = gameFallbackColor(game.id); final steps = <_BackdropStep>[ - if (gameThumbUrl(libraryId, game.id, kind: 'snap') case final u?) - _BackdropStep(u, blur: false), - if (gameThumbUrl(libraryId, game.id, kind: 'title') case final u?) - _BackdropStep(u, blur: false), - if (gameThumbUrl(libraryId, game.id) case final u?) - _BackdropStep(u, blur: true), + const _BackdropStep('snap', blur: false), + const _BackdropStep('title', blur: false), + const _BackdropStep('boxart', blur: true), ]; return Stack( @@ -410,7 +772,15 @@ class _Backdrop extends StatelessWidget { ), ), ), - _BackdropImage(steps: steps, index: 0), + _BackdropImage( + steps: steps, + gameId: game.id, + artworkScope: artworkScope, + artworkDataSource: artworkDataSource, + retroArtworkTransport: retroArtworkTransport, + retroArtworkActivityGate: retroArtworkActivityGate, + index: 0, + ), if (landscape) DecoratedBox( decoration: BoxDecoration( @@ -442,34 +812,91 @@ class _Backdrop extends StatelessWidget { } class _BackdropStep { - const _BackdropStep(this.url, {required this.blur}); - final String url; + const _BackdropStep(this.role, {required this.blur}); + final String role; final bool blur; } class _BackdropImage extends StatelessWidget { - const _BackdropImage({required this.steps, required this.index}); + const _BackdropImage({ + required this.steps, + required this.gameId, + required this.artworkScope, + required this.artworkDataSource, + required this.retroArtworkTransport, + required this.retroArtworkActivityGate, + required this.index, + }); final List<_BackdropStep> steps; + final String gameId; + final String artworkScope; + final RetroArtworkDataSource? artworkDataSource; + final RetroArtworkTransport? retroArtworkTransport; + final RetroArtworkActivityGate? retroArtworkActivityGate; final int index; @override Widget build(BuildContext context) { if (index >= steps.length) return const SizedBox.shrink(); final step = steps[index]; - final image = CachedNetworkImage( - imageUrl: step.url, - fit: BoxFit.cover, - alignment: Alignment.center, - fadeInDuration: const Duration(milliseconds: 250), - errorWidget: (context, url, error) => - _BackdropImage(steps: steps, index: index + 1), - ); + final reference = artworkDataSource?.imageFor(gameId, role: step.role); + if (reference == null) { + return _nextStep(); + } + final source = reference.source; + final transport = retroArtworkTransport; + final gate = retroArtworkActivityGate; + void onError(Object error) { + artworkDataSource?.reportImageFailure( + gameId, + role: step.role, + statusCode: _artworkStatusCode(error), + ); + } + + final image = source != null && transport != null && gate != null + ? RetroArtworkImage( + source: source, + transport: transport, + activityGate: gate, + // Full-bleed backdrop, so there is no painted width smaller than + // the window to size from. Upstream's backdrop budget rather than a + // literal, so game art and media art stay on one number. + maxDecodeWidth: BackgroundService.backdropMaxWidth, + fit: BoxFit.cover, + alignment: Alignment.center, + onLoadFinished: () => + artworkDataSource?.reportImageLoaded(gameId, role: step.role), + errorBuilder: (_, error) { + onError(error); + return _nextStep(); + }, + ) + : reference.legacyUrl == null + ? _nextStep() + : BoundedNetworkImage( + imageUrl: reference.legacyUrl!, + cacheManager: gameArtworkCacheManagerForScope(artworkScope), + fit: BoxFit.cover, + alignment: Alignment.center, + fadeInDuration: const Duration(milliseconds: 250), + maxWidth: 1920, + onLoadFinished: () => + artworkDataSource?.reportImageLoaded(gameId, role: step.role), + errorBuilder: (_, _, error) { + onError(error); + return _nextStep(); + }, + ); if (!step.blur) return image; final sigma = GlassSettings.capSigma(24); if (sigma <= 0) { return ColorFiltered( - colorFilter: const ColorFilter.mode(Color(0x8C000000), BlendMode.srcOver), + colorFilter: const ColorFilter.mode( + Color(0x8C000000), + BlendMode.srcOver, + ), child: image, ); } @@ -478,22 +905,82 @@ class _BackdropImage extends StatelessWidget { child: image, ); } + + Widget _nextStep() => _BackdropImage( + steps: steps, + gameId: gameId, + artworkScope: artworkScope, + artworkDataSource: artworkDataSource, + retroArtworkTransport: retroArtworkTransport, + retroArtworkActivityGate: retroArtworkActivityGate, + index: index + 1, + ); } class _Poster extends StatelessWidget { const _Poster({ - required this.libraryId, required this.game, + required this.artworkScope, + required this.artworkDataSource, + required this.retroArtworkTransport, + required this.retroArtworkActivityGate, required this.width, }); - final String libraryId; final GameDetail game; + final String artworkScope; + final RetroArtworkDataSource? artworkDataSource; + final RetroArtworkTransport? retroArtworkTransport; + final RetroArtworkActivityGate? retroArtworkActivityGate; final double width; @override Widget build(BuildContext context) { - final url = gameThumbUrl(libraryId, game.id); + final reference = artworkDataSource?.imageFor(game.id, role: 'boxart'); + final source = reference?.source; + final transport = retroArtworkTransport; + final gate = retroArtworkActivityGate; + final fallback = _ArtFallback(seed: game.id, iconSize: 40); + final artwork = source != null && transport != null && gate != null + ? RetroArtworkImage( + source: source, + transport: transport, + activityGate: gate, + // Sized from what is painted, like every other bounded image. + maxDecodeWidth: BoundedNetworkImage.cacheWidthFor( + width, + MediaQuery.devicePixelRatioOf(context), + ), + fit: BoxFit.cover, + onLoadFinished: () => + artworkDataSource?.reportImageLoaded(game.id, role: 'boxart'), + errorBuilder: (_, error) { + artworkDataSource?.reportImageFailure( + game.id, + role: 'boxart', + statusCode: _artworkStatusCode(error), + ); + return fallback; + }, + ) + : reference?.legacyUrl == null + ? fallback + : BoundedNetworkImage( + imageUrl: reference!.legacyUrl!, + cacheManager: gameArtworkCacheManagerForScope(artworkScope), + fit: BoxFit.cover, + maxWidth: 512, + onLoadFinished: () => + artworkDataSource?.reportImageLoaded(game.id, role: 'boxart'), + errorBuilder: (_, _, error) { + artworkDataSource?.reportImageFailure( + game.id, + role: 'boxart', + statusCode: _artworkStatusCode(error), + ); + return fallback; + }, + ); return ClipRRect( borderRadius: BorderRadius.circular(12), child: DecoratedBox( @@ -501,25 +988,25 @@ class _Poster extends StatelessWidget { borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.white24), boxShadow: const [ - BoxShadow(color: Colors.black54, blurRadius: 18, offset: Offset(0, 8)), + BoxShadow( + color: Colors.black54, + blurRadius: 18, + offset: Offset(0, 8), + ), ], ), - child: SizedBox( - width: width, - height: width * 1.34, - child: url == null - ? _ArtFallback(seed: game.id, iconSize: 40) - : CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - errorWidget: (_, _, _) => _ArtFallback(seed: game.id, iconSize: 40), - ), - ), + child: SizedBox(width: width, height: width * 1.34, child: artwork), ), ); } } +int? _artworkStatusCode(Object error) { + if (error is DioException) return error.response?.statusCode; + if (error is HttpExceptionWithStatus) return error.statusCode; + return null; +} + class _ArtFallback extends StatelessWidget { const _ArtFallback({required this.seed, required this.iconSize}); @@ -531,7 +1018,11 @@ class _ArtFallback extends StatelessWidget { return ColoredBox( color: gameFallbackColor(seed), child: Center( - child: Icon(Icons.videogame_asset, size: iconSize, color: Colors.white70), + child: Icon( + Icons.videogame_asset, + size: iconSize, + color: Colors.white70, + ), ), ); } @@ -595,16 +1086,62 @@ class _MetaPills extends StatelessWidget { @override Widget build(BuildContext context) { + final native = _usesNativeBackend(game); final pills = [ if (game.region != null && game.region!.isNotEmpty) _Pill(text: game.region!), if (game.sizeBytes > 0) _Pill(text: _formatBytes(game.sizeBytes)), if (hasSave) const _Pill(text: 'Save found', accent: true), if (game.bios.isNotEmpty) const _Pill(text: 'BIOS required'), + _BackendPill(native: native, core: game.core), ]; return Wrap(spacing: 7, runSpacing: 7, children: pills); } } +/// Reports the actual route for this game, including any per-system fallback +/// from a missing or unsupported native core to EmulatorJS. Purely +/// informational -- changing the core happens through the core button in +/// [_ActionRow] or the details panel, not by tapping this pill. +class _BackendPill extends StatelessWidget { + const _BackendPill({required this.native, required this.core}); + + final bool native; + final String core; + + @override + Widget build(BuildContext context) { + final color = native ? const Color(0xFFA9F0CC) : const Color(0xFFB7DBFF); + final background = native + ? const Color(0x267FE0B0) + : const Color(0x263D9BFF); + final border = native ? const Color(0x4D7FE0B0) : const Color(0x4D3D9BFF); + final label = native + ? 'Native core · ${_coreLabel(core)}' + : 'WebView · EmulatorJS'; + final pill = Container( + padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 4), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + native ? Icons.memory_rounded : Icons.web_asset_rounded, + color: color, + size: 14, + ), + const SizedBox(width: 5), + Text(label, style: TextStyle(color: color, fontSize: 12)), + ], + ), + ); + return Semantics(label: label, child: pill); + } +} + class _Pill extends StatelessWidget { const _Pill({required this.text, this.accent = false}); @@ -630,29 +1167,38 @@ class _Pill extends StatelessWidget { class _ActionRow extends StatelessWidget { const _ActionRow({ + required this.game, required this.hasSave, required this.tv, required this.fullWidthPrimary, + required this.primaryFocusNode, required this.onPlay, required this.onRestart, + required this.onSelectCore, }); + final GameDetail game; final bool hasSave; final bool tv; final bool fullWidthPrimary; + final FocusNode primaryFocusNode; final VoidCallback onPlay; final VoidCallback onRestart; + final VoidCallback onSelectCore; @override Widget build(BuildContext context) { final primaryLabel = hasSave ? 'Continue' : 'Play'; final accent = Theme.of(context).colorScheme.primary; + final native = _usesNativeBackend(game); + final coreFill = _coreButtonFillColor(native); if (tv) { return Row( mainAxisSize: MainAxisSize.min, children: [ FocusableButton( + focusNode: primaryFocusNode, autofocus: true, focusColor: accent, padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14), @@ -675,11 +1221,35 @@ class _ActionRow extends StatelessWidget { ), ), ], + if (_canSelectCore(game)) ...[ + const SizedBox(width: 12), + DecoratedBox( + decoration: BoxDecoration( + color: coreFill, + borderRadius: BorderRadius.circular( + FocusTheme.defaultBorderRadius, + ), + ), + child: FocusableButton( + padding: const EdgeInsets.symmetric( + horizontal: 22, + vertical: 14, + ), + onPressed: onSelectCore, + child: _ActionLabel( + icon: Icons.memory, + label: _coreLabel(game.core), + color: Colors.white, + ), + ), + ), + ], ], ); } final primary = FilledButton.icon( + focusNode: primaryFocusNode, onPressed: onPlay, icon: const Icon(Icons.play_arrow), label: Text(primaryLabel), @@ -689,12 +1259,22 @@ class _ActionRow extends StatelessWidget { icon: const Icon(Icons.refresh), label: const Text('Restart'), ); + final core = FilledButton.icon( + onPressed: onSelectCore, + style: FilledButton.styleFrom( + backgroundColor: coreFill, + foregroundColor: Colors.white, + ), + icon: const Icon(Icons.memory), + label: Text(_coreLabel(game.core)), + ); if (fullWidthPrimary) { return Row( children: [ Expanded(child: primary), if (hasSave) ...[const SizedBox(width: 10), restart], + if (_canSelectCore(game)) ...[const SizedBox(width: 10), core], ], ); } @@ -703,6 +1283,7 @@ class _ActionRow extends StatelessWidget { children: [ primary, if (hasSave) ...[const SizedBox(width: 10), restart], + if (_canSelectCore(game)) ...[const SizedBox(width: 10), core], ], ); } @@ -728,7 +1309,11 @@ class _ActionLabel extends StatelessWidget { const SizedBox(width: 8), Text( label, - style: TextStyle(color: color, fontSize: 16, fontWeight: FontWeight.w600), + style: TextStyle( + color: color, + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), ], ); @@ -758,7 +1343,11 @@ class _OverviewBlock extends StatelessWidget { ), Text( text, - style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4), + style: const TextStyle( + color: Colors.white70, + fontSize: 13, + height: 1.4, + ), ), ], ); @@ -766,10 +1355,15 @@ class _OverviewBlock extends StatelessWidget { } class _DetailsPanel extends StatelessWidget { - const _DetailsPanel({required this.game, required this.hasSave}); + const _DetailsPanel({ + required this.game, + required this.hasSave, + required this.onSelectCore, + }); final GameDetail game; final bool hasSave; + final VoidCallback onSelectCore; @override Widget build(BuildContext context) { @@ -786,11 +1380,16 @@ class _DetailsPanel extends StatelessWidget { if (game.region != null && game.region!.isNotEmpty) _DetailRow(label: 'Region', value: game.region!), if (game.rating != null) - _DetailRow(label: 'Rating', value: '${game.rating!.toStringAsFixed(1)} / 5'), + _DetailRow( + label: 'Rating', + value: '${game.rating!.toStringAsFixed(1)} / 5', + ), _DetailRow(label: 'File', value: game.fileName), if (game.sizeBytes > 0) _DetailRow(label: 'Size', value: _formatBytes(game.sizeBytes)), - _DetailRow(label: 'Core', value: game.core), + _canSelectCore(game) + ? _CoreDetailRow(game: game, onTap: onSelectCore) + : _DetailRow(label: 'Core', value: _coreLabel(game.core)), _DetailRow(label: 'Save state', value: hasSave ? 'Found' : 'None'), if (game.bios.isNotEmpty) _DetailRow( @@ -814,6 +1413,331 @@ class _DetailsPanel extends StatelessWidget { } } +class _CoreDetailRow extends StatelessWidget { + const _CoreDetailRow({required this.game, required this.onTap}); + + final GameDetail game; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final recommended = game.recommendedCore; + final subtitle = recommended == null || recommended == game.core + ? null + : 'Recommended: ${_coreLabel(recommended)}'; + return FocusableButton( + padding: EdgeInsets.zero, + borderRadius: 8, + semanticLabel: 'Core, ${_coreLabel(game.core)}', + onPressed: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + width: 96, + child: Text( + 'Core', + style: TextStyle(color: Colors.white60, fontSize: 13), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _coreLabel(game.core), + style: const TextStyle(color: Colors.white, fontSize: 13), + ), + if (subtitle != null) + Text( + subtitle, + style: const TextStyle( + color: Colors.white60, + fontSize: 12, + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right, color: Colors.white60, size: 18), + ], + ), + ), + ); + } +} + +class _CoreSelection { + const _CoreSelection({ + required this.core, + required this.backend, + required this.setBackend, + required this.setCore, + }); + + final String? core; + final String? backend; + final bool setBackend; + final bool setCore; +} + +/// One reachable (player, core) outcome shown as a row in the picker — pins +/// down both halves of what "Play" will actually do. +class _CoreRow { + const _CoreRow({ + required this.label, + required this.core, + required this.isEmulatorJs, + required this.recommended, + required this.setBackend, + required this.setCore, + this.detail, + }); + + final String label; + final String core; + final bool isEmulatorJs; + final bool recommended; + final bool setBackend; + final bool setCore; + final String? detail; + + _CoreSelection get selection => _CoreSelection( + core: setCore ? core : null, + backend: isEmulatorJs ? 'emulatorjs' : null, + setBackend: setBackend, + setCore: setCore, + ); +} + +/// Builds the full set of genuinely reachable (player, core) rows for [game]. +/// +/// Candidate cores are the union of the game's current core and every core +/// the server validated ([GameDetail.availableCores]), recommended core +/// first. A native row is only emitted when [nativeCoreReachable] (checked +/// independent of the user's current preference, so a row can always be +/// forced); an EmulatorJS row is emitted whenever the WebView backend exists +/// on this platform at all. +List<_CoreRow> _buildCoreRows(GameDetail game) { + final recommended = game.recommendedCore ?? game.core; + final candidates = []; + void addCandidate(String core) { + if (!candidates.contains(core)) candidates.add(core); + } + + addCandidate(recommended); + addCandidate(game.core); + for (final core in game.availableCores) { + addCandidate(core); + } + + // Arcade ROMs can run under FBNeo (native-only) or MAME (EmulatorJS-only), + // but the server's `availableCores` only lists what it validated the + // archive against — e.g. a MAME-only validation would otherwise hide the + // FBNeo option entirely. Force both in as candidates for any arcade-family + // game; reachability below still filters out what isn't actually + // installed/available. + if (isArcadeFamilyCore(game.core) || isArcadeFamilyCore(recommended)) { + addCandidate('arcade'); + addCandidate('mame'); + } + + // The "Recommended" tag goes on whichever player would actually run the + // recommended core: native if that's reachable, otherwise EmulatorJS. Only + // one row can carry it, since only one row exists per (player, core) pair. + final recommendOnNative = nativeCoreReachable(recommended); + final setBackend = game.supportsBackendOverrides; + final setCore = game.supportsCoreOverrides; + + // Both branches emit one row per candidate core; only the player, the + // reachability test, and which recommendation half they claim differ. + _CoreRow buildRow({ + required String core, + required bool isEmulatorJs, + required bool isRecommended, + }) { + return _CoreRow( + label: isEmulatorJs ? 'EmulatorJS (WebView)' : 'Native core', + core: core, + isEmulatorJs: isEmulatorJs, + recommended: isRecommended, + setBackend: isEmulatorJs ? true : setBackend, + setCore: setCore, + detail: isRecommended + ? game.coreCompatibilityReason + : _notValidatedWarning(game, core), + ); + } + + final rows = <_CoreRow>[]; + if (setBackend || setCore) { + for (final core in candidates) { + if (nativeCoreReachable(core)) { + rows.add( + buildRow( + core: core, + isEmulatorJs: false, + isRecommended: recommendOnNative && core == recommended, + ), + ); + } + } + } + // Offered whenever *either* override channel is usable: a legacy server + // that hasn't rolled out backendOverrideSupported (setBackend == false) + // can still reach EmulatorJS via a plain core override, and arcade + // families force setCore on regardless of backend-override support. + if (emulatorJsAvailable && (setBackend || setCore)) { + for (final core in candidates) { + rows.add( + buildRow( + core: core, + isEmulatorJs: true, + isRecommended: !recommendOnNative && core == recommended, + ), + ); + } + } + return rows; +} + +/// A warning for offering [core] when the server has compatibility data but +/// didn't validate this ROM against it. +String? _notValidatedWarning(GameDetail game, String core) { + if (game.availableCores.isEmpty || game.availableCores.contains(core)) { + return null; + } + return 'This archive is not validated for ${_coreLabel(core)} and may not launch correctly.'; +} + +class _CorePickerDialog extends StatelessWidget { + const _CorePickerDialog({required this.game}); + + final GameDetail game; + + @override + Widget build(BuildContext context) { + final effectiveNative = _usesNativeBackend(game); + final rows = _buildCoreRows(game); + return AlertDialog( + title: const Text('Choose player'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final row in rows) + _CorePickerOption( + label: row.label, + subtitle: row.recommended + ? '${_coreLabel(row.core)} (Recommended)' + : _coreLabel(row.core), + detail: row.detail, + selected: + row.isEmulatorJs == !effectiveNative && + row.core == game.core, + onTap: () => Navigator.pop(context, row.selection), + ), + ], + ), + ), + ), + ); + } +} + +class _CorePickerOption extends StatelessWidget { + const _CorePickerOption({ + required this.label, + required this.selected, + required this.onTap, + this.subtitle, + this.detail, + }); + + final String label; + final String? subtitle; + + /// Second, optional line below [subtitle] — used for the server's + /// `coreCompatibilityReason` explanation, kept visually distinct (smaller, + /// dimmer, italic) so it reads as supplementary detail rather than a label. + final String? detail; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return FocusableButton( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + borderRadius: 8, + semanticLabel: label, + onPressed: onTap, + child: Row( + children: [ + Icon( + selected ? Icons.radio_button_checked : Icons.radio_button_off, + color: selected + ? Theme.of(context).colorScheme.primary + : Colors.white70, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(color: Colors.white)), + if (subtitle != null) + Text( + subtitle!, + style: const TextStyle(color: Colors.white60, fontSize: 12), + ), + if (detail != null && detail!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + detail!, + style: const TextStyle( + color: Colors.white38, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +String _coreLabel(String core) => switch (core) { + 'arcade' => 'FBNeo', + 'mame' => 'MAME', + _ => core, +}; + +bool _usesEmulatorJsOverride(GameDetail game) => + game.userBackendOverride == 'emulatorjs'; + +bool _usesNativeBackend(GameDetail game) => + !_usesEmulatorJsOverride(game) && usesNativeGameBackendFor(game.core); + +/// The core-picker button's fill: same green/blue hues [_BackendPill] uses +/// for native/WebView, so the two stay visually paired, but solid rather than +/// tinted so the button reads as clickable the way [FilledButton] does. +Color _coreButtonFillColor(bool native) => + native ? const Color(0xFF2E9E6B) : const Color(0xFF2E74E0); + +/// Hidden when there is only one supported per-game player/core outcome. A +/// backend choice and an arcade-core choice are separate capabilities: normal +/// console games only write the former, while arcade games may write both. +bool _canSelectCore(GameDetail game) => _buildCoreRows(game).length > 1; + class _DetailRow extends StatelessWidget { const _DetailRow({required this.label, required this.value}); diff --git a/lib/ui/screens/games/game_library_screen.dart b/lib/ui/screens/games/game_library_screen.dart index 06fa8f573..746888b74 100644 --- a/lib/ui/screens/games/game_library_screen.dart +++ b/lib/ui/screens/games/game_library_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -6,12 +7,16 @@ import 'package:go_router/go_router.dart'; import 'package:moonfin_design/moonfin_design.dart'; import 'package:server_core/server_core.dart'; +import '../../../data/services/retro_artwork/retro_artwork_activity_gate.dart'; +import '../../../data/services/retro_artwork/retro_artwork_cache.dart'; +import '../../../data/services/retro_artwork/retro_artwork_transport.dart'; import '../../../l10n/app_localizations.dart'; import '../../../preference/user_preferences.dart'; import '../../../util/focus/dpad_keys.dart'; import '../../../util/focus/grid_focus_node_mixin.dart'; import '../../../util/platform_detection.dart'; import '../../navigation/destinations.dart'; +import '../../navigation/route_lifecycle_observer.dart'; import '../../widgets/game/game_system_card.dart'; /// Displays the platforms in a retro-game library. Selecting a platform opens @@ -27,24 +32,73 @@ class GameLibraryScreen extends StatefulWidget { } class _GameLibraryScreenState extends State - with GridFocusNodeMixin { + with GridFocusNodeMixin, RouteAware { final MediaServerClient _client = GetIt.instance(); final UserPreferences _prefs = GetIt.instance(); bool _loading = true; bool _hasError = false; List _systems = const []; - Map> _gamesBySystem = const {}; - Map _gameCountsBySystem = const {}; + RetroArtworkActivityGate? _retroArtworkActivityGate; + RetroArtworkTransport? _retroArtworkTransport; + String? _retroArtworkServerIdentity; + ModalRoute? _observedRoute; + bool _routeIsCovered = false; @override void initState() { super.initState(); + unawaited(_initializePreviewArtwork()); _load(); } + Future _initializePreviewArtwork() async { + if (!GetIt.instance.isRegistered() || + !GetIt.instance.isRegistered()) { + // Widget tests and stripped-down embeddings can still use protocol 1. + return; + } + final cache = await GetIt.instance.getAsync(); + if (!mounted) return; + final gate = GetIt.instance(); + _retroArtworkActivityGate = gate; + _retroArtworkTransport = RetroArtworkTransport.forServer( + client: _client, + cache: cache, + activityGate: gate, + ); + _retroArtworkServerIdentity = normalizeRetroArtworkServerIdentity( + _client.baseUrl, + ); + if (mounted && !_routeIsCovered) setState(() {}); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route == null || route == _observedRoute) return; + if (_observedRoute != null) routeLifecycleObserver.unsubscribe(this); + _observedRoute = route; + routeLifecycleObserver.subscribe(this, route); + } + + @override + void didPushNext() { + _routeIsCovered = true; + _retroArtworkTransport?.cancelAll(); + } + + @override + void didPopNext() { + _routeIsCovered = false; + if (mounted) setState(() {}); + } + @override void dispose() { + if (_observedRoute != null) routeLifecycleObserver.unsubscribe(this); + _retroArtworkTransport?.dispose(); disposeGridFocusNodes(); super.dispose(); } @@ -63,36 +117,15 @@ class _GameLibraryScreenState extends State return; } - // Begin optional previews immediately, but do not make them part of the - // critical path for rendering the system list. - final previewsFuture = _loadPreviews(games); try { final systems = await games.getSystems(widget.libraryId); if (!mounted) return; setState(() { _systems = systems; - _gamesBySystem = const {}; - _gameCountsBySystem = { - for (final system in systems) - if (system.gameCount > 0) system.id.toLowerCase(): system.gameCount, - }; _loading = false; }); - - final previews = await previewsFuture; - if (!mounted || previews == null) return; - final populatedSystems = systems - .where( - (system) => (previews.counts[system.id.toLowerCase()] ?? 0) > 0, - ) - .toList(growable: false); - setState(() { - _systems = populatedSystems; - _gamesBySystem = previews.games; - _gameCountsBySystem = previews.counts; - }); WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) cleanupGridFocusNodes(populatedSystems.length); + if (mounted) cleanupGridFocusNodes(systems.length); }); } catch (e) { debugPrint('[GameLibraryScreen] Failed to load systems: $e'); @@ -104,24 +137,6 @@ class _GameLibraryScreenState extends State } } - Future<_GameSystemPreviews?> _loadPreviews(GamesApi gamesApi) async { - try { - final allGames = await gamesApi.getGames(widget.libraryId); - final previews = >{}; - final counts = {}; - for (final game in allGames) { - final key = game.system.toLowerCase(); - counts[key] = (counts[key] ?? 0) + 1; - final systemPreviews = previews.putIfAbsent(key, () => []); - if (systemPreviews.length < 4) systemPreviews.add(game); - } - return _GameSystemPreviews(games: previews, counts: counts); - } catch (e) { - debugPrint('[GameLibraryScreen] Failed to load system previews: $e'); - return null; - } - } - @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -206,12 +221,16 @@ class _GameLibraryScreenState extends State itemBuilder: (context, index) { final system = _systems[index]; return GameSystemCard( - libraryId: widget.libraryId, system: system, - games: _gamesBySystem[system.id.toLowerCase()] ?? const [], - gameCount: - _gameCountsBySystem[system.id.toLowerCase()] ?? - (system.gameCount > 0 ? system.gameCount : null), + gameCount: system.gameCount, + retroArtworkTransport: _routeIsCovered + ? null + : _retroArtworkTransport, + retroArtworkActivityGate: _routeIsCovered + ? null + : _retroArtworkActivityGate, + libraryId: widget.libraryId, + serverIdentity: _retroArtworkServerIdentity, autofocus: PlatformDetection.isTV && index == 0, focusNode: getGridItemFocusNode(index, prefix: 'game_system'), focusColor: focusColor, @@ -242,10 +261,3 @@ class _GameLibraryScreenState extends State ); } } - -class _GameSystemPreviews { - const _GameSystemPreviews({required this.games, required this.counts}); - - final Map> games; - final Map counts; -} diff --git a/lib/ui/screens/games/game_system_screen.dart b/lib/ui/screens/games/game_system_screen.dart index a6f047e42..de822feb9 100644 --- a/lib/ui/screens/games/game_system_screen.dart +++ b/lib/ui/screens/games/game_system_screen.dart @@ -2,31 +2,52 @@ import 'dart:async'; import 'dart:ui'; import 'package:custom_tv_text_field/custom_tv_text_field.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show ScrollCacheExtent; import 'package:flutter/services.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart' + show HttpExceptionWithStatus; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:moonfin_design/moonfin_design.dart'; import 'package:server_core/server_core.dart'; +import '../../../data/services/background_service.dart'; +import '../../../data/services/retro_artwork/retro_artwork_activity_gate.dart'; +import '../../../data/services/retro_artwork/retro_artwork_cache.dart'; +import '../../../data/services/retro_artwork/retro_artwork_data_source.dart'; +import '../../../data/services/retro_artwork/retro_artwork_transport.dart'; import '../../../data/viewmodels/game_system_browse_view_model.dart'; import '../../../l10n/app_localizations.dart'; import '../../../preference/user_preferences.dart'; +import '../../../util/game_artwork_cache.dart'; import '../../../util/game_library.dart'; import '../../../util/focus/dpad_keys.dart'; import '../../../util/focus/grid_focus_node_mixin.dart'; import '../../../util/platform_detection.dart'; +import '../../../util/tv_image_cache_stub.dart' + if (dart.library.io) '../../../util/tv_image_cache_io.dart'; import '../../navigation/destinations.dart'; +import '../../navigation/route_lifecycle_observer.dart'; import '../../widgets/bounded_network_image.dart'; import '../../widgets/focus/focusable_toolbar_button.dart'; import '../../widgets/focus/request_initial_focus.dart'; import '../../widgets/game/game_alpha_picker_bar.dart'; -import '../../widgets/game/game_artwork_load_scheduler.dart'; import '../../widgets/game/game_poster_card.dart'; +import '../../widgets/game/retro_artwork_image.dart'; + import '../../widgets/local_search_field.dart'; +/// Rows either side of the anchor named in the server priority hint. Wider +/// than the viewport so thumbnailing starts before a row is scrolled into it. +const int _priorityWindowRows = 4; + +/// Concurrent legacy-protocol cache warms. Protocol 2 is bounded by +/// [RetroArtworkTransport.maxConcurrentTransfers] instead. +const int _legacyPrefetchWorkers = 4; + /// Browses one retro-game platform using the same vertical grid and alphabet /// filtering interaction as Moonfin's regular media libraries. class GameSystemScreen extends StatefulWidget { @@ -35,28 +56,40 @@ class GameSystemScreen extends StatefulWidget { required this.libraryId, required this.systemId, this.systemName, + @visibleForTesting this.debugArtworkDataSource, + @visibleForTesting this.debugArtworkTransport, + @visibleForTesting this.debugArtworkActivityGate, }); final String libraryId; final String systemId; final String? systemName; + /// Test-only seam. When supplied, [_initializeArtworkDataSource] wires + /// these directly instead of resolving the real capability probe and + /// factory chain, so widget tests can drive band admission with fully + /// controllable fakes. + @visibleForTesting + final RetroArtworkDataSource? debugArtworkDataSource; + @visibleForTesting + final RetroArtworkTransport? debugArtworkTransport; + @visibleForTesting + final RetroArtworkActivityGate? debugArtworkActivityGate; + @override State createState() => _GameSystemScreenState(); } class _GameSystemScreenState extends State - with GridFocusNodeMixin { + with GridFocusNodeMixin, RouteAware { static const _compactBreakpoint = 600.0; static const _compactHorizontalPadding = 16.0; static const _desktopHorizontalPadding = 60.0; static const _gridTopPadding = 8.0; static const _gridBottomFocusPeek = 52.0; - static const _artworkPrefetchRows = 5; - static const _tvArtworkSettleDelay = Duration(milliseconds: 200); - static const _otherArtworkSettleDelay = Duration(milliseconds: 120); - static const _tvArtworkPrefetchDelay = Duration(milliseconds: 700); - static const _otherArtworkPrefetchDelay = Duration(milliseconds: 450); + // Backdrop art is a much heavier decode than a grid thumbnail; deliberately + // its own constant, not shared with the grid settle delays above. + static const _backdropSettleDelay = Duration(milliseconds: 700); final UserPreferences _prefs = GetIt.instance(); final TextEditingController _searchController = TextEditingController(); @@ -69,42 +102,208 @@ class _GameSystemScreenState extends State ), }; final ScrollController _gridScrollController = ScrollController(); - late final GamesApi? _gamesApi; - late final GameArtworkLoadScheduler _artworkScheduler; late final GameSystemBrowseViewModel _browse; + RetroArtworkActivityGate? _retroArtworkActivityGate; + RetroArtworkTransport? _retroArtworkTransport; + RetroArtworkDataSource? _retroArtworkDataSource; + _ArtworkGridLayout? _artworkGridLayout; + // Only used to avoid re-sending an identical priority hint. Nothing about + // what renders depends on these. + List? _artworkPlanGames; + int? _artworkPlanAnchorRow; + int _priorityGeneration = 0; + late String _artworkScope; List? _observedVisibleGames; Timer? _hoverScrollSettle; GameSummary? _hoveredGame; bool _suppressHoverEnrichment = false; Timer? _tvBackReplayGuardTimer; - Timer? _artworkSettleTimer; - Timer? _artworkPrefetchTimer; + Timer? _backdropSettleTimer; + // Only ever set by the settle timer below, never straight from the view + // model's own (shorter) backdropGame. + GameSummary? _settledBackdropGame; + String? _pendingBackdropGameId; bool _ignoreNextTvPop = false; bool _allowTvPop = false; + bool _routeIsCovered = false; + bool _artworkRefreshQueued = false; + ModalRoute? _observedRoute; @override void initState() { super.initState(); - _gamesApi = GetIt.instance().gamesApi; - _artworkScheduler = GameArtworkLoadScheduler() - ..addListener(_onArtworkSchedulerChanged); + _artworkScope = gameArtworkScope(widget.libraryId, widget.systemId); + _activateArtworkScope(); _browse = GameSystemBrowseViewModel( - gamesApi: _gamesApi, + gamesApi: GetIt.instance().gamesApi, libraryId: widget.libraryId, systemId: widget.systemId, systemName: widget.systemName, )..addListener(_onBrowseChanged); _searchController.addListener(_onSearchChanged); _searchFocus.addListener(_onSearchFocusChanged); - _browse.load(); + unawaited(_loadGamesAndArtwork()); + } + + Future _loadGamesAndArtwork() async { + await _browse.load(); + if (!mounted || _browse.error != null) return; + await _initializeArtworkDataSource(); + final dataSource = _retroArtworkDataSource; + if (dataSource == null || !mounted) return; + try { + await dataSource.refreshSystem( + libraryId: widget.libraryId, + systemId: widget.systemId, + ); + } catch (error) { + debugPrint( + '[GameSystemScreen] Failed to refresh artwork manifest: $error', + ); + } + if (mounted) { + setState(() {}); + _scheduleArtworkPriority(); + } + } + + Future _initializeArtworkDataSource() async { + if (_retroArtworkDataSource != null) return; + final debugDataSource = widget.debugArtworkDataSource; + if (debugDataSource != null) { + debugDataSource.addSnapshotListener(_onArtworkSnapshotChanged); + final gate = widget.debugArtworkActivityGate; + if (gate != null) { + _retroArtworkActivityGate = gate; + gate.addListener(_onArtworkGateChanged); + } + _retroArtworkTransport = widget.debugArtworkTransport; + _retroArtworkDataSource = debugDataSource; + if (_routeIsCovered) debugDataSource.onRouteCovered(); + return; + } + if (!GetIt.instance.isRegistered() || + !GetIt.instance.isRegistered()) { + return; + } + final client = GetIt.instance(); + final cache = await GetIt.instance.getAsync(); + if (!mounted || _retroArtworkDataSource != null) return; + final gate = GetIt.instance(); + final transport = RetroArtworkTransport.forServer( + client: client, + cache: cache, + activityGate: gate, + ); + final dataSource = await RetroArtworkDataSourceFactory.create( + client: client, + activityGate: gate, + transport: transport, + ); + if (!mounted) { + dataSource?.dispose(); + transport.dispose(); + return; + } + dataSource?.addSnapshotListener(_onArtworkSnapshotChanged); + _retroArtworkActivityGate = gate; + gate.addListener(_onArtworkGateChanged); + _retroArtworkTransport = transport; + _retroArtworkDataSource = dataSource; + if (_routeIsCovered) dataSource?.onRouteCovered(); + } + + void _onArtworkSnapshotChanged() { + _scheduleArtworkRefresh( + recenter: + _retroArtworkDataSource?.protocol == RetroArtworkProtocol.manifest, + ); + } + + /// Image loading and error builders may report an artwork outcome while the + /// framework is building the grid. Repaint only after that frame completes. + void _scheduleArtworkRefresh({required bool recenter}) { + if (!mounted || _artworkRefreshQueued) return; + _artworkRefreshQueued = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _artworkRefreshQueued = false; + if (!mounted) return; + setState(() {}); + if (recenter) _scheduleArtworkPriority(force: true); + }); + // addPostFrameCallback only fires once a frame is already scheduled. If + // artwork completes while the app is otherwise idle there isn't one, so + // ask for it explicitly or the callback above queues forever. + WidgetsBinding.instance.scheduleFrame(); + } + + void _onArtworkGateChanged() { + final gate = _retroArtworkActivityGate; + if (!mounted || gate == null) return; + if (!gate.isOpen) { + _clearArtworkPlan(); + return; + } + if (!_routeIsCovered) _scheduleArtworkPriority(force: true); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route == null || route == _observedRoute) return; + if (_observedRoute != null) { + routeLifecycleObserver.unsubscribe(this); + } + _observedRoute = route; + routeLifecycleObserver.subscribe(this, route); + } + + @override + void didPushNext() { + super.didPushNext(); + _routeIsCovered = true; + _clearArtworkPlan(); + _retroArtworkDataSource?.onRouteCovered(); + _retroArtworkTransport?.cancelAll(); + } + + @override + void didPopNext() { + super.didPopNext(); + _routeIsCovered = false; + _retroArtworkDataSource?.onRouteReentered(); + _scheduleArtworkPriority(force: true); + } + + // This route is keyed by "/" (see app_router.dart), so + // Flutter always mounts a fresh state when the system changes and this + // branch is not expected to run in practice. It stays as a safety net in + // case that keying ever changes. + @override + void didUpdateWidget(covariant GameSystemScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.libraryId != widget.libraryId || + oldWidget.systemId != widget.systemId) { + releaseGameArtworkCacheScope(_artworkScope); + _artworkScope = gameArtworkScope(widget.libraryId, widget.systemId); + _activateArtworkScope(); + } } @override void dispose() { + if (_observedRoute != null) { + routeLifecycleObserver.unsubscribe(this); + _observedRoute = null; + } _browse.removeListener(_onBrowseChanged); _browse.dispose(); - _artworkScheduler.removeListener(_onArtworkSchedulerChanged); - _artworkScheduler.dispose(); + _retroArtworkDataSource?.removeSnapshotListener(_onArtworkSnapshotChanged); + _retroArtworkDataSource?.dispose(); + _retroArtworkTransport?.dispose(); + _retroArtworkActivityGate?.removeListener(_onArtworkGateChanged); + _backdropSettleTimer?.cancel(); _searchController.removeListener(_onSearchChanged); _searchFocus.removeListener(_onSearchFocusChanged); _searchController.dispose(); @@ -115,8 +314,8 @@ class _GameSystemScreenState extends State _gridScrollController.dispose(); _hoverScrollSettle?.cancel(); _tvBackReplayGuardTimer?.cancel(); - _artworkSettleTimer?.cancel(); - _artworkPrefetchTimer?.cancel(); + releaseGameArtworkCacheScope(_artworkScope); + unawaited(enforceGameArtworkCacheBudget(throttle: true)); disposeGridFocusNodes(); super.dispose(); } @@ -125,6 +324,17 @@ class _GameSystemScreenState extends State _browse.updateSearch(_searchController.text); } + void _activateArtworkScope() { + unawaited(_retainArtworkScope()); + } + + Future _retainArtworkScope() async { + await retainGameArtworkCacheScope(_artworkScope); + // Once the new system is protected, inactive systems are eligible for + // whole-scope LRU eviction if the combined game-art budget needs room. + await enforceGameArtworkCacheBudget(throttle: true); + } + void _selectLetter(String letter) { if (letter == _browse.selectedLetter) return; @@ -144,22 +354,47 @@ class _GameSystemScreenState extends State final visibleGames = _browse.visibleGames; if (!identical(_observedVisibleGames, visibleGames)) { _observedVisibleGames = visibleGames; - _artworkSettleTimer?.cancel(); - _artworkPrefetchTimer?.cancel(); - _artworkScheduler.clearViewport(); + _artworkGridLayout = null; + _clearArtworkPlan(); gridContentVersion++; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) cleanupGridFocusNodes(visibleGames.length); }); } + _scheduleBackdropSettle(); setState(() {}); } - void _onSearchFocusChanged() { - if (mounted) setState(() {}); + /// Gates the entire backdrop pipeline (build, decode, blur) behind + /// [_backdropSettleDelay], keyed off undebounced `activeGame` rather than + /// the view model's own `backdropGame` (using both would stack two waits). + void _scheduleBackdropSettle() { + final target = _browse.activeGame; + if (target == null) { + // Hiding is immediate; only *starting* a new backdrop is delayed. + _backdropSettleTimer?.cancel(); + _backdropSettleTimer = null; + _pendingBackdropGameId = null; + _settledBackdropGame = null; + return; + } + // Comparing String ids (not null == null) below: a null target is + // handled above, so reaching here always means a real candidate. + if (target.id == _settledBackdropGame?.id || + target.id == _pendingBackdropGameId) { + return; + } + _backdropSettleTimer?.cancel(); + _pendingBackdropGameId = target.id; + _backdropSettleTimer = Timer(_backdropSettleDelay, () { + _backdropSettleTimer = null; + _pendingBackdropGameId = null; + if (!mounted || _browse.activeGame?.id != target.id) return; + setState(() => _settledBackdropGame = target); + }); } - void _onArtworkSchedulerChanged() { + void _onSearchFocusChanged() { if (mounted) setState(() {}); } @@ -214,139 +449,6 @@ class _GameSystemScreenState extends State }); } - void _scheduleArtworkWindow({ - required ScrollMetrics metrics, - required int crossAxisCount, - required double rowStride, - required List games, - }) { - _artworkSettleTimer?.cancel(); - _artworkPrefetchTimer?.cancel(); - // Stop feeding the old viewport into flutter_cache_manager. Its requests - // cannot be cancelled once started, but clearing here caps stale work at - // the current four-request batch instead of the whole nearby-row queue. - _artworkScheduler.clearViewport(); - final rowCount = (games.length / crossAxisCount).ceil(); - if (rowCount == 0) return; - final firstVisibleRow = ((metrics.pixels - _gridTopPadding) / rowStride) - .floor() - .clamp(0, rowCount - 1) - .toInt(); - final lastVisibleRowExclusive = - ((metrics.pixels + metrics.viewportDimension - _gridTopPadding) / - rowStride) - .ceil() - .clamp(firstVisibleRow + 1, rowCount) - .toInt(); - final firstRow = (firstVisibleRow - _artworkPrefetchRows) - .clamp(0, rowCount - 1) - .toInt(); - final lastRowExclusive = (lastVisibleRowExclusive + _artworkPrefetchRows) - .clamp(1, rowCount) - .toInt(); - final visibleFirstIndex = firstVisibleRow * crossAxisCount; - final visibleLastIndexExclusive = (lastVisibleRowExclusive * crossAxisCount) - .clamp(0, games.length) - .toInt(); - - final settleDelay = PlatformDetection.isTV - ? _tvArtworkSettleDelay - : _otherArtworkSettleDelay; - _artworkSettleTimer = Timer(settleDelay, () { - if (!mounted || !identical(games, _browse.visibleGames)) return; - // Submit only the settled viewport first. Rapid intermediate d-pad or - // pointer positions never reach the cache manager's non-cancellable FIFO. - _showArtworkRange( - games, - firstIndex: visibleFirstIndex, - lastIndexExclusive: visibleLastIndexExclusive, - priorityFirstIndex: visibleFirstIndex, - priorityLastIndexExclusive: visibleLastIndexExclusive, - crossAxisCount: crossAxisCount, - ); - }); - - final prefetchDelay = PlatformDetection.isTV - ? _tvArtworkPrefetchDelay - : _otherArtworkPrefetchDelay; - _artworkPrefetchTimer = Timer(prefetchDelay, () { - if (!mounted || !identical(games, _browse.visibleGames)) return; - // Add nearby rows only after the user remains on this viewport. Visible - // URLs stay first in the expanded queue. - _showArtworkRange( - games, - firstIndex: firstRow * crossAxisCount, - lastIndexExclusive: (lastRowExclusive * crossAxisCount) - .clamp(0, games.length) - .toInt(), - priorityFirstIndex: visibleFirstIndex, - priorityLastIndexExclusive: visibleLastIndexExclusive, - crossAxisCount: crossAxisCount, - ); - }); - } - - void _showArtworkRange( - List games, { - required int firstIndex, - required int lastIndexExclusive, - required int priorityFirstIndex, - required int priorityLastIndexExclusive, - required int crossAxisCount, - }) { - if (games.isEmpty || firstIndex >= lastIndexExclusive) return; - var priorityStart = priorityFirstIndex.clamp( - firstIndex, - lastIndexExclusive - 1, - ); - var priorityEnd = priorityLastIndexExclusive.clamp( - priorityStart + 1, - lastIndexExclusive, - ); - - // Center the priority block on the focused row so the row the user is on - // fetches first and nearby rows expand outward from it, rather than always - // starting at the top row of the viewport (which may be partly scrolled - // off). Falls back to the passed viewport block when nothing is focused - // (e.g. touch scrolling), where there is no "current row". - final focusedIndex = _gridHasFocus ? lastFocusedGridIndex : null; - if (focusedIndex != null && - focusedIndex >= firstIndex && - focusedIndex < lastIndexExclusive) { - final focusedRowStart = focusedIndex - (focusedIndex % crossAxisCount); - priorityStart = focusedRowStart.clamp(firstIndex, lastIndexExclusive - 1); - priorityEnd = (focusedRowStart + crossAxisCount).clamp( - priorityStart + 1, - lastIndexExclusive, - ); - } - - final orderedIndexes = gameArtworkLoadOrder( - firstIndex: firstIndex, - lastIndexExclusive: lastIndexExclusive, - visibleFirstIndex: priorityStart, - visibleLastIndexExclusive: priorityEnd, - crossAxisCount: crossAxisCount, - surroundingRows: _artworkPrefetchRows, - ); - final urls = [ - for (final index in orderedIndexes) ?_gameThumbUrl(games[index].id), - ]; - final priorityIndex = - focusedIndex != null && - focusedIndex >= priorityStart && - focusedIndex < priorityEnd - ? focusedIndex - : priorityStart; - _artworkScheduler.showViewport( - urls, - priorityKey: _gameThumbUrl(games[priorityIndex].id), - ); - } - - String? _gameThumbUrl(String gameId) => - _gamesApi?.thumbUrl(libraryId: widget.libraryId, gameId: gameId); - @override Widget build(BuildContext context) { final compact = @@ -364,20 +466,23 @@ class _GameSystemScreenState extends State !compact && screenSize.height >= 480 * textScale.clamp(1, 2) && _prefs.get(UserPreferences.showMediaDetailsOnLibraryPage); - final backdropGame = showBackdrop ? _browse.backdropGame : null; + final backdropGame = showBackdrop ? _settledBackdropGame : null; final hasBackdrop = backdropGame != null; final scaffold = Scaffold( backgroundColor: AppColorScheme.background, body: Stack( children: [ - if (backdropGame != null) + if (backdropGame != null && !_routeIsCovered) Positioned.fill( child: AnimatedSwitcher( duration: const Duration(milliseconds: 300), child: _GameBrowseBackdrop( key: ValueKey(backdropGame.id), - libraryId: widget.libraryId, game: backdropGame, + artworkScope: _artworkScope, + artworkDataSource: _retroArtworkDataSource, + retroArtworkTransport: _retroArtworkTransport, + retroArtworkActivityGate: _retroArtworkActivityGate, blur: _prefs .get(UserPreferences.browsingBackgroundBlurAmount) .toDouble(), @@ -676,6 +781,138 @@ class _GameSystemScreenState extends State }); } + /// Tells the server which games to thumbnail first, and pre-warms the cache + /// on the legacy protocol. + /// + /// This is advisory. Nothing gates rendering on it, so a hint that is stale, + /// superseded, or never sent costs ordering and never artwork: each card + /// requests its own image when the grid builds it and cancels when the grid + /// disposes it, which is also what bounds a fast scroll. + void _scheduleArtworkPriority({ + ScrollMetrics? metrics, + int? focusedIndex, + bool force = false, + }) { + final layout = _artworkGridLayout; + final dataSource = _retroArtworkDataSource; + // A null data source here is the ordinary case for the first frames after + // the screen opens: initialization is async, and the hint follows as soon + // as it lands. + if (!mounted || layout == null || dataSource == null) return; + final currentMetrics = + metrics ?? + (_gridScrollController.hasClients + ? _gridScrollController.position + : null); + final viewportCenter = _viewportCenteredIndex(layout, currentMetrics); + final anchorIndex = + (focusedIndex ?? + (_gridHasFocus ? lastFocusedGridIndex : null) ?? + viewportCenter) + .clamp(0, layout.games.length - 1) + .toInt(); + final anchorRow = anchorIndex ~/ layout.crossAxisCount; + if (!force && + identical(_artworkPlanGames, layout.games) && + _artworkPlanAnchorRow == anchorRow) { + return; + } + _artworkPlanGames = layout.games; + _artworkPlanAnchorRow = anchorRow; + + final reach = layout.crossAxisCount * _priorityWindowRows; + final start = (anchorIndex - reach).clamp(0, layout.games.length).toInt(); + final end = (anchorIndex + reach).clamp(0, layout.games.length).toInt(); + final gameIds = [ + layout.games[anchorIndex].id, + for (var index = start; index < end; index++) + if (index != anchorIndex) layout.games[index].id, + ]; + if (gameIds.isEmpty) return; + // The generation only supersedes queued hints, never a transfer. + unawaited( + dataSource.submitActiveBandPriority( + gameIds, + planGeneration: ++_priorityGeneration, + ), + ); + if (dataSource.protocol == RetroArtworkProtocol.legacy) { + unawaited(_prefetchLegacyArtwork(dataSource, gameIds)); + } + } + + void _clearArtworkPlan() { + _artworkPlanGames = null; + _artworkPlanAnchorRow = null; + } + + int _viewportCenteredIndex( + _ArtworkGridLayout layout, + ScrollMetrics? metrics, + ) { + final offset = metrics == null + ? 0.0 + : metrics.pixels + metrics.viewportDimension / 2; + final row = (offset / layout.rowStride) + .floor() + .clamp(0, (layout.games.length - 1) ~/ layout.crossAxisCount) + .toInt(); + return (row * layout.crossAxisCount + layout.crossAxisCount ~/ 2) + .clamp(0, layout.games.length - 1) + .toInt(); + } + + /// Pre-warms the legacy cache manager and records each outcome so missing + /// art stays known. Protocol 2 needs no equivalent: its cards drive their own + /// transfers through [RetroArtworkImage]. + Future _prefetchLegacyArtwork( + RetroArtworkDataSource dataSource, + List gameIds, + ) async { + final pending = <(String, String)>[ + for (final gameId in gameIds) + if (dataSource.imageFor(gameId)?.legacyUrl case final url?) + (gameId, url), + ]; + if (pending.isEmpty) return; + // Abandons the walk when a newer hint replaces this one. Safe because this + // only fills a cache -- the cards read through it either way. + final generation = _priorityGeneration; + var next = 0; + + Future worker() async { + while (mounted && + generation == _priorityGeneration && + next < pending.length) { + final (gameId, url) = pending[next++]; + try { + await gameArtworkCacheManagerForScope( + _artworkScope, + ).getSingleFile(url); + dataSource.reportImageLoaded(gameId); + } catch (error) { + dataSource.reportImageFailure( + gameId, + statusCode: _statusCodeFromArtworkError(error), + ); + } + } + } + + final workerCount = pending.length < _legacyPrefetchWorkers + ? pending.length + : _legacyPrefetchWorkers; + await Future.wait(>[ + for (var index = 0; index < workerCount; index++) worker(), + ]); + } + + int? _statusCodeFromArtworkError(Object error) { + if (error is DioException) return error.response?.statusCode; + if (error is HttpExceptionWithStatus) return error.statusCode; + return null; + } + Widget _buildGrid( List games, { required double desktopScale, @@ -709,33 +946,16 @@ class _GameSystemScreenState extends State captionHeight * textScale; final childAspectRatio = cardWidth / cardHeight; final rowStride = cardHeight + 14; - final initialVisibleRowCount = (constraints.maxHeight / rowStride) - .ceil(); - final initialVisibleLastIndex = (initialVisibleRowCount * columnCount) - .clamp(0, games.length) - .toInt(); - final initialArtworkLastIndex = - ((initialVisibleRowCount + _artworkPrefetchRows) * columnCount) - .clamp(0, games.length) - .toInt(); - final scheduleArtwork = PlatformDetection.isTV; - if (scheduleArtwork && - !_artworkScheduler.hasViewport && - games.isNotEmpty) { + final layout = _ArtworkGridLayout( + games: games, + crossAxisCount: columnCount, + rowStride: rowStride, + ); + if (_artworkGridLayout != layout) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || - _artworkScheduler.hasViewport || - !identical(games, _browse.visibleGames)) { - return; - } - _showArtworkRange( - games, - firstIndex: 0, - lastIndexExclusive: initialArtworkLastIndex, - priorityFirstIndex: 0, - priorityLastIndexExclusive: initialVisibleLastIndex, - crossAxisCount: columnCount, - ); + if (!mounted || !identical(games, _browse.visibleGames)) return; + _artworkGridLayout = layout; + _scheduleArtworkPriority(); }); } final isNeon = ThemeRegistry.active.id == ThemeRegistry.neonPulseId; @@ -747,26 +967,10 @@ class _GameSystemScreenState extends State ); return NotificationListener( onNotification: (notification) { - // TV navigation uses a small priority queue to keep d-pad focus - // ahead of the cache manager's non-cancellable request FIFO. - // Pointer-driven platforms use the same lazy image loading as the - // standard media grids, allowing incoming rows to paint artwork - // while a drag or fling is still in progress. - if (!scheduleArtwork) return false; - if (notification is ScrollStartNotification) { - // Cancel pending prefetch, but keep the current viewport enabled - // so already-loaded artwork stays visible while scrolling and the - // initial submission is not wiped by a programmatic scroll (e.g. - // the first focus). ScrollEnd re-submits the settled window. - _artworkSettleTimer?.cancel(); - _artworkPrefetchTimer?.cancel(); + if (notification is ScrollUpdateNotification) { + _scheduleArtworkPriority(metrics: notification.metrics); } else if (notification is ScrollEndNotification) { - _scheduleArtworkWindow( - metrics: notification.metrics, - crossAxisCount: columnCount, - rowStride: rowStride, - games: games, - ); + _scheduleArtworkPriority(metrics: notification.metrics); } return false; }, @@ -778,12 +982,10 @@ class _GameSystemScreenState extends State ), child: GridView.builder( controller: _gridScrollController, - // Pointer-driven platforms construct an extra viewport so their - // normal lazy image loading stays ahead of an active scroll. TV - // uses the explicit artwork scheduler instead. - scrollCacheExtent: scheduleArtwork - ? null - : const ScrollCacheExtent.viewport(1), + // Keep one viewport above and below the visible rows constructed + // on every platform. This restores smooth reverse scrolling and + // lets the focused row begin its own image request immediately. + scrollCacheExtent: const ScrollCacheExtent.viewport(1), padding: EdgeInsets.fromLTRB( horizontalPadding, _gridTopPadding, @@ -799,19 +1001,51 @@ class _GameSystemScreenState extends State itemCount: games.length, itemBuilder: (context, index) { final game = games[index]; - final imageUrl = _gameThumbUrl(game.id); - final artworkGeneration = !scheduleArtwork || imageUrl == null + final reference = _routeIsCovered ? null - : _artworkScheduler.generationFor(imageUrl); + : _retroArtworkDataSource?.imageFor(game.id); + final source = reference?.source; final column = index % columnCount; final atVisualRightEdge = isRtl ? column == 0 : column == columnCount - 1 || index == games.length - 1; return GamePosterCard( - imageUrl: imageUrl, + imageUrl: reference?.legacyUrl, + artwork: + source == null || + _retroArtworkTransport == null || + _retroArtworkActivityGate == null + ? null + : RetroArtworkImage( + source: source, + transport: _retroArtworkTransport!, + activityGate: _retroArtworkActivityGate!, + // Same rule every other bounded image in the app + // uses: decode to the pixels actually painted. A + // constant here decoded a grid tile at the source + // thumbnail's full width on every device, which is + // several times the tile's real size on a dense + // display and wasted the image cache budget + // accordingly. + maxDecodeWidth: BoundedNetworkImage.cacheWidthFor( + cardWidth, + MediaQuery.devicePixelRatioOf(context), + ), + fit: BoxFit.cover, + onLoadFinished: () => _retroArtworkDataSource + ?.reportImageLoaded(game.id), + errorBuilder: (_, error) { + _retroArtworkDataSource?.reportImageFailure( + game.id, + statusCode: _statusCodeFromArtworkError(error), + ); + return const SizedBox.shrink(); + }, + ), title: game.title, fileName: game.fileName, seed: game.id, + cacheManager: gameArtworkCacheManagerForScope(_artworkScope), width: cardWidth, focusNode: getGridItemFocusNode(index, prefix: 'game_grid'), focusColor: focusColor, @@ -824,6 +1058,7 @@ class _GameSystemScreenState extends State showBackdrop: showBackdrop, showDetails: showDetails, ); + _scheduleArtworkPriority(focusedIndex: index); _scrollToGridRow( index: index, crossAxisCount: columnCount, @@ -839,20 +1074,15 @@ class _GameSystemScreenState extends State ), onHoverEnd: () => _deactivateHoveredGame(game), stopRightTraversal: atVisualRightEdge, - loadArtwork: - imageUrl != null && - (!scheduleArtwork || - _artworkScheduler.isEnabled(imageUrl)), - onArtworkLoadFinished: - artworkGeneration == null || imageUrl == null - ? null - : () { - if (!mounted) return; - _artworkScheduler.markFinished( - imageUrl, - artworkGeneration, - ); - }, + loadArtwork: true, + onArtworkLoadFinished: () => + _retroArtworkDataSource?.reportImageLoaded(game.id), + onArtworkError: (error) { + _retroArtworkDataSource?.reportImageFailure( + game.id, + statusCode: _statusCodeFromArtworkError(error), + ); + }, onKeyEvent: (_, event) { if (PlatformDetection.isTV && event.isActionable && @@ -881,6 +1111,28 @@ class _GameSystemScreenState extends State } } +class _ArtworkGridLayout { + const _ArtworkGridLayout({ + required this.games, + required this.crossAxisCount, + required this.rowStride, + }); + + final List games; + final int crossAxisCount; + final double rowStride; + + @override + bool operator ==(Object other) => + other is _ArtworkGridLayout && + identical(other.games, games) && + other.crossAxisCount == crossAxisCount && + other.rowStride == rowStride; + + @override + int get hashCode => Object.hash(games, crossAxisCount, rowStride); +} + class _FocusedGameHud extends StatelessWidget { const _FocusedGameHud({ required this.desktopScale, @@ -1008,23 +1260,25 @@ class _GameMetadataRow extends StatelessWidget { class _GameBrowseBackdrop extends StatelessWidget { const _GameBrowseBackdrop({ super.key, - required this.libraryId, required this.game, + required this.artworkScope, + required this.artworkDataSource, + required this.retroArtworkTransport, + required this.retroArtworkActivityGate, required this.blur, }); - final String libraryId; final GameSummary game; + final String artworkScope; + final RetroArtworkDataSource? artworkDataSource; + final RetroArtworkTransport? retroArtworkTransport; + final RetroArtworkActivityGate? retroArtworkActivityGate; final double blur; @override Widget build(BuildContext context) { final fallback = gameFallbackColor(game.id); - final urls = [ - gameThumbUrl(libraryId, game.id, kind: 'snap'), - gameThumbUrl(libraryId, game.id, kind: 'title'), - gameThumbUrl(libraryId, game.id), - ].nonNulls.toList(growable: false); + const roles = ['snap', 'title', 'boxart']; return Stack( fit: StackFit.expand, @@ -1041,7 +1295,15 @@ class _GameBrowseBackdrop extends StatelessWidget { ), ), ), - _GameBrowseBackdropImage(urls: urls, blur: blur), + _GameBrowseBackdropImage( + roles: roles, + gameId: game.id, + artworkScope: artworkScope, + artworkDataSource: artworkDataSource, + retroArtworkTransport: retroArtworkTransport, + retroArtworkActivityGate: retroArtworkActivityGate, + blur: blur, + ), ], ); } @@ -1049,28 +1311,85 @@ class _GameBrowseBackdrop extends StatelessWidget { class _GameBrowseBackdropImage extends StatelessWidget { const _GameBrowseBackdropImage({ - required this.urls, + required this.roles, + required this.gameId, + required this.artworkScope, + required this.artworkDataSource, + required this.retroArtworkTransport, + required this.retroArtworkActivityGate, required this.blur, this.index = 0, }); - final List urls; + final List roles; + final String gameId; + final String artworkScope; + final RetroArtworkDataSource? artworkDataSource; + final RetroArtworkTransport? retroArtworkTransport; + final RetroArtworkActivityGate? retroArtworkActivityGate; final double blur; final int index; @override Widget build(BuildContext context) { - if (index >= urls.length) return const SizedBox.shrink(); - - Widget image = BoundedNetworkImage( - imageUrl: urls[index], - fit: BoxFit.cover, - maxWidth: blur > 0 ? 640 : 1920, - scale: blur > 0 ? 0.6 : 1, - fadeInDuration: const Duration(milliseconds: 200), - errorBuilder: (_, _, _) => - _GameBrowseBackdropImage(urls: urls, blur: blur, index: index + 1), - ); + if (index >= roles.length) return const SizedBox.shrink(); + final role = roles[index]; + final reference = artworkDataSource?.imageFor(gameId, role: role); + if (reference == null) return _next(); + + final source = reference.source; + final transport = retroArtworkTransport; + final gate = retroArtworkActivityGate; + + // Always sits behind a translucent scrim (often a blur too), so it is + // decoded at half upstream's backdrop budget: indistinguishable once + // dimmed, for a quarter the decode cost. Derived from upstream's constants + // rather than written out, so this tracks any change to them. Note the + // blurred case halves the *unblurred* budget, not upstream's already + // reduced blurred one, which would be softer than this layer wants. + Widget image; + if (source != null && transport != null && gate != null) { + image = RetroArtworkImage( + source: source, + transport: transport, + activityGate: gate, + maxDecodeWidth: blur > 0 + ? BackgroundService.backdropMaxWidth ~/ 4 + : BackgroundService.backdropMaxWidth ~/ 2, + fit: BoxFit.cover, + onLoadFinished: () => + artworkDataSource?.reportImageLoaded(gameId, role: role), + errorBuilder: (_, error) { + artworkDataSource?.reportImageFailure( + gameId, + role: role, + statusCode: _gameArtworkStatusCode(error), + ); + return _next(); + }, + ); + } else if (reference.legacyUrl case final url?) { + image = BoundedNetworkImage( + imageUrl: url, + cacheManager: gameArtworkCacheManagerForScope(artworkScope), + fit: BoxFit.cover, + maxWidth: blur > 0 ? 480 : 960, + scale: blur > 0 ? 0.6 : 1, + fadeInDuration: const Duration(milliseconds: 200), + onLoadFinished: () => + artworkDataSource?.reportImageLoaded(gameId, role: role), + errorBuilder: (_, _, error) { + artworkDataSource?.reportImageFailure( + gameId, + role: role, + statusCode: _gameArtworkStatusCode(error), + ); + return _next(); + }, + ); + } else { + return _next(); + } if (blur <= 0) return image; final sigma = GlassSettings.decorativeSigma(blur); @@ -1086,4 +1405,21 @@ class _GameBrowseBackdropImage extends StatelessWidget { ); return image; } + + Widget _next() => _GameBrowseBackdropImage( + roles: roles, + gameId: gameId, + artworkScope: artworkScope, + artworkDataSource: artworkDataSource, + retroArtworkTransport: retroArtworkTransport, + retroArtworkActivityGate: retroArtworkActivityGate, + blur: blur, + index: index + 1, + ); +} + +int? _gameArtworkStatusCode(Object error) { + if (error is DioException) return error.response?.statusCode; + if (error is HttpExceptionWithStatus) return error.statusCode; + return null; } diff --git a/lib/ui/screens/playback/game_emulator_screen.dart b/lib/ui/screens/playback/game_emulator_screen.dart index 6bb351049..3c71e5a84 100644 --- a/lib/ui/screens/playback/game_emulator_screen.dart +++ b/lib/ui/screens/playback/game_emulator_screen.dart @@ -7,17 +7,20 @@ import 'package:flutter/services.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; +import 'package:pointer_interceptor/pointer_interceptor.dart'; import 'package:server_core/server_core.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; import '../../widgets/adaptive/adaptive_glass.dart'; import '../../../l10n/app_localizations.dart'; +import '../../../data/services/retro_artwork/retro_artwork_activity_gate.dart'; import '../../../util/game_cores.dart'; import '../../../util/platform_detection.dart'; import '../../../util/focus/gamepad/gamepad_suppressor.dart'; import '../../../util/focus/gamepad/android_gamepad_channel.dart'; import '../../../util/insecure_certificates.dart'; import '../../../util/webview_environment.dart'; +import '../../screensaver/screensaver_controller.dart'; +import 'game_playback_ui.dart'; import 'game_audio_owner.dart'; /// Full-screen EmulatorJS host. Loads the Moonbase plugin's player shell in a WebView, streams @@ -30,6 +33,7 @@ class GameEmulatorScreen extends StatefulWidget { required this.libraryId, required this.gameId, required this.core, + this.romFileName, this.biosId, this.gameName, this.startFresh = false, @@ -38,6 +42,7 @@ class GameEmulatorScreen extends StatefulWidget { final String libraryId; final String gameId; final String core; + final String? romFileName; final String? biosId; final String? gameName; @@ -57,7 +62,16 @@ class _GameEmulatorScreenState extends State String? _playerUrl; String? _error; bool _emulatorReady = false; - bool _saving = false; + // Set once by _exit and never reset. Unlike a "saving in progress" flag that + // is released when the persist step finishes, this must stay true for the + // rest of the screen's life: a second _exit() call arriving after the + // persist completes but before context.pop() runs (two platform-channel + // round trips apart -- well within gamepad auto-repeat) would otherwise + // re-enter and pop a second route. + bool _exiting = false; + RetroArtworkActivityGate? _artworkActivityGate; + bool _holdsGameplayArtworkBlock = false; + ScreensaverController? _screensaverController; bool _hasSave = false; // EmulatorJS settings (control remaps + options) sync per user, not per game, so they use a @@ -89,6 +103,10 @@ class _GameEmulatorScreenState extends State final ScrollController _pickerScroll = ScrollController(); bool get _pickerOpen => _pickerOption != null; + // True only while Android is driving EmulatorJS's own control-mapping dialog. + bool _emulatorControlsOpen = false; + bool _confirmingExit = false; + // Open-overlay gesture: hold Start+Select for 5 seconds. bool _startHeld = false; bool _selectHeld = false; @@ -103,8 +121,12 @@ class _GameEmulatorScreenState extends State @override void initState() { super.initState(); + _acquireGameplayArtworkBlock(); _enterImmersive(); - WakelockPlus.enable(); + // A game owns every key from here; a stale IME binding from the browse + // screen's search field would otherwise sit in front of the d-pad. + detachTextInputForGameplay(); + _acquireScreensaverBlock(); // Outside the Android guard on purpose, because the pad belongs to the // game on every platform. Either way UI navigation shouldn't also react. GamepadSuppressor.push(); @@ -113,26 +135,94 @@ class _GameEmulatorScreenState extends State // Routed rather than taking the channel outright, since stick navigation // listens on the same channel and only one handler is allowed. AndroidGamepadChannel.ensureInstalled(); - AndroidGamepadChannel.setButtonHandler(_onNativeGamepad); + AndroidGamepadChannel.setEmulatorInputHandler(_onNativeGamepad); AndroidGamepadChannel.setGameActive(true); } - _prepare(); + unawaited( + _prepare().catchError((Object error, StackTrace stackTrace) { + _releaseGameplayArtworkBlock(); + if (mounted) { + setState(() => _error = 'Could not start this game. ($error)'); + } + }), + ); + } + + void _acquireGameplayArtworkBlock() { + if (_holdsGameplayArtworkBlock || + !GetIt.instance.isRegistered()) { + return; + } + _artworkActivityGate = GetIt.instance(); + _holdsGameplayArtworkBlock = true; + _artworkActivityGate!.setGameplayActive(true); + // The gate stops new artwork work but frees none of what is already + // decoded, and the WebView renderer this screen is about to start competes + // for that memory from a separate process. + releaseImageMemoryForGameplay(); + } + + void _releaseGameplayArtworkBlock() { + if (!_holdsGameplayArtworkBlock) return; + _holdsGameplayArtworkBlock = false; + _artworkActivityGate?.setGameplayActive(false); + } + + // The screensaver controller owns the wake lock, so marking playback active + // does both jobs at once: the display stays awake, and the idle screensaver + // stays disarmed. Taking the wake lock directly leaves the controller + // believing the app is idle, and the screensaver then draws itself over a + // running game -- with no way out, because the WebView consumes gameplay keys + // before they can reach the dismiss handler in Flutter's key pipeline. + void _acquireScreensaverBlock() { + if (!GetIt.instance.isRegistered()) return; + _screensaverController = GetIt.instance(); + _screensaverController!.setPlaybackActive(true); + } + + void _releaseScreensaverBlock() { + final controller = _screensaverController; + if (controller == null) return; + _screensaverController = null; + controller.setPlaybackActive(false); } Future _onNativeGamepad(MethodCall call) async { + if (call.method == 'onKeyboard') { + if (_emulatorControlsOpen) { + final args = (call.arguments as Map).cast(); + final keyCode = args['keyCode'] as int?; + if (keyCode != null) await _sendEmulatorKeyboardInput(keyCode); + } + return null; + } if (call.method != 'onButton') return null; final args = (call.arguments as Map).cast(); - final index = args['index'] as int; + final label = args['label'] as String?; final pressed = args['pressed'] as bool; - // Native source uses RetroPad indices, which double as the injection index in-game. - _handleGamepad(_semanticFromRetroPad(index), pressed, - injectIndex: index, canInject: true); + final device = (args['device'] as Map?)?.cast(); + if (label == null) return null; + if (_emulatorControlsOpen) { + // Do not hold the platform-channel handler open while waiting for a + // WebView callback. That serialized quick d-pad taps behind each other + // on Android TV. The player reports an actual menu close separately. + _sendEmulatorControlInput(label, pressed, device); + return null; + } + _handleGamepad( + _semanticFromEmulatorLabel(label), + pressed, + emulatorLabel: label, + emulatorDevice: device, + canInject: true, + ); return null; } Future _prepare() async { final games = _client.gamesApi; if (games == null) { + _releaseGameplayArtworkBlock(); setState(() => _error = 'This server does not support games.'); return; } @@ -140,16 +230,23 @@ class _GameEmulatorScreenState extends State // On TV and desktop the on-screen touch gamepad is useless, and EmulatorJS wrongly enables // it on Android TV (which reports a mobile user agent). Disable it by default there. if (PlatformDetection.isTV || PlatformDetection.useDesktopUi) { - _userScripts.add(UserScript( - source: - "window.EJS_defaultOptions = Object.assign({}, window.EJS_defaultOptions, {'virtual-gamepad': 'disabled'});", - injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, - )); + _userScripts.add( + UserScript( + source: + "window.EJS_defaultOptions = Object.assign({}, window.EJS_defaultOptions, {'virtual-gamepad': 'disabled'});", + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + ), + ); } var hasSave = false; try { - final existing = await games.getSave(gameStateKey(widget.gameId, widget.core)); + final existing = await loadGameStateWithMigration( + games, + widget.gameId, + widget.core, + forceEmulatorJs: true, + ); hasSave = existing != null && existing.isNotEmpty; } catch (_) {} _hasSave = hasSave; @@ -159,10 +256,13 @@ class _GameEmulatorScreenState extends State final settings = await games.getSave(_settingsId, kind: 'settings'); if (settings != null && settings.isNotEmpty) { final json = utf8.decode(settings); - _userScripts.add(UserScript( - source: "try { localStorage.setItem('ejs-settings', ${jsonEncode(json)}); } catch (e) {}", - injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, - )); + _userScripts.add( + UserScript( + source: + "try { localStorage.setItem('ejs-settings', ${jsonEncode(json)}); } catch (e) {}", + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + ), + ); } } catch (_) {} @@ -170,10 +270,12 @@ class _GameEmulatorScreenState extends State libraryId: widget.libraryId, gameId: widget.gameId, core: widget.core, + romFileName: widget.romFileName, biosId: widget.biosId, gameName: widget.gameName, // Auto-load only when resuming and a save exists (avoids a 404 fetch otherwise). includeSaveUrl: hasSave && !widget.startFresh, + saveId: gameStateKey(widget.gameId, widget.core, forceEmulatorJs: true), ); if (!mounted) return; @@ -181,19 +283,14 @@ class _GameEmulatorScreenState extends State } void _enterImmersive() { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - if (!PlatformDetection.isTV) { - SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); - } + GamePlaybackSystemUi.enter( + immersive: true, + lockLandscape: !PlatformDetection.isTV, + ); } - Future _restoreSystemUi() async { - await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - await SystemChrome.setPreferredOrientations(DeviceOrientation.values); - } + Future _restoreSystemUi() => + GamePlaybackSystemUi.restore(immersive: true); void _onPlayerMessage(List args) { if (args.isEmpty) return; @@ -202,6 +299,7 @@ class _GameEmulatorScreenState extends State switch (message['type']) { case 'moonfin-ready': if (mounted) setState(() => _emulatorReady = true); + unawaited(_registerAndroidGamepads()); break; case 'gamepad': // JS-forwarded (iOS/desktop): standard Gamepad API indices; gameplay is read natively @@ -210,10 +308,41 @@ class _GameEmulatorScreenState extends State final pressed = message['pressed'] as bool; _handleGamepad(_semanticFromStandard(index), pressed, canInject: false); break; + case 'moonfin-menu-request': + // Escape reached the page instead of Flutter. The WebView owns focus + // while a game runs, so a keyboard Escape is delivered to EmulatorJS, + // which acts on it itself -- ending emulation without this screen ever + // learning, so none of the save-on-exit work runs. The bridge now + // swallows that key and asks for the overlay instead, which is the same + // thing Back does on every other input device. + _backOneLevel(); + break; + case 'moonfin-controls-closed': + final reason = message['reason'] as String? ?? 'close'; + unawaited(_onEmulatorControlsClosed(reason)); + break; + case 'moonfin-emulator-contract-violation': + // player.html's moonfinAssertEmulatorContract reports (but never throws for) each + // EmulatorJS internal the native controller-menu adapter depends on that is missing at + // ready-time -- e.g. after an EmulatorJS upstream upgrade renamed or removed one. This + // has no user-visible effect on its own (the adapter's own per-access try/catch already + // degrades to an unresponsive control row rather than crashing), so it is only logged + // here for whoever investigates a "controller settings don't work" report. + final missing = message['missing'] as String?; + debugPrint( + '[GameEmulatorScreen] EmulatorJS contract violation: missing $missing', + ); + break; } } - void _handleGamepad(_Gp sem, bool pressed, {int? injectIndex, required bool canInject}) { + void _handleGamepad( + _Gp sem, + bool pressed, { + String? emulatorLabel, + Map? emulatorDevice, + required bool canInject, + }) { if (sem == _Gp.start || sem == _Gp.select) { if (sem == _Gp.start) _startHeld = pressed; if (sem == _Gp.select) _selectHeld = pressed; @@ -258,7 +387,11 @@ class _GameEmulatorScreenState extends State _changeOption(1); break; case _Gp.confirm: - _openPicker(_settingsSelected); + if (_settingsSelected < 0) { + _closeSettings(); + } else { + _openPicker(_settingsSelected); + } break; case _Gp.cancel: _closeSettings(); @@ -290,19 +423,23 @@ class _GameEmulatorScreenState extends State return; } - if (canInject && injectIndex != null) { + if (canInject && emulatorLabel != null) { // Suppress Start/Select while the open-combo is held so the game never sees a 5s Start. final suppress = _startHeld && _selectHeld && (sem == _Gp.start || sem == _Gp.select); if (!suppress) { _controller?.evaluateJavascript( - source: 'window.moonfinInput && window.moonfinInput($injectIndex, $pressed);', + source: + 'window.moonfinGamepadInput && ' + 'window.moonfinGamepadInput(${jsonEncode(emulatorLabel)}, $pressed, ' + '${jsonEncode(emulatorDevice)});', ); } } } - bool get _menuOpen => _overlayOpen || _settingsOpen || _pickerOpen; + bool get _menuOpen => + _overlayOpen || _settingsOpen || _pickerOpen || _emulatorControlsOpen; void _updateCombo() { if (_startHeld && _selectHeld && !_menuOpen) { @@ -327,20 +464,81 @@ class _GameEmulatorScreenState extends State _selected = 0; }); _controller?.evaluateJavascript( - source: 'window.moonfinPause && window.moonfinPause(true);'); + source: 'window.moonfinPause && window.moonfinPause(true);', + ); + } + + /// Walks out one overlay level. The single back behaviour for every input + /// path: the system gesture and TV/gamepad Back through [PopScope], and a + /// keyboard Escape relayed by the bridge, which the WebView would otherwise + /// consume. + /// + /// A fatal error is the exception: there is no emulator left to walk back + /// into, so Back leaves outright rather than opening an overlay over an error + /// message. Matches the native player, which was made escapable after a + /// mid-game fatal error for the same reason. + void _backOneLevel() { + if (_error != null) { + unawaited(_exit()); + } else if (_confirmingExit) { + // Cancel the confirmation before any other level, so a stray Back can + // never fall through to something that ends the session. + _cancelExitConfirmation(); + } else if (_pickerOpen) { + setState(() => _pickerOption = null); + } else if (_emulatorControlsOpen) { + _sendEmulatorControlInput('BACK', true); + } else if (_settingsOpen) { + _closeSettings(); + } else if (_overlayOpen) { + _closeOverlay(); + } else { + _openOverlay(); + } + } + + /// Asks before ending a running session. A failed load has nothing to lose, + /// so it leaves straight away rather than making the user confirm their way + /// off an error message. + void _requestExit() { + if (_error != null || _playerUrl == null) { + unawaited(_exit()); + return; + } + setState(() { + _confirmingExit = true; + _selected = 0; + }); + } + + void _cancelExitConfirmation() { + setState(() { + _confirmingExit = false; + _selected = 0; + }); } void _closeOverlay() { if (!_overlayOpen) return; - setState(() => _overlayOpen = false); + setState(() { + _overlayOpen = false; + _confirmingExit = false; + }); _controller?.evaluateJavascript( - source: 'window.moonfinPause && window.moonfinPause(false);'); + source: 'window.moonfinPause && window.moonfinPause(false);', + ); } void _move(int dir) { final n = _actions().length; - setState(() => _selected = (_selected + dir + n) % n); - _ensureVisible(_overlayScroll, _selected); + setState( + () => _selected = wrapGamePlaybackMenuSelection(_selected, dir, n), + ); + ensureGamePlaybackMenuSelectionVisible( + _overlayScroll, + _selected, + rowExtent: _rowExtent, + ); } void _activate() { @@ -350,30 +548,167 @@ class _GameEmulatorScreenState extends State List<_OverlayItem> _actions() { final l = _l10n; + // Exit ends the session, and it is one press away from several input + // paths. Confirming it by swapping the action list -- rather than showing a + // dialog -- keeps it navigable by remote, gamepad and keyboard alike, since + // selection and wrapping are driven off this list. The safe choice is + // first, so the default highlight cannot end the game. + if (_confirmingExit) { + return [ + _OverlayItem( + Icons.play_arrow, + l?.resume ?? 'Keep playing', + null, + _cancelExitConfirmation, + ), + _OverlayItem(Icons.close, l?.exit ?? 'Exit', null, _exit, danger: true), + ]; + } return [ - _OverlayItem(Icons.play_arrow, l?.resume ?? 'Resume', null, _closeOverlay), - _OverlayItem(Icons.save_outlined, l?.gameSaveState ?? 'Save state', null, _saveAction), + _OverlayItem( + Icons.play_arrow, + l?.resume ?? 'Resume', + null, + _closeOverlay, + ), + _OverlayItem( + Icons.save_outlined, + l?.gameSaveState ?? 'Save state', + null, + _saveAction, + ), if (_hasSave) - _OverlayItem(Icons.download_outlined, l?.gameLoadState ?? 'Load state', null, _loadAction), - _OverlayItem(Icons.refresh, l?.restart ?? 'Restart', null, _restartAction), + _OverlayItem( + Icons.download_outlined, + l?.gameLoadState ?? 'Load state', + null, + _loadAction, + ), + _OverlayItem( + Icons.refresh, + l?.restart ?? 'Restart', + null, + _restartAction, + ), _OverlayItem( Icons.fast_forward, l?.gameFastForward ?? 'Fast-forward', _fastForward ? 'On' : 'Off', _toggleFastForward, ), - _OverlayItem(Icons.tune, l?.gameEmulatorSettings ?? 'Emulator settings', null, _openSettings), - _OverlayItem(Icons.close, l?.exit ?? 'Exit', null, _exit, danger: true), + _OverlayItem( + Icons.gamepad_outlined, + 'Controller settings', + null, + _openControllerSettings, + ), + _OverlayItem( + Icons.tune, + l?.gameEmulatorSettings ?? 'Emulator settings', + null, + _openSettings, + ), + _OverlayItem( + Icons.close, + l?.exit ?? 'Exit', + null, + _requestExit, + danger: true, + ), ]; } + Future _openControllerSettings() async { + // Android can report Bluetooth/USB controllers after the emulator page is + // ready (or after a reconnect). Re-query immediately before opening the + // upstream picker so its device list never depends on the initial page + // load timing. + await _registerAndroidGamepads(); + final controller = _controller; + if (controller == null) return; + var opened = false; + try { + final result = await controller.callAsyncJavaScript( + functionBody: ''' + if (window.moonfinControlsApiVersion !== 1 || + typeof window.moonfinOpenControls !== 'function') { + return false; + } + return window.moonfinOpenControls() === true; + ''', + ); + opened = result?.value == true; + } catch (_) {} + if (!mounted) return; + if (!opened) { + _showTransientMessage( + 'Controller settings need a newer version of the server emulator player.', + ); + return; + } + _closeOverlay(); + if (PlatformDetection.isAndroid) { + setState(() => _emulatorControlsOpen = true); + await AndroidGamepadChannel.setEmulatorControlsActive(true); + } + } + + void _sendEmulatorControlInput( + String label, + bool pressed, [ + Map? device, + ]) { + final controller = _controller; + if (controller == null) return; + controller.evaluateJavascript( + source: + 'window.moonfinControlInput && window.moonfinControlInput(' + '${jsonEncode(label)}, $pressed, ${jsonEncode(device)});', + ); + } + + Future _onEmulatorControlsClosed(String reason) async { + if (!mounted || !_emulatorControlsOpen) return; + setState(() => _emulatorControlsOpen = false); + await AndroidGamepadChannel.setEmulatorControlsActive(false); + // A controller may have connected while the picker was open. Re-query it + // before gameplay resumes instead of requiring a page reload. + unawaited(_registerAndroidGamepads()); + // Back is the deliberate exit path to Moonfin's pause menu. The upstream + // Close footer button resumes gameplay directly. + if (reason == 'back') _openOverlay(); + } + + Future _sendEmulatorKeyboardInput(int keyCode) async { + // Matches the sibling _sendEmulatorControlInput: fire-and-forget + // evaluateJavascript with an existence check, rather than + // callAsyncJavaScript. Still async because the only caller (the + // "onKeyboard" branch of _onNativeGamepad) awaits it. + await _controller?.evaluateJavascript( + source: + 'window.moonfinKeyboardInput && window.moonfinKeyboardInput($keyCode);', + ); + } + + Future _registerAndroidGamepads() async { + if (!PlatformDetection.isAndroid || _controller == null) return; + final devices = await AndroidGamepadChannel.getEmulatorGamepads(); + if (devices.isEmpty || !mounted) return; + await _controller!.evaluateJavascript( + source: + 'window.moonfinRegisterGamepads && ' + 'window.moonfinRegisterGamepads(${jsonEncode(devices)});', + ); + } + Future _openSettings() async { final controller = _controller; if (controller == null) return; var options = <_GameOption>[]; try { final result = await controller.callAsyncJavaScript( - functionBody: 'return window.moonfinGetOptions ? window.moonfinGetOptions() : "[]";', + functionBody: + 'return window.moonfinGetOptions ? window.moonfinGetOptions() : "[]";', ); final value = result?.value; if (value is String && value.isNotEmpty) { @@ -383,7 +718,7 @@ class _GameEmulatorScreenState extends State if (!mounted) return; setState(() { _options = options; - _settingsSelected = 0; + _settingsSelected = options.isEmpty ? -1 : 0; _overlayOpen = false; _settingsOpen = true; }); @@ -397,40 +732,30 @@ class _GameEmulatorScreenState extends State } void _settingsMove(int dir) { - if (_options.isEmpty) return; - final n = _options.length; - setState(() => _settingsSelected = (_settingsSelected + dir + n) % n); - _ensureVisible(_settingsScroll, _settingsSelected); - } - - /// Keeps the selected row on screen; these lists are index-driven (not Flutter focus), so - /// they do not auto-scroll on their own. - void _ensureVisible(ScrollController controller, int index) { - if (!controller.hasClients) return; - final pos = controller.position; - final top = index * _rowExtent; - final bottom = top + _rowExtent; - double target = pos.pixels; - if (top < pos.pixels) { - target = top; - } else if (bottom > pos.pixels + pos.viewportDimension) { - target = bottom - pos.viewportDimension; - } - target = target.clamp(pos.minScrollExtent, pos.maxScrollExtent); - if (target != pos.pixels) { - controller.animateTo( - target, - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, + // The close control is selection -1, followed by every settings row. + // Including it in this index-driven model makes the header reachable by a + // d-pad without introducing a separate Flutter focus tree over the WebView. + final count = _options.length + 1; + final current = _settingsSelected + 1; + final next = wrapGamePlaybackMenuSelection(current, dir, count); + setState(() => _settingsSelected = next - 1); + if (_settingsSelected >= 0) { + ensureGamePlaybackMenuSelectionVisible( + _settingsScroll, + _settingsSelected, + rowExtent: _rowExtent, ); } } void _changeOption(int dir) { - if (_options.isEmpty) return; + if (_options.isEmpty || _settingsSelected < 0) return; final option = _options[_settingsSelected]; if (option.choices.isEmpty) return; - final next = (option.currentIndex + dir).clamp(0, option.choices.length - 1); + final next = (option.currentIndex + dir).clamp( + 0, + option.choices.length - 1, + ); if (next == option.currentIndex) return; _applyChoice(option, next); } @@ -439,7 +764,8 @@ class _GameEmulatorScreenState extends State setState(() => option.currentIndex = choiceIndex); final choice = option.choices[choiceIndex]; _controller?.evaluateJavascript( - source: "window.moonfinSetOption && window.moonfinSetOption(" + source: + "window.moonfinSetOption && window.moonfinSetOption(" "${jsonEncode(option.id)}, ${jsonEncode(choice.value)});", ); } @@ -454,7 +780,11 @@ class _GameEmulatorScreenState extends State }); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _pickerOpen) { - _ensureVisible(_pickerScroll, _pickerSelected); + ensureGamePlaybackMenuSelectionVisible( + _pickerScroll, + _pickerSelected, + rowExtent: _rowExtent, + ); } }); } @@ -463,8 +793,18 @@ class _GameEmulatorScreenState extends State final index = _pickerOption; if (index == null) return; final n = _options[index].choices.length; - setState(() => _pickerSelected = (_pickerSelected + dir + n) % n); - _ensureVisible(_pickerScroll, _pickerSelected); + setState( + () => _pickerSelected = wrapGamePlaybackMenuSelection( + _pickerSelected, + dir, + n, + ), + ); + ensureGamePlaybackMenuSelectionVisible( + _pickerScroll, + _pickerSelected, + rowExtent: _rowExtent, + ); } void _applyPicker() { @@ -483,28 +823,41 @@ class _GameEmulatorScreenState extends State final choices = <_GameChoice>[]; for (final c in (entry['choices'] as List? ?? const [])) { if (c is Map) { - choices.add(_GameChoice( - (c['value'] as String?) ?? '', - (c['label'] as String?) ?? '', - )); + choices.add( + _GameChoice( + (c['value'] as String?) ?? '', + (c['label'] as String?) ?? '', + ), + ); } } if (choices.isEmpty) continue; final current = entry['current'] as String?; var currentIndex = choices.indexWhere((c) => c.value == current); if (currentIndex < 0) currentIndex = 0; - result.add(_GameOption( - id: (entry['id'] as String?) ?? '', - label: (entry['label'] as String?) ?? '', - choices: choices, - currentIndex: currentIndex, - )); + result.add( + _GameOption( + id: (entry['id'] as String?) ?? '', + label: (entry['label'] as String?) ?? '', + choices: choices, + currentIndex: currentIndex, + ), + ); } return result; } + void _showTransientMessage(String message) { + if (!mounted) return; + showGamePlaybackMessage(context, message); + } + Future _saveAction() async { - await _saveState(); + try { + await _saveState(); + } catch (_) { + _showTransientMessage('Could not save state.'); + } _closeOverlay(); } @@ -512,47 +865,98 @@ class _GameEmulatorScreenState extends State final games = _client.gamesApi; if (games != null) { try { - final bytes = await games.getSave(gameStateKey(widget.gameId, widget.core)); + final bytes = await loadGameStateWithMigration( + games, + widget.gameId, + widget.core, + forceEmulatorJs: true, + ); if (bytes != null && bytes.isNotEmpty) { final b64 = base64.encode(bytes); await _controller?.evaluateJavascript( - source: "window.moonfinLoadState && window.moonfinLoadState('$b64');", + source: + "window.moonfinLoadState && window.moonfinLoadState('$b64');", ); } - } catch (_) {} + } catch (_) { + _showTransientMessage('Could not load state.'); + } } _closeOverlay(); } - void _restartAction() { - _controller?.evaluateJavascript( - source: 'window.moonfinRestart && window.moonfinRestart();'); + Future _restartAction() async { + try { + // Awaited (unlike the other fire-and-forget evaluateJavascript calls in + // this file) so a rejected WebView call is actually caught here instead + // of surfacing as an unhandled async error. + await _controller?.evaluateJavascript( + source: 'window.moonfinRestart && window.moonfinRestart();', + ); + } catch (_) { + _showTransientMessage('Could not restart.'); + } _closeOverlay(); } void _toggleFastForward() { setState(() => _fastForward = !_fastForward); _controller?.evaluateJavascript( - source: 'window.moonfinFastForward && window.moonfinFastForward($_fastForward);', + source: + 'window.moonfinFastForward && window.moonfinFastForward($_fastForward);', ); } /// Pulls the current save state out of EmulatorJS and PUTs it to the server. + /// + /// Errors are allowed to propagate: `_persistOnExit` (the exit-time caller) + /// already wraps this in its own try/timeout, and `_saveAction` (the menu + /// caller) needs to see failures so it can surface them instead of silently + /// looking like a successful save. Future _saveState() async { final controller = _controller; final games = _client.gamesApi; - if (controller == null || games == null || !_emulatorReady) return; + // Every one of these is a silent "no save happened", and the user is told + // the opposite by the exit confirmation. Say which one it was: a save state + // that never lands is invisible until someone goes looking for it on the + // server, which is exactly how this went unnoticed. + if (controller == null || games == null || !_emulatorReady) { + debugPrint( + '[GameEmulatorScreen] Save state skipped: ' + 'controller=${controller != null}, api=${games != null}, ' + 'emulatorReady=$_emulatorReady', + ); + return; + } + Object? value; try { final result = await controller.callAsyncJavaScript( - functionBody: 'return window.moonfinGetState ? window.moonfinGetState() : null;', + functionBody: + 'return window.moonfinGetState ? window.moonfinGetState() : null;', ); - final value = result?.value; - if (value is String && value.isNotEmpty) { - final bytes = base64.decode(value); - await games.putSave(gameStateKey(widget.gameId, widget.core), bytes); - } - } catch (_) {} + value = result?.value; + } catch (error) { + // callAsyncJavaScript is not supported on every platform this screen + // runs on, and a throw here would otherwise be swallowed whole by + // _persistOnExit's catch. + debugPrint('[GameEmulatorScreen] Save state read failed: $error'); + return; + } + + if (value is! String || value.isEmpty) { + debugPrint( + '[GameEmulatorScreen] Save state empty; nothing persisted ' + '(moonfinGetState returned ${value == null ? 'null' : value.runtimeType})', + ); + return; + } + final bytes = base64.decode(value); + await games.putSave( + gameStateKey(widget.gameId, widget.core, forceEmulatorJs: true), + bytes, + ); + debugPrint('[GameEmulatorScreen] Save state persisted: ${bytes.length} B'); } /// Reads EmulatorJS's settings out of the WebView and syncs them to the server (per user). @@ -562,7 +966,8 @@ class _GameEmulatorScreenState extends State if (controller == null || games == null) return; try { final result = await controller.callAsyncJavaScript( - functionBody: 'return window.moonfinGetSettings ? window.moonfinGetSettings() : null;', + functionBody: + 'return window.moonfinGetSettings ? window.moonfinGetSettings() : null;', ); final value = result?.value; if (value is String && value.isNotEmpty) { @@ -571,20 +976,28 @@ class _GameEmulatorScreenState extends State } catch (_) {} } + /// Reports a failure that ends the session. + /// + /// Setting [_error] is what removes the WebView from the tree, so this is + /// also how a dead or wedged emulator surface stops holding input focus. + /// Only the first failure is kept: a renderer death tends to be followed by + /// resource errors for the same cause, and the first one is the useful one. + void _setFatalError(String message) { + if (!mounted || _error != null) return; + setState(() => _error = message); + } + Future _exit() async { - if (_saving) return; - _saving = true; + if (_exiting) return; + _exiting = true; // The save reads state back out of the WebView, and on Windows and web that // round-trip can stall on a large PSP state. Give it a few seconds and leave // anyway, otherwise the WebView stays on screen and swallows every input. try { await _persistOnExit().timeout(const Duration(seconds: 3)); - } catch (_) { - } finally { - _saving = false; - } + } catch (_) {} await _restoreSystemUi(); - await WakelockPlus.disable(); + _releaseScreensaverBlock(); if (mounted) context.pop(); } @@ -601,6 +1014,8 @@ class _GameEmulatorScreenState extends State @override void dispose() { + _releaseGameplayArtworkBlock(); + _releaseScreensaverBlock(); releaseGameAudio(); _comboTimer?.cancel(); _settingsScroll.dispose(); @@ -608,12 +1023,12 @@ class _GameEmulatorScreenState extends State _pickerScroll.dispose(); if (PlatformDetection.isAndroid) { AndroidGamepadChannel.setGameActive(false); - AndroidGamepadChannel.setButtonHandler(null); + AndroidGamepadChannel.setEmulatorControlsActive(false); + AndroidGamepadChannel.setEmulatorInputHandler(null); } GamepadSuppressor.pop(); // Best-effort restore if disposed without going through _exit (e.g. system pop). _restoreSystemUi(); - WakelockPlus.disable(); super.dispose(); } @@ -624,12 +1039,7 @@ class _GameEmulatorScreenState extends State canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; - // Back / Esc toggles the menu (a remote with no Start+Select reaches it this way). - if (_overlayOpen) { - _closeOverlay(); - } else { - _openOverlay(); - } + _backOneLevel(); }, child: Scaffold( backgroundColor: Colors.black, @@ -679,10 +1089,47 @@ class _GameEmulatorScreenState extends State callback: _onPlayerMessage, ); }, - onReceivedServerTrustAuthRequest: gAllowSelfSignedCertificates - ? (controller, challenge) async => ServerTrustAuthResponse( - action: ServerTrustAuthResponseAction.PROCEED, - ) + // The renderer is a separate process and can be killed out + // from under us -- reliably so on memory-constrained TV + // boxes running a heavy core like N64. Nothing surfaced + // that before: the surface simply froze mid-load (a stuck + // "Download Game Data 33%"), kept input focus, and left no + // way out but killing the app. Reporting it swaps the dead + // WebView for the error branch above, which releases focus + // and makes the overlay reachable again. + onRenderProcessGone: (controller, detail) { + _setFatalError( + detail.didCrash == true + ? 'The emulator stopped unexpectedly. This game may ' + 'be too demanding for this device.' + : 'The emulator ran out of memory on this device. ' + 'Try a less demanding system, or close other ' + 'apps and retry.', + ); + }, + onReceivedError: (controller, request, error) { + // Sub-resource failures are EmulatorJS's business; only a + // main-frame failure means the player itself never came up. + if (request.isForMainFrame == true) { + _setFatalError( + 'Could not load the emulator. (${error.description})', + ); + } + }, + onReceivedHttpError: (controller, request, response) { + if (request.isForMainFrame == true) { + _setFatalError( + 'Could not load the emulator. ' + '(HTTP ${response.statusCode})', + ); + } + }, + onReceivedServerTrustAuthRequest: + gAllowSelfSignedCertificates + ? (controller, challenge) async => + ServerTrustAuthResponse( + action: ServerTrustAuthResponseAction.PROCEED, + ) : null, ), ), @@ -693,13 +1140,17 @@ class _GameEmulatorScreenState extends State top: 8, left: 8, child: SafeArea( - child: Material( - color: Colors.black54, - shape: const CircleBorder(), - child: IconButton( - icon: const Icon(Icons.menu, color: Colors.white), - tooltip: _l10n?.gameMenu ?? 'Menu', - onPressed: _openOverlay, + // On Web the emulator is an iframe platform view, which would + // otherwise swallow clicks intended for this Flutter control. + child: PointerInterceptor( + child: Material( + color: Colors.black54, + shape: const CircleBorder(), + child: IconButton( + icon: const Icon(Icons.menu, color: Colors.white), + tooltip: _l10n?.gameMenu ?? 'Menu', + onPressed: _openOverlay, + ), ), ), ), @@ -722,32 +1173,39 @@ class _GameEmulatorScreenState extends State required Widget child, }) { return Positioned.fill( - child: GestureDetector( - onTap: onDismiss, - child: ColoredBox( - color: Colors.black.withValues(alpha: 0.58), - child: SafeArea( - child: Center( - child: GestureDetector( - onTap: () {}, - child: LayoutBuilder( - builder: (context, constraints) { - // Never taller than the viewport, so the panel's list scrolls instead of - // overflowing on short screens. - final available = constraints.maxHeight - 24; - final cap = (maxHeight == null || maxHeight > available) - ? available - : maxHeight; - return ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxWidth, maxHeight: cap), - child: adaptiveGlass( - context: context, - fallbackColor: const Color(0xE6141A22), - cornerRadius: 18, - child: child, - ), - ); - }, + // Intercept the platform view across the complete modal, not only its + // buttons, so scrim taps and every nested control work on Web and iOS. + child: PointerInterceptor( + child: GestureDetector( + onTap: onDismiss, + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.58), + child: SafeArea( + child: Center( + child: GestureDetector( + onTap: () {}, + child: LayoutBuilder( + builder: (context, constraints) { + // Never taller than the viewport, so the panel's list scrolls instead of + // overflowing on short screens. + final available = constraints.maxHeight - 24; + final cap = (maxHeight == null || maxHeight > available) + ? available + : maxHeight; + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: cap, + ), + child: adaptiveGlass( + context: context, + fallbackColor: const Color(0xE6141A22), + cornerRadius: 18, + child: child, + ), + ); + }, + ), ), ), ), @@ -781,8 +1239,27 @@ class _GameEmulatorScreenState extends State ), ), GestureDetector( + behavior: HitTestBehavior.opaque, onTap: _closeSettings, - child: const Icon(Icons.close, color: Colors.white54, size: 20), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: 44, + height: 44, + decoration: BoxDecoration( + color: _settingsSelected < 0 + ? const Color(0xFF3F8CFF) + : Colors.transparent, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.close, + color: _settingsSelected < 0 + ? Colors.white + : Colors.white54, + size: 22, + semanticLabel: _l10n?.close ?? 'Close', + ), + ), ), ], ), @@ -793,8 +1270,12 @@ class _GameEmulatorScreenState extends State ? Padding( padding: const EdgeInsets.all(24), child: Text( - _l10n?.gameNoCoreOptions ?? 'This core has no adjustable options.', - style: const TextStyle(color: Colors.white54, fontSize: 13), + _l10n?.gameNoCoreOptions ?? + 'This core has no adjustable options.', + style: const TextStyle( + color: Colors.white54, + fontSize: 13, + ), textAlign: TextAlign.center, ), ) @@ -836,7 +1317,11 @@ class _GameEmulatorScreenState extends State width: 40, height: 40, child: Center( - child: Icon(Icons.arrow_back, color: Colors.white, size: 22), + child: Icon( + Icons.arrow_back, + color: Colors.white, + size: 22, + ), ), ), ), @@ -932,9 +1417,11 @@ class _GameEmulatorScreenState extends State style: const TextStyle(color: Colors.white, fontSize: 14), ), ), - Icon(Icons.chevron_left, - size: 18, - color: option.currentIndex > 0 ? Colors.white54 : Colors.white12), + Icon( + Icons.chevron_left, + size: 18, + color: option.currentIndex > 0 ? Colors.white54 : Colors.white12, + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( @@ -942,11 +1429,13 @@ class _GameEmulatorScreenState extends State style: const TextStyle(color: Colors.white, fontSize: 13), ), ), - Icon(Icons.chevron_right, - size: 18, - color: option.currentIndex < option.choices.length - 1 - ? Colors.white54 - : Colors.white12), + Icon( + Icons.chevron_right, + size: 18, + color: option.currentIndex < option.choices.length - 1 + ? Colors.white54 + : Colors.white12, + ), ], ), ), @@ -1017,8 +1506,10 @@ class _GameEmulatorScreenState extends State ), ), const SizedBox(height: 2), - Text(_l10n?.gamePaused ?? 'Paused', - style: const TextStyle(color: Colors.white54, fontSize: 12)), + Text( + _l10n?.gamePaused ?? 'Paused', + style: const TextStyle(color: Colors.white54, fontSize: 12), + ), ], ), ), @@ -1030,7 +1521,8 @@ class _GameEmulatorScreenState extends State itemExtent: _rowExtent, padding: const EdgeInsets.symmetric(vertical: 6), itemCount: items.length, - itemBuilder: (context, i) => _overlayRow(items[i], i == selected, i), + itemBuilder: (context, i) => + _overlayRow(items[i], i == selected, i), ), ), ], @@ -1081,8 +1573,13 @@ class _GameEmulatorScreenState extends State } class _OverlayItem { - const _OverlayItem(this.icon, this.label, this.trailing, this.onSelect, - {this.danger = false}); + const _OverlayItem( + this.icon, + this.label, + this.trailing, + this.onSelect, { + this.danger = false, + }); final IconData icon; final String label; @@ -1113,29 +1610,17 @@ class _GameOption { enum _Gp { up, down, left, right, confirm, cancel, start, select, other } -/// Native Android source: MainActivity forwards libretro RetroPad indices. -_Gp _semanticFromRetroPad(int index) { - switch (index) { - case 4: - return _Gp.up; - case 5: - return _Gp.down; - case 6: - return _Gp.left; - case 7: - return _Gp.right; - case 0: - return _Gp.confirm; - case 8: - return _Gp.cancel; - case 3: - return _Gp.start; - case 2: - return _Gp.select; - default: - return _Gp.other; - } -} +_Gp _semanticFromEmulatorLabel(String label) => switch (label) { + 'DPAD_UP' => _Gp.up, + 'DPAD_DOWN' => _Gp.down, + 'DPAD_LEFT' => _Gp.left, + 'DPAD_RIGHT' => _Gp.right, + 'BUTTON_2' => _Gp.confirm, + 'BUTTON_1' => _Gp.cancel, + 'START' => _Gp.start, + 'SELECT' => _Gp.select, + _ => _Gp.other, +}; /// JS-forwarded source: browser Gamepad API standard-mapping button indices. _Gp _semanticFromStandard(int index) { diff --git a/lib/ui/screens/playback/game_playback_ui.dart b/lib/ui/screens/playback/game_playback_ui.dart new file mode 100644 index 000000000..068d18bef --- /dev/null +++ b/lib/ui/screens/playback/game_playback_ui.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Wraps an index-driven game menu selection, including reverse navigation. +/// +/// Game overlays intentionally do not use Flutter focus because their game +/// surface may be a platform view or a native texture. Both playback backends +/// therefore drive their menus with an integer selection. +int wrapGamePlaybackMenuSelection(int current, int delta, int itemCount) { + if (itemCount <= 0) return 0; + return ((current + delta) % itemCount + itemCount) % itemCount; +} + +/// Scrolls an index-driven menu row into view without taking focus from the +/// active game surface. +void ensureGamePlaybackMenuSelectionVisible( + ScrollController controller, + int index, { + required double rowExtent, +}) { + if (!controller.hasClients) return; + final position = controller.position; + final top = index * rowExtent; + final bottom = top + rowExtent; + var target = position.pixels; + if (top < position.pixels) { + target = top; + } else if (bottom > position.pixels + position.viewportDimension) { + target = bottom - position.viewportDimension; + } + target = target.clamp(position.minScrollExtent, position.maxScrollExtent); + if (target == position.pixels) return; + controller.animateTo( + target, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + ); +} + +/// Displays a short non-blocking playback failure message. +void showGamePlaybackMessage(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 3)), + ); +} + +/// Shared full-screen system UI lifecycle for game players. +/// +/// The flags preserve each backend's platform policy: EmulatorJS hides system +/// UI on every host while native playback does so only with touch controls. +abstract final class GamePlaybackSystemUi { + static void enter({required bool immersive, required bool lockLandscape}) { + if (!immersive) return; + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + if (lockLandscape) { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } + } + + static Future restore({required bool immersive}) async { + if (!immersive) return; + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + await SystemChrome.setPreferredOrientations(DeviceOrientation.values); + } +} + +/// Releases decoded-image memory before a game takes over the screen. +/// +/// Both playback backends are memory-hungry in a way the rest of the app is +/// not: the native host allocates frame buffers plus a core's own heap, and the +/// EmulatorJS backend runs a whole WebAssembly emulator inside a WebView +/// renderer that lives in a *separate process* and is killed outright by the OS +/// when memory runs short. On a memory-constrained TV box that kill has been +/// observed mid-load, freezing the emulator on its loading screen with no way +/// out (buglog bug-033, a 3 GB Shield with ~46 MB free). +/// +/// Pausing artwork work -- which the activity gate already does -- stops new +/// allocations but frees nothing already resident. This drops what is held, so +/// the memory goes to the emulator instead. Nothing artwork-backed is visible +/// behind a full-screen game, so clearing live images costs only a re-decode of +/// whatever the user returns to, and buys headroom on exactly the low-end +/// hardware that needs it most. +void releaseImageMemoryForGameplay() { + final cache = PaintingBinding.instance.imageCache; + cache.clear(); + cache.clearLiveImages(); +} + +/// Detaches any lingering IME connection before a game takes over input. +/// +/// The game browse screens have a search field, and focusing it binds the +/// system IME to this app. That binding is not released when the field loses +/// focus -- Android keeps `mBoundToMethod=true` with `mServedView=null` -- so +/// every subsequent key, including the d-pad, is offered to the IME before it +/// reaches us. Once the IME's channel goes stale that send fails (EPIPE), and +/// the input dispatcher then holds the *next* key back, logging "Waiting to +/// send key ... because there are unprocessed events that may cause focus to +/// change". The symptom is exactly what it sounds like: presses that arrive +/// late or appear to stick. +/// +/// Nothing on a full-screen game surface wants a text connection, so drop it on +/// the way in. Flutter re-attaches on its own the next time a field is focused, +/// via the setClient it always sends. +void detachTextInputForGameplay() { + FocusManager.instance.primaryFocus?.unfocus(); + SystemChannels.textInput.invokeMethod('TextInput.hide'); + SystemChannels.textInput.invokeMethod('TextInput.clearClient'); +} diff --git a/lib/ui/screens/playback/native_game_player_screen.dart b/lib/ui/screens/playback/native_game_player_screen.dart index fbf48b5b0..94950310d 100644 --- a/lib/ui/screens/playback/native_game_player_screen.dart +++ b/lib/ui/screens/playback/native_game_player_screen.dart @@ -1,23 +1,31 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:archive/archive_io.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:gamepads/gamepads.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; +import 'package:path/path.dart' as p; import 'package:jellyfin_preference/jellyfin_preference.dart'; import 'package:server_core/server_core.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; import '../../../data/services/core_download_service.dart'; import '../../../l10n/app_localizations.dart'; +import '../../../data/services/retro_artwork/retro_artwork_activity_gate.dart'; import '../../../playback/native_game_player.dart'; import '../../../util/game_cores.dart'; import '../../../util/game_storage.dart'; +import '../../../util/native_controller_mapping.dart'; import '../../../util/platform_detection.dart'; +import '../../../util/focus/gamepad/android_gamepad_channel.dart'; import '../../../util/focus/gamepad/gamepad_suppressor.dart'; +import '../../screensaver/screensaver_controller.dart'; +import 'game_playback_ui.dart'; +import 'native_controller_mapping_screen.dart'; import 'game_audio_owner.dart'; /// Native game player: the libretro core runs in the runner and renders into a @@ -32,6 +40,7 @@ class NativeGamePlayerScreen extends StatefulWidget { required this.core, this.gameName, this.startFresh = false, + @visibleForTesting this.player, }); final String libraryId; @@ -40,6 +49,12 @@ class NativeGamePlayerScreen extends StatefulWidget { final String? gameName; final bool startFresh; + /// Test-only seam: a fake [NativeGamePlayer] widget tests can drive + /// through load/event lifecycles without a native runner. Always null in + /// production, where [NativeGamePlayer.create] picks the platform bridge. + @visibleForTesting + final NativeGamePlayer? player; + @override State createState() => _NativeGamePlayerScreenState(); } @@ -47,7 +62,7 @@ class NativeGamePlayerScreen extends StatefulWidget { class _NativeGamePlayerScreenState extends State with GameAudioOwner { final MediaServerClient _client = GetIt.instance(); - final NativeGamePlayer _player = NativeGamePlayer.create(); + late final NativeGamePlayer _player; late final CoreDownloadService _cores = CoreDownloadService(GetIt.instance()); @@ -62,12 +77,21 @@ class _NativeGamePlayerScreenState extends State double _aspect = 4 / 3; int _controllers = 1; bool _exiting = false; + // True once the native session has been told to stop. Separate from + // _exiting so a fatal error can tear the core down without also + // suppressing the route pop that the user still needs. + bool _sessionStopped = false; + RetroArtworkActivityGate? _artworkActivityGate; + bool _holdsGameplayArtworkBlock = false; + ScreensaverController? _screensaverController; StreamSubscription>? _events; // In-game overlay, opened with the Menu button and driven by the same // controller (mirrored button events) or the Siri remote (remote presses). bool _overlayOpen = false; bool _settingsOpen = false; + bool _controllerMappingOpen = false; + bool _confirmingExit = false; int _selected = 0; int _settingsSelected = 0; int _fastForward = 1; @@ -85,6 +109,10 @@ class _NativeGamePlayerScreenState extends State final ScrollController _overlayScroll = ScrollController(); final ScrollController _settingsScroll = ScrollController(); final ScrollController _pickerScroll = ScrollController(); + final GlobalKey _controllerMappingKey = + GlobalKey(); + List _controllerDevices = const []; + Map _controllerMappings = const {}; // Controller Start is deferred so it can double as the menu gesture: a quick // press reaches the game on release, holding it opens the overlay. @@ -138,24 +166,73 @@ class _NativeGamePlayerScreenState extends State @override void initState() { super.initState(); - WakelockPlus.enable(); + _player = widget.player ?? NativeGamePlayer.create(); + _acquireGameplayArtworkBlock(); + _acquireScreensaverBlock(); _enterImmersive(); + // A game owns every key from here; a stale IME binding from the browse + // screen's search field would otherwise sit in front of the d-pad. + detachTextInputForGameplay(); // The pad belongs to the libretro core while a game is running, so UI level // pad navigation stays suppressed for the lifetime of this screen. GamepadSuppressor.push(); claimGameAudio(); - _events = _player.events.listen(_onEvent); + _events = _player.events.listen( + _onEvent, + onError: (Object error) => _setFatalError('Playback error: $error'), + ); if (_readsGamepadsInDart) { _gamepadEvents = Gamepads.normalizedEvents.listen(_onGamepadEvent); } _prepare(); } + void _acquireGameplayArtworkBlock() { + if (_holdsGameplayArtworkBlock || + !GetIt.instance.isRegistered()) { + return; + } + _artworkActivityGate = GetIt.instance(); + _holdsGameplayArtworkBlock = true; + _artworkActivityGate!.setGameplayActive(true); + // The gate stops new artwork work but frees none of what is already + // decoded, and the core about to load allocates its own heap and frame + // buffers on top of whatever this app is still holding. + releaseImageMemoryForGameplay(); + } + + void _releaseGameplayArtworkBlock() { + if (!_holdsGameplayArtworkBlock) return; + _holdsGameplayArtworkBlock = false; + _artworkActivityGate?.setGameplayActive(false); + } + + // The screensaver controller owns the wake lock, so marking playback active + // does both jobs at once: the display stays awake, and the idle screensaver + // stays disarmed. Taking the wake lock directly leaves the controller + // believing the app is idle, and the screensaver then draws itself over a + // running game -- with no way out, because gameplay keys are consumed + // natively and never reach the dismiss handler in Flutter's key pipeline. + void _acquireScreensaverBlock() { + if (!GetIt.instance.isRegistered()) return; + _screensaverController = GetIt.instance(); + _screensaverController!.setPlaybackActive(true); + } + + void _releaseScreensaverBlock() { + final controller = _screensaverController; + if (controller == null) return; + _screensaverController = null; + controller.setPlaybackActive(false); + } + @override Future pauseForAudioClaim() => _player.pause(); @override void dispose() { + _releaseGameplayArtworkBlock(); + _releaseScreensaverBlock(); releaseGameAudio(); _events?.cancel(); _gamepadEvents?.cancel(); @@ -165,10 +242,16 @@ class _NativeGamePlayerScreenState extends State _settingsScroll.dispose(); _pickerScroll.dispose(); GamepadSuppressor.pop(); - WakelockPlus.disable(); // Best-effort restore if disposed without going through an exit path. unawaited(_restoreSystemUi()); - unawaited(_player.stop()); + // _exit() and _backOut() already await/queue their own stop() before + // popping the route, which is what tears this screen down in the normal + // case. Calling stop() again here would be a second, redundant teardown + // of the single per-process libretro session -- and on the native side, a + // save-state that lands just as a stop() call arrives can hang forever, + // so every extra unawaited stop() widens that race. Only fire one here + // when neither exit path already did. + if (!_sessionStopped) unawaited(_player.stop()); super.dispose(); } @@ -198,20 +281,52 @@ class _NativeGamePlayerScreenState extends State case 'button': final index = (event['index'] as num?)?.toInt() ?? -1; final pressed = event['pressed'] as bool? ?? false; - if (index == 3 && !PlatformDetection.isAppleTV) { + if (index == 3 && + !PlatformDetection.isAppleTV && + !_controllerMappingOpen) { _onStartButton(pressed); } else if (_overlayOpen && pressed) { - _nav(_navForButton(index)); + if (_controllerMappingOpen) { + _controllerMappingKey.currentState?.handleButton(index, pressed); + } else { + _nav(_navForButton(index)); + } } case 'coreMessage': _showCoreMessage(event['message']?.toString()); case 'error': - if (mounted) { - setState(() => _error = event['message']?.toString() ?? 'Error'); - } + _setFatalError(event['message']?.toString() ?? 'Error'); + } + } + + // Shared by the in-band 'error' event and the event stream's own onError: + // an unrecoverable native failure. When a texture is already live, this + // stops the core and drops it so the error message isn't shown floating + // over a frozen game. Guarded against _sessionStopped so it never races the + // stop() that the normal exit path already performs. + void _setFatalError(String message) { + if (!mounted) return; + _releaseGameplayArtworkBlock(); + final hadTexture = _textureId != null && !_sessionStopped; + setState(() { + _error = message; + if (hadTexture) _textureId = null; + }); + if (hadTexture) { + // Mark the session as torn down so dispose()'s stop() guard and any + // later _exit()/_backOut() do not stop() a second time. The route pop + // stays available: _exiting is deliberately left alone here, so the + // user is never stranded on the error screen behind a dead back button. + _sessionStopped = true; + unawaited(_player.stop()); } } + void _showTransientMessage(String message) { + if (!mounted) return; + showGamePlaybackMessage(context, message); + } + // Cores warn about things like missing system files while they run, so show // the latest one briefly over the game rather than dropping it. void _showCoreMessage(String? message) { @@ -226,13 +341,13 @@ class _NativeGamePlayerScreenState extends State // Mirrored RetroPad indices: 0=confirm (bottom face), 8=cancel (east face), // 4=up, 5=down, 6=left, 7=right. String? _navForButton(int index) => const { - 4: 'up', - 5: 'down', - 6: 'left', - 7: 'right', - 0: 'confirm', - 8: 'cancel', - }[index]; + 4: 'up', + 5: 'down', + 6: 'left', + 7: 'right', + 0: 'confirm', + 8: 'cancel', + }[index]; // Controller Start: a quick press is pulsed to the game on release, holding // past the threshold opens the overlay, and while the overlay is open a press @@ -286,10 +401,29 @@ class _NativeGamePlayerScreenState extends State _stickMask = 0; _sendMask(); } - if (pressed) _nav(_navActionForGamepad(button)); + if (pressed) { + // The remapping panel is driven by RetroPad indices from the native + // 'button' event stream, which only Android and the Apple runners + // emit -- desktop runners consume setInput and send nothing back. So + // the panel has to be fed from here, or its rows would sit inert + // while the d-pad quietly moved the pause menu underneath it. + // + // No guard against the press that is being captured for a binding: + // handleButton already ignores everything while _capturing is set, + // which is what keeps the button the user is binding from also + // activating the row they are binding it to. + if (_controllerMappingOpen) { + final index = _retroPadIndexForGamepad(button); + if (index != null) { + _controllerMappingKey.currentState?.handleButton(index, true); + } + } else { + _nav(_navActionForGamepad(button)); + } + } return; } - final bit = _gamepadButtonToBit[button]; + final bit = _bitForGamepadButton(event.gamepadId, button); if (bit == null) return; _gamepadMask = pressed ? _gamepadMask | bit : _gamepadMask & ~bit; _sendMask(); @@ -312,6 +446,25 @@ class _NativeGamePlayerScreenState extends State _sendMask(); } + /// The RetroPad bit a physical button should set, honouring any custom + /// mapping saved for the controller that produced it. + /// + /// A button the user has not rebound keeps [_gamepadButtonToBit]'s default, + /// which matches how Android treats a partial mapping. That does mean a + /// custom binding can double up with a default one -- bind Y to RetroPad A + /// and the physical A still sends A as well -- but the alternative, silently + /// unbinding buttons the user never touched, makes a half-finished remap feel + /// like the pad has broken. + int? _bitForGamepadButton(String gamepadId, GamepadButton button) { + final mapping = _controllerMappings[desktopControllerDeviceId(gamepadId)]; + final code = desktopGamepadButtonCodes[button]; + if (mapping != null && code != null) { + final bound = mapping.keycodeToButton[code]; + if (bound != null) return 1 << bound.retroPadIndex; + } + return _gamepadButtonToBit[button]; + } + // Negative stick values map to the first bit, positive to the second. The Y // axis reports up as positive, so up is the positive bit there. void _setStickBits(int negativeBit, int positiveBit, double value) { @@ -324,18 +477,35 @@ class _NativeGamePlayerScreenState extends State } void _setTriggerBit(int bit, double value) { - _stickMask = value >= _gamepadDeadzone ? _stickMask | bit : _stickMask & ~bit; + _stickMask = value >= _gamepadDeadzone + ? _stickMask | bit + : _stickMask & ~bit; } + /// The RetroPad index [NativeControllerMappingScreen.handleButton] expects + /// for a physical button. Deliberately the raw button rather than the + /// remapped one: the panel is navigated with the pad as it physically is, + /// otherwise a half-finished remap could leave the user unable to reach the + /// row that would fix it. + int? _retroPadIndexForGamepad(GamepadButton button) => switch (button) { + GamepadButton.dpadUp => 4, + GamepadButton.dpadDown => 5, + GamepadButton.dpadLeft => 6, + GamepadButton.dpadRight => 7, + GamepadButton.a => 0, + GamepadButton.b => 8, + _ => null, + }; + String? _navActionForGamepad(GamepadButton button) => switch (button) { - GamepadButton.dpadUp => 'up', - GamepadButton.dpadDown => 'down', - GamepadButton.dpadLeft => 'left', - GamepadButton.dpadRight => 'right', - GamepadButton.a => 'confirm', - GamepadButton.b => 'cancel', - _ => null, - }; + GamepadButton.dpadUp => 'up', + GamepadButton.dpadDown => 'down', + GamepadButton.dpadLeft => 'left', + GamepadButton.dpadRight => 'right', + GamepadButton.a => 'confirm', + GamepadButton.b => 'cancel', + _ => null, + }; void _onRemotePress(String? key) => _nav(key == 'select' ? 'confirm' : key); @@ -378,6 +548,11 @@ class _NativeGamePlayerScreenState extends State return KeyEventResult.handled; } } + // Only the platforms that actually play with a keyboard turn keys into + // RetroPad bits. Android reads controllers natively and forwards them over + // the gamepad channel, so mapping them here as well would send every press + // to the core twice. + if (!usesKeyboardInput) return KeyEventResult.ignored; final bit = _keyToBit[event.logicalKey]; if (bit == null) return KeyEventResult.ignored; if (event is KeyDownEvent) { @@ -414,10 +589,16 @@ class _NativeGamePlayerScreenState extends State // Steps back one overlay level: the value picker returns to the settings // list, the settings list to the pause menu, and the pause menu resumes. void _overlayBack() { - if (_pickerOpen) { + // Back out of the confirmation before any other level, so a stray Back + // never falls through to something that ends the session. + if (_confirmingExit) { + _cancelExitConfirmation(); + } else if (_pickerOpen) { setState(() => _pickerOption = null); } else if (_settingsOpen) { setState(() => _settingsOpen = false); + } else if (_controllerMappingOpen) { + setState(() => _controllerMappingOpen = false); } else { _closeOverlay(); } @@ -426,38 +607,60 @@ class _NativeGamePlayerScreenState extends State Future _prepare() async { final games = _client.gamesApi; if (games == null) { + _releaseGameplayArtworkBlock(); setState(() => _error = 'This server does not support games.'); return; } final coreId = libretroCoreId(widget.core); - if (coreId == null) { + if (coreId == null || !nativeCanPlay(widget.core)) { + _releaseGameplayArtworkBlock(); setState(() => _error = 'This system is not supported yet.'); return; } + try { + // Isolated from the outer try below: this reads persisted, user-editable + // JSON, and a corrupt mapping must degrade to default controller + // mappings rather than turn a cosmetic remap problem into a hard + // "Could not start this game" failure for the whole screen. + try { + await _loadControllerMappings(games); + } catch (e) { + debugPrint( + '[NativeGamePlayerScreen] Ignoring bad controller mapping data: $e', + ); + _controllerDevices = const []; + _controllerMappings = const {}; + } - // tvOS and macOS bundle their cores, so the native side loads them from the - // app. Android, Windows, and Linux load a downloaded file. - String? corePath; - if (!bundlesGameCores) { - if (!supportsCoreDownloads) { - if (mounted) { - setState(() => _error = 'This system is not supported on this device.'); + // tvOS and macOS bundle their cores, so the native side loads them from + // the app. Android, Windows, and Linux load a downloaded file. + String? corePath; + if (!bundlesGameCores) { + if (!supportsCoreDownloads) { + _releaseGameplayArtworkBlock(); + if (mounted) { + setState( + () => _error = 'This system is not supported on this device.', + ); + } + return; } - return; - } - corePath = await installedCorePath(coreId); - if (corePath == null) { - if (mounted) { - setState(() => _error = - 'The core for this system is not installed. Add it in Settings > Playback > Emulator Cores.'); + corePath = await installedCorePath(coreId); + if (corePath == null) { + _releaseGameplayArtworkBlock(); + if (mounted) { + setState( + () => _error = + 'The core for this system is not installed. Add it in Settings > Playback > Emulator Cores.', + ); + } + return; } - return; } - } - try { final detail = await games.getGame(widget.libraryId, widget.gameId); if (detail == null || !mounted) { + _releaseGameplayArtworkBlock(); if (mounted) setState(() => _error = 'Game not found.'); return; } @@ -466,12 +669,33 @@ class _NativeGamePlayerScreenState extends State if (!await _installSupportFiles(coreId)) return; final systemDir = await GameStorage.systemDir(); final saveDir = await GameStorage.saveDir(); - final cacheDir = - await GameStorage.romDir(widget.libraryId, widget.gameId); + final cacheDir = await GameStorage.romDir( + widget.libraryId, + widget.gameId, + ); await GameStorage.writeMeta(cacheDir, detail.title, detail.system); + // Five awaited IO calls happened since the last mounted check above, and + // only one native libretro session can exist per process -- so backing + // out mid-extraction must not let a later step start a session on a + // torn-down screen. + if (!mounted) return; setState(() => _status = 'Downloading...'); - final romFile = File('${cacheDir.path}/${detail.fileName}'); + // The server names these files; a traversal or absolute path here means + // the server is hostile or compromised. Reject rather than sanitize + // (same decision as the native host's lh_load game_id guard) and + // surface it as a visible error instead of silently writing wherever + // the name points. + final String romFileName; + try { + romFileName = sanitizeDownloadFileName(detail.fileName); + } on FormatException catch (e) { + _setFatalError( + 'This game has an invalid file name and cannot be downloaded. (${e.message})', + ); + return; + } + final romFile = File(p.join(cacheDir.path, romFileName)); if (!await romFile.exists()) { await games.downloadRom( widget.libraryId, @@ -486,7 +710,16 @@ class _NativeGamePlayerScreenState extends State } for (final bios in detail.bios) { - final biosFile = File('${systemDir.path}/${bios.fileName}'); + final String biosFileName; + try { + biosFileName = sanitizeDownloadFileName(bios.fileName); + } on FormatException catch (e) { + _setFatalError( + 'A required BIOS file has an invalid name and cannot be downloaded. (${e.message})', + ); + return; + } + final biosFile = File(p.join(systemDir.path, biosFileName)); if (!await biosFile.exists()) { await games.downloadBios(widget.libraryId, bios.id, biosFile.path); } @@ -497,16 +730,27 @@ class _NativeGamePlayerScreenState extends State _status = 'Starting...'; _progress = null; }); - final contentPath = await _extractIfArchive(romFile, cacheDir); + final contentPath = await _extractIfArchive( + romFile, + cacheDir, + preserveArchive: isArcadeFamilyCore(widget.core), + ); if (contentPath == null) { + _releaseGameplayArtworkBlock(); if (mounted) { setState(() => _error = 'This archive format is not supported.'); } return; } - final settingsJson = - await _loadSettings(games, coreId).catchError((_) => null); + final settingsJson = await _loadSettings( + games, + coreId, + ).catchError((_) => null); + // Last check before starting the one-per-process native session: if the + // screen was unmounted while settings were loading, starting it now + // would leave a session running with nothing left to tear it down. + if (!mounted) return; final info = await _player.load( core: coreId, corePath: corePath, @@ -525,18 +769,20 @@ class _NativeGamePlayerScreenState extends State await _player.start(); if (!widget.startFresh) { - final save = await games.getSave(_stateKey); + final save = await loadGameStateWithMigration( + games, + widget.gameId, + widget.core, + ); if (save != null && save.isNotEmpty) { await _player.loadState(Uint8List.fromList(save)); } } if (mounted) setState(() => _textureId = info.textureId); } catch (e) { + _releaseGameplayArtworkBlock(); if (mounted) { - final message = e is PlatformException && e.code == 'core_missing' - ? 'The core for this system is not included in this build.' - : 'Could not start this game. ($e)'; - setState(() => _error = message); + setState(() => _error = _startFailureMessage(e)); } } } @@ -570,12 +816,51 @@ class _NativeGamePlayerScreenState extends State return mounted; } - /// Returns the playable content path: the file itself, or the ROM extracted - /// from a .zip next to it. 7z is not readable here yet. - Future _extractIfArchive(File file, Directory cacheDir) async { + /// The message shown when the native session fails to start. + /// + /// `load_failed` is deliberately not reported as an error condition the user + /// should try to interpret. It is the single code every native load failure + /// collapses into -- the core rejecting the ROM, and the core demanding a + /// renderer this host does not provide, arrive here identically -- so naming + /// a cause would be a guess. What every one of those cases has in common is + /// that the same game is very likely playable through the other backend, so + /// the message points at the control that switches it. The wording matches + /// the game detail screen's own labels so the user is looking for a string + /// that actually appears on screen. + /// + /// The most common trigger today is a hardware-rendered core: the host + /// answers RETRO_ENVIRONMENT_SET_HW_RENDER with false, and cores with no + /// software renderer (Nintendo 64's mupen64plus_next above all) fail their + /// content load outright. See buglog bug-032. + String _startFailureMessage(Object error) { + if (error is PlatformException) { + switch (error.code) { + case 'core_missing': + return 'The core for this system is not included in this build.'; + case 'load_failed': + return 'This game cannot be played with the native core. ' + 'Open the game\'s details screen and switch it to ' + '"EmulatorJS (WebView)", then try again.'; + } + } + return 'Could not start this game. ($error)'; + } + + /// Returns the playable content path: the file itself, the ROM extracted + /// from a .zip next to it, or (when [preserveArchive] is true) the zip + /// path unmodified. Arcade cores (FBNeo/MAME) identify a machine by the + /// zip's own name and expect every chip inside it, so extracting "the + /// largest file" like every other system does would destroy the set. + /// 7z is not readable here yet. + Future _extractIfArchive( + File file, + Directory cacheDir, { + bool preserveArchive = false, + }) async { final lower = file.path.toLowerCase(); if (lower.endsWith('.7z')) return null; if (!lower.endsWith('.zip')) return file.path; + if (preserveArchive) return file.path; final marker = File('${cacheDir.path}/.extracted'); if (await marker.exists()) { @@ -594,7 +879,24 @@ class _NativeGamePlayerScreenState extends State if (best == null || entry.size > best.size) best = entry; } if (best == null) return null; - final outPath = '${cacheDir.path}/${best.name.split('/').last}'; + // The archive came from the server, so its entry names are as untrusted + // as the download file names. Split on either separator before taking the + // last segment: the zip format specifies forward slashes, but nothing + // enforces it, and a hostile archive can use backslashes precisely to + // slip past a '/'-only split on Windows. Whatever survives still has to + // pass the same single-segment rejection. + final String entryName; + try { + entryName = sanitizeDownloadFileName( + best.name.split(RegExp(r'[/\\]')).last, + ); + } on FormatException { + _setFatalError( + 'The downloaded archive contains an unusable file name.', + ); + return null; + } + final outPath = p.join(cacheDir.path, entryName); final output = OutputFileStream(outPath); best.writeContent(output); await output.close(); @@ -606,8 +908,13 @@ class _NativeGamePlayerScreenState extends State } Future?> _loadSettings( - GamesApi games, String coreId) async { - final blob = await games.getSave('moonfin-native-$coreId', kind: 'settings'); + GamesApi games, + String coreId, + ) async { + final blob = await games.getSave( + 'moonfin-native-$coreId', + kind: 'settings', + ); if (blob == null || blob.isEmpty) return null; final text = String.fromCharCodes(blob); final map = {}; @@ -620,20 +927,62 @@ class _NativeGamePlayerScreenState extends State return map.isEmpty ? null : map; } - List<_OverlayAction> _actions() => [ - _OverlayAction('Resume', _closeOverlay), - _OverlayAction('Press Start', () => _pressButton(3)), - _OverlayAction('Press Select', () => _pressButton(2)), - _OverlayAction('Save state', _saveState), - _OverlayAction('Load state', _loadState), - _OverlayAction('Restart', _restart), - _OverlayAction( - _fastForward > 1 ? 'Fast-forward: On' : 'Fast-forward: Off', - _toggleFastForward, - ), - _OverlayAction('Emulator settings', _openSettings), - _OverlayAction('Exit', _exit), - ]; + // Exit is the one destructive action in this menu, and it is reachable by a + // single press from several input paths. Confirming it as a replacement + // action list rather than a dialog keeps it navigable by remote, gamepad and + // keyboard alike, because selection, wrapping and scrolling are all driven + // off this list. "Keep playing" is first so the default highlight is the + // safe choice. + List<_OverlayAction> _actions() => _confirmingExit + ? [ + _OverlayAction('Keep playing', _cancelExitConfirmation), + _OverlayAction('Exit game', _exit, danger: true), + ] + : _mainActions(); + + List<_OverlayAction> _mainActions() => [ + _OverlayAction('Resume', _closeOverlay), + _OverlayAction('Press Start', () => _pressButton(3)), + _OverlayAction('Press Select', () => _pressButton(2)), + _OverlayAction('Save state', _saveState), + _OverlayAction('Load state', _loadState), + _OverlayAction( + _fastForward > 1 ? 'Fast-forward: On' : 'Fast-forward: Off', + _toggleFastForward, + ), + _OverlayAction('Restart', _restart), + // Hidden when nothing was found to remap, which covers both the Apple + // platforms (no remapping at all) and a desktop session with no controller + // plugged in, where the panel would open onto an empty list. + if (_controllerDevices.isNotEmpty) + _OverlayAction('Controller mapping', _openControllerMapping), + _OverlayAction('Emulator settings', _openSettings), + _OverlayAction('Reset emulator settings', _resetEmulatorSettings), + _OverlayAction('Exit', _requestExit), + ]; + + /// Asks before ending a running session. + /// + /// Only while a game is actually running: on the loading or error screen + /// there is nothing to lose, and making the user confirm their way off a + /// failure message would be obstructive. + void _requestExit() { + if (_textureId == null || _error != null) { + _exit(); + return; + } + setState(() { + _confirmingExit = true; + _selected = 0; + }); + } + + void _cancelExitConfirmation() { + setState(() { + _confirmingExit = false; + _selected = 0; + }); + } void _toggleOverlay() { if (_textureId == null) return; @@ -645,6 +994,7 @@ class _NativeGamePlayerScreenState extends State _overlayOpen = true; _selected = 0; }); + unawaited(AndroidGamepadChannel.setOverlayOpen(true)); } } @@ -652,8 +1002,11 @@ class _NativeGamePlayerScreenState extends State setState(() { _overlayOpen = false; _settingsOpen = false; + _controllerMappingOpen = false; + _confirmingExit = false; _pickerOption = null; }); + unawaited(AndroidGamepadChannel.setOverlayOpen(false)); _player.resume(); } @@ -675,7 +1028,7 @@ class _NativeGamePlayerScreenState extends State controller = _overlayScroll; } if (count == 0) return; - final wrapped = ((current + delta) % count + count) % count; + final wrapped = wrapGamePlaybackMenuSelection(current, delta, count); setState(() { if (_pickerOpen) { _pickerSelected = wrapped; @@ -685,27 +1038,10 @@ class _NativeGamePlayerScreenState extends State _selected = wrapped; } }); - _ensureVisible(controller, wrapped); - } - - // Scrolls the selected row back into view, walking the list by whole rows. - void _ensureVisible(ScrollController controller, int index) { - if (!controller.hasClients) return; - final position = controller.position; - final top = index * _rowExtent; - final bottom = top + _rowExtent; - var target = position.pixels; - if (top < position.pixels) { - target = top; - } else if (bottom > position.pixels + position.viewportDimension) { - target = bottom - position.viewportDimension; - } - target = target.clamp(position.minScrollExtent, position.maxScrollExtent); - if (target == position.pixels) return; - controller.animateTo( - target, - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, + ensureGamePlaybackMenuSelectionVisible( + controller, + wrapped, + rowExtent: _rowExtent, ); } @@ -713,9 +1049,12 @@ class _NativeGamePlayerScreenState extends State if (_pickerOpen) { _applyPicker(); } else if (_settingsOpen) { - _openPicker(_settingsSelected); + if (_options.isNotEmpty && _settingsSelected < _options.length) { + _openPicker(_settingsSelected); + } } else { - _actions()[_selected].onSelect(); + final actions = _actions(); + if (_selected < actions.length) actions[_selected].onSelect(); } } @@ -737,11 +1076,18 @@ class _NativeGamePlayerScreenState extends State if (opt.choices.length < 2) return; setState(() { _pickerOption = optionIndex; - _pickerSelected = - opt.choices.indexOf(opt.current).clamp(0, opt.choices.length - 1); + _pickerSelected = opt.choices + .indexOf(opt.current) + .clamp(0, opt.choices.length - 1); }); WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _pickerOpen) _ensureVisible(_pickerScroll, _pickerSelected); + if (mounted && _pickerOpen) { + ensureGamePlaybackMenuSelectionVisible( + _pickerScroll, + _pickerSelected, + rowExtent: _rowExtent, + ); + } }); } @@ -773,8 +1119,10 @@ class _NativeGamePlayerScreenState extends State final games = _client.gamesApi; final coreId = libretroCoreId(widget.core); if (games == null || coreId == null || _options.isEmpty) return; - final blob = - _options.map((o) => '${o.id}=${o.current}').join('\n').codeUnits; + final blob = _options + .map((o) => '${o.id}=${o.current}') + .join('\n') + .codeUnits; try { await games.putSave('moonfin-native-$coreId', blob, kind: 'settings'); } on Exception { @@ -783,26 +1131,87 @@ class _NativeGamePlayerScreenState extends State } Future _saveState() async { - final games = _client.gamesApi; - final bytes = await _player.saveState(); - if (bytes != null && bytes.isNotEmpty && games != null) { - await games.putSave(_stateKey, bytes); + try { + final games = _client.gamesApi; + final bytes = await _player.saveState(); + if (bytes != null && bytes.isNotEmpty && games != null) { + await games.putSave(_stateKey, bytes); + } + } catch (_) { + _showTransientMessage('Could not save state.'); + } finally { + if (mounted) _closeOverlay(); } - _closeOverlay(); } Future _loadState() async { - final games = _client.gamesApi; - final save = await games?.getSave(_stateKey); - if (save != null && save.isNotEmpty) { - await _player.loadState(Uint8List.fromList(save)); + try { + final games = _client.gamesApi; + final save = games == null + ? null + : await loadGameStateWithMigration(games, widget.gameId, widget.core); + if (save != null && save.isNotEmpty) { + await _player.loadState(Uint8List.fromList(save)); + } + } catch (_) { + _showTransientMessage('Could not load state.'); + } finally { + if (mounted) _closeOverlay(); } - _closeOverlay(); } Future _restart() async { - await _player.restart(); - _closeOverlay(); + try { + // _applyOption persists in the background. Re-send and flush the + // visible values here so an immediate restart cannot race that + // best-effort write. + for (final option in _options) { + await _player.setOption(option.id, option.current); + } + await _persistOptions(); + await _player.restart(); + } on PlatformException catch (e) { + _showTransientMessage( + e.code == 'restart_unavailable' + ? 'Restart is not available for this core.' + : 'Could not restart.', + ); + } catch (_) { + _showTransientMessage('Could not restart.'); + } finally { + if (mounted) _closeOverlay(); + } + } + + // The first value in a legacy libretro option is its core-defined default. + // Restart immediately because many cores only read these during initialization. + Future _resetEmulatorSettings() async { + try { + final options = await _player.getOptions(); + if (options.isEmpty) return; + for (final option in options) { + await _player.setOption(option.id, option.choices.first); + } + if (!mounted) return; + setState(() { + _options = options + .map( + (option) => GameCoreOption( + id: option.id, + label: option.label, + current: option.choices.first, + choices: option.choices, + ), + ) + .toList(growable: false); + }); + await _persistOptions(); + await _player.restart(); + } catch (_) { + _showTransientMessage('Could not reset emulator settings.'); + } finally { + if (mounted) _closeOverlay(); + } } // Resume first so the running core samples the pulse, then send the button. @@ -818,7 +1227,17 @@ class _NativeGamePlayerScreenState extends State } Future _openSettings() async { - final options = await _player.getOptions(); + // Invoked as an unawaited VoidCallback from the overlay, and unlike its + // sibling action methods, getOptions() does not swallow its own errors -- + // a throwing core would otherwise surface as an unhandled async error + // with no feedback to the player. + List options; + try { + options = await _player.getOptions(); + } catch (_) { + if (mounted) _showTransientMessage('Could not load emulator settings.'); + return; + } if (!mounted) return; setState(() { _options = List.of(options); @@ -827,6 +1246,92 @@ class _NativeGamePlayerScreenState extends State }); } + /// The controllers that can be remapped on this platform. + /// + /// Android asks its gamepad channel, which is also what applies the mapping. + /// Windows and Linux ask the gamepads package, since on those platforms the + /// mapping is applied here in Dart. Apple platforms bind their buttons in + /// Swift and have no remapping, so they report nothing and the menu entry is + /// hidden. + Future> _remappableDevices() async { + if (PlatformDetection.isAndroid) { + final rawDevices = await AndroidGamepadChannel.getEmulatorGamepads(); + return rawDevices + .map(NativeControllerDevice.fromMap) + .where((device) => device.id.isNotEmpty) + .toList(growable: false); + } + if (!_readsGamepadsInDart) return const []; + final pads = await Gamepads.list(); + return pads + .where((pad) => pad.id.isNotEmpty) + .map( + (pad) => NativeControllerDevice( + id: desktopControllerDeviceId(pad.id), + name: pad.name.isEmpty ? 'Gamepad ${pad.id}' : pad.name, + ), + ) + .toList(growable: false); + } + + Future _loadControllerMappings(GamesApi games) async { + final devices = await _remappableDevices(); + if (devices.isEmpty) return; + final mappings = {}; + for (final device in devices) { + mappings[device.id] = await loadControllerMapping(games, device.id); + } + if (!mounted) return; + _controllerDevices = devices; + _controllerMappings = mappings; + await _syncControllerMappings(); + } + + String _controllerMappingsJson() => jsonEncode({ + for (final entry in _controllerMappings.entries) + entry.key: jsonDecode(entry.value.toJson()), + }); + + /// Pushes the mappings to whatever applies them. + /// + /// Only Android has a native side to tell: it filters KeyEvents before they + /// ever reach Dart. On Windows and Linux nothing needs pushing, because + /// [_bitForGamepadButton] consults [_controllerMappings] directly as each + /// event arrives. + Future _syncControllerMappings() async { + if (!PlatformDetection.isAndroid) return; + await AndroidGamepadChannel.setControllerMapping(_controllerMappingsJson()); + } + + Future _updateControllerMapping( + String deviceId, + NativeControllerMapping mapping, + ) async { + final games = _client.gamesApi; + if (games == null) return; + setState(() { + _controllerMappings = Map.unmodifiable({ + ..._controllerMappings, + deviceId: mapping, + }); + }); + await _syncControllerMappings(); + try { + await saveControllerMapping(games, deviceId, mapping); + } catch (_) { + // The mapping is active for this session even if the best-effort sync + // fails; the next successful change will persist the latest value. + } + } + + void _openControllerMapping() { + setState(() { + _controllerMappingOpen = true; + _settingsOpen = false; + _pickerOption = null; + }); + } + Future _exit() async { if (_exiting) return; _exiting = true; @@ -844,22 +1349,38 @@ class _NativeGamePlayerScreenState extends State .map((e) => '${e.key}=${e.value}') .join('\n') .codeUnits; - await games.putSave('moonfin-native-$coreId', blob, - kind: 'settings'); + await games.putSave('moonfin-native-$coreId', blob, kind: 'settings'); } } } catch (_) { // Exit must not be blocked by sync failures. } - await _player.stop(); - await _restoreSystemUi(); - if (mounted) context.pop(); + if (!_sessionStopped) { + _sessionStopped = true; + try { + await _player.stop().timeout(const Duration(seconds: 3)); + } catch (_) { + // The route must still be escapable when native teardown fails or stalls. + } + } + try { + await _restoreSystemUi(); + } finally { + if (mounted) context.pop(); + } } // Leaves the loading or error screen without the save-on-exit that a running - // game does. + // game does. Guarded the same way as _exit(): reachable both from the menu + // and from the system back gesture, and a second invocation (or a prior + // _setFatalError that already stopped the session) must not stop() again. void _backOut() { - unawaited(_player.stop()); + if (_exiting) return; + _exiting = true; + if (!_sessionStopped) { + _sessionStopped = true; + unawaited(_player.stop()); + } unawaited(_restoreSystemUi()); if (mounted) context.pop(); } @@ -867,23 +1388,25 @@ class _NativeGamePlayerScreenState extends State // Phones and tablets play full screen in landscape, the natural orientation // for the on-screen pad and most games. TV and desktop are left alone. void _enterImmersive() { - if (!usesOnScreenControls) return; - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); + GamePlaybackSystemUi.enter( + immersive: usesOnScreenControls, + lockLandscape: usesOnScreenControls, + ); } - Future _restoreSystemUi() async { - if (!usesOnScreenControls) return; - await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - await SystemChrome.setPreferredOrientations(DeviceOrientation.values); - } + Future _restoreSystemUi() => + GamePlaybackSystemUi.restore(immersive: usesOnScreenControls); @override Widget build(BuildContext context) { Widget scaffold = _buildScaffold(context); + // Keyboard-driven platforms only, deliberately. Putting this on Android as + // well briefly looked like the way to catch a USB keyboard's Escape, but it + // inserts the framework's key pipeline in front of every gameplay key that + // falls through from the native router -- and each of those then waits on a + // platform -> Dart -> platform round trip before Android considers the + // event handled, which shows up as input lag. Android catches Escape in + // NativePadInput instead, alongside Menu, and never involves Flutter. if (usesKeyboardInput) { scaffold = Focus(autofocus: true, onKeyEvent: _onKey, child: scaffold); } @@ -914,7 +1437,19 @@ class _NativeGamePlayerScreenState extends State Center( child: AspectRatio( aspectRatio: _aspect, - child: Texture(textureId: _textureId!), + // The core renders at its native resolution (a few hundred + // pixels per side) and this texture is stretched to the whole + // display. Texture defaults to FilterQuality.low, whose 2x2 + // bilinear tap turns every source pixel into a gradient at the + // 4-8x magnification a TV asks for, which is why native cores + // looked soft next to EmulatorJS. Point sampling keeps the + // pixel art crisp. Scaling is still non-integer here, so + // pixels land on uneven widths; an integer prescale in the + // host removes that separately. + child: Texture( + textureId: _textureId!, + filterQuality: FilterQuality.none, + ), ), ), if (_textureId == null && _error == null) @@ -924,10 +1459,7 @@ class _NativeGamePlayerScreenState extends State children: [ const CircularProgressIndicator(), const SizedBox(height: 20), - Text( - _status, - style: const TextStyle(color: Colors.white70), - ), + Text(_status, style: const TextStyle(color: Colors.white70)), if (_progress != null) ...[ const SizedBox(height: 12), SizedBox( @@ -1036,7 +1568,10 @@ class _NativeGamePlayerScreenState extends State ), alignment: Alignment.center, child: DefaultTextStyle( - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), child: label, ), ), @@ -1059,19 +1594,31 @@ class _NativeGamePlayerScreenState extends State children: [ Align( alignment: Alignment.topCenter, - child: _touchButton(1 << 4, const Icon(Icons.keyboard_arrow_up, color: white)), + child: _touchButton( + 1 << 4, + const Icon(Icons.keyboard_arrow_up, color: white), + ), ), Align( alignment: Alignment.bottomCenter, - child: _touchButton(1 << 5, const Icon(Icons.keyboard_arrow_down, color: white)), + child: _touchButton( + 1 << 5, + const Icon(Icons.keyboard_arrow_down, color: white), + ), ), Align( alignment: Alignment.centerLeft, - child: _touchButton(1 << 6, const Icon(Icons.keyboard_arrow_left, color: white)), + child: _touchButton( + 1 << 6, + const Icon(Icons.keyboard_arrow_left, color: white), + ), ), Align( alignment: Alignment.centerRight, - child: _touchButton(1 << 7, const Icon(Icons.keyboard_arrow_right, color: white)), + child: _touchButton( + 1 << 7, + const Icon(Icons.keyboard_arrow_right, color: white), + ), ), ], ), @@ -1085,10 +1632,22 @@ class _NativeGamePlayerScreenState extends State height: 168, child: Stack( children: [ - Align(alignment: Alignment.topCenter, child: _touchButton(1 << 9, const Text('X'))), - Align(alignment: Alignment.bottomCenter, child: _touchButton(1 << 0, const Text('B'))), - Align(alignment: Alignment.centerLeft, child: _touchButton(1 << 1, const Text('Y'))), - Align(alignment: Alignment.centerRight, child: _touchButton(1 << 8, const Text('A'))), + Align( + alignment: Alignment.topCenter, + child: _touchButton(1 << 9, const Text('X')), + ), + Align( + alignment: Alignment.bottomCenter, + child: _touchButton(1 << 0, const Text('B')), + ), + Align( + alignment: Alignment.centerLeft, + child: _touchButton(1 << 1, const Text('Y')), + ), + Align( + alignment: Alignment.centerRight, + child: _touchButton(1 << 8, const Text('A')), + ), ], ), ), @@ -1101,8 +1660,16 @@ class _NativeGamePlayerScreenState extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ - _touchButton(1 << 2, const Text('SEL', style: TextStyle(fontSize: 11)), size: 44), - _touchButton(1 << 3, const Text('START', style: TextStyle(fontSize: 10)), size: 44), + _touchButton( + 1 << 2, + const Text('SEL', style: TextStyle(fontSize: 11)), + size: 44, + ), + _touchButton( + 1 << 3, + const Text('START', style: TextStyle(fontSize: 10)), + size: 44, + ), ], ), ), @@ -1174,9 +1741,11 @@ class _NativeGamePlayerScreenState extends State Widget _buildOverlay() { final l10n = AppLocalizations.of(context); - final showBack = _settingsOpen || _pickerOpen; + final showBack = _settingsOpen || _pickerOpen || _controllerMappingOpen; final String title; - if (_pickerOpen) { + if (_controllerMappingOpen) { + title = 'Controller mapping'; + } else if (_pickerOpen) { title = _options[_pickerOption!].label; } else if (_settingsOpen) { title = l10n.gameEmulatorSettings; @@ -1221,8 +1790,11 @@ class _NativeGamePlayerScreenState extends State width: 44, height: 44, child: Center( - child: Icon(Icons.arrow_back, - color: Colors.white, size: 28), + child: Icon( + Icons.arrow_back, + color: Colors.white, + size: 28, + ), ), ), ) @@ -1254,6 +1826,15 @@ class _NativeGamePlayerScreenState extends State } Widget _buildOverlayBody(AppLocalizations l10n) { + if (_controllerMappingOpen) { + return NativeControllerMappingScreen( + key: _controllerMappingKey, + devices: _controllerDevices, + mappings: _controllerMappings, + onMappingChanged: _updateControllerMapping, + onClose: () => setState(() => _controllerMappingOpen = false), + ); + } if (_pickerOpen) { final opt = _options[_pickerOption!]; return Flexible( @@ -1305,26 +1886,49 @@ class _NativeGamePlayerScreenState extends State } final actions = _actions(); return Flexible( - child: ListView.builder( - key: const ValueKey('actions'), - controller: _overlayScroll, - shrinkWrap: true, - itemExtent: _rowExtent, - itemCount: actions.length, - itemBuilder: (context, i) => _overlayRow( - actions[i].label, - i == _selected, - () { - setState(() => _selected = i); - actions[i].onSelect(); - }, - ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_confirmingExit) + const Padding( + padding: EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Text( + 'Exit this game? Progress since the last save will be lost.', + style: TextStyle(color: Colors.white70, fontSize: 18), + ), + ), + Flexible( + child: ListView.builder( + key: const ValueKey('actions'), + controller: _overlayScroll, + shrinkWrap: true, + itemExtent: _rowExtent, + itemCount: actions.length, + itemBuilder: (context, i) => + _overlayRow(actions[i].label, i == _selected, () { + setState(() => _selected = i); + actions[i].onSelect(); + }, danger: actions[i].danger), + ), + ), + ], ), ); } - Widget _overlayRow(String label, bool selected, VoidCallback onTap, - {IconData? trailing}) { + Widget _overlayRow( + String label, + bool selected, + VoidCallback onTap, { + IconData? trailing, + bool danger = false, + }) { + // Selected rows invert to a white fill, so the warning tint only applies + // when unselected; on the highlight it would be unreadable. + final labelColor = selected + ? Colors.black + : (danger ? const Color(0xFFFF8A80) : Colors.white); return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, @@ -1343,15 +1947,15 @@ class _NativeGamePlayerScreenState extends State label, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle( - color: selected ? Colors.black : Colors.white, - fontSize: 22, - ), + style: TextStyle(color: labelColor, fontSize: 22), ), ), if (trailing != null) - Icon(trailing, - size: 22, color: selected ? Colors.black : Colors.white), + Icon( + trailing, + size: 22, + color: selected ? Colors.black : Colors.white, + ), ], ), ), @@ -1360,7 +1964,11 @@ class _NativeGamePlayerScreenState extends State } class _OverlayAction { - const _OverlayAction(this.label, this.onSelect); + const _OverlayAction(this.label, this.onSelect, {this.danger = false}); final String label; final VoidCallback onSelect; + + /// Marks an action that ends the session, so the row can read as the + /// consequential one rather than looking like every other menu entry. + final bool danger; } diff --git a/lib/ui/widgets/bounded_network_image.dart b/lib/ui/widgets/bounded_network_image.dart index 0893cf7d1..e48fc7331 100644 --- a/lib/ui/widgets/bounded_network_image.dart +++ b/lib/ui/widgets/bounded_network_image.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import '../theme/vibrance.dart'; import 'image_source.dart'; @@ -19,6 +20,7 @@ class BoundedNetworkImage extends StatelessWidget { final Widget Function(BuildContext context, String url, Object error)? errorBuilder; final VoidCallback? onLoadFinished; + final BaseCacheManager? cacheManager; /// Multiplier applied to the resolved width before clamping. Useful for /// blurred images where a low-resolution decode is acceptable. @@ -38,21 +40,27 @@ class BoundedNetworkImage extends StatelessWidget { this.fadeInDuration = Duration.zero, this.errorBuilder, this.onLoadFinished, + this.cacheManager, this.scale = 1.0, this.minWidth = 64, this.maxWidth = 1024, }); - static int _cacheWidthFor( + /// The decoded pixel width upstream uses for every bounded image: the painted + /// width in physical pixels, clamped. Public so the game artwork widgets -- + /// which decode from bytes rather than a URL and so cannot use this widget + /// itself -- size their decodes by the same rule instead of a constant. + static int cacheWidthFor( double layoutWidth, double devicePixelRatio, { double scale = 1.0, int minWidth = 64, int maxWidth = 1024, }) { - return (layoutWidth * devicePixelRatio * scale) - .round() - .clamp(minWidth, maxWidth); + return (layoutWidth * devicePixelRatio * scale).round().clamp( + minWidth, + maxWidth, + ); } static Future precache( @@ -64,7 +72,7 @@ class BoundedNetworkImage extends StatelessWidget { int maxWidth = 1024, }) { final dpr = MediaQuery.devicePixelRatioOf(context); - final cacheW = _cacheWidthFor( + final cacheW = cacheWidthFor( layoutWidth, dpr, scale: scale, @@ -96,7 +104,7 @@ class BoundedNetworkImage extends StatelessWidget { Widget _buildImage(BuildContext context, double dpr) { return LayoutBuilder( builder: (context, constraints) { - final cacheW = _cacheWidthFor( + final cacheW = cacheWidthFor( constraints.maxWidth, dpr, scale: scale, @@ -128,6 +136,7 @@ class BoundedNetworkImage extends StatelessWidget { } return CachedNetworkImage( imageUrl: imageUrl, + cacheManager: cacheManager, fit: fit, alignment: alignment, fadeInDuration: fadeInDuration, diff --git a/lib/ui/widgets/game/game_artwork_load_scheduler.dart b/lib/ui/widgets/game/game_artwork_load_scheduler.dart deleted file mode 100644 index f97fcdd92..000000000 --- a/lib/ui/widgets/game/game_artwork_load_scheduler.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'dart:collection'; - -import 'package:flutter/foundation.dart'; - -/// Returns a row-aligned artwork queue with visible items first, followed by -/// surrounding rows expanding outward above and below the viewport. -List gameArtworkLoadOrder({ - required int firstIndex, - required int lastIndexExclusive, - required int visibleFirstIndex, - required int visibleLastIndexExclusive, - required int crossAxisCount, - required int surroundingRows, -}) { - assert(crossAxisCount > 0); - assert(surroundingRows >= 0); - if (firstIndex >= lastIndexExclusive) return const []; - - final visibleStart = visibleFirstIndex - .clamp(firstIndex, lastIndexExclusive - 1) - .toInt(); - final visibleEnd = visibleLastIndexExclusive - .clamp(visibleStart + 1, lastIndexExclusive) - .toInt(); - final indexes = [ - for (var index = visibleStart; index < visibleEnd; index++) index, - ]; - - for (var distance = 1; distance <= surroundingRows; distance++) { - final aboveStart = visibleStart - distance * crossAxisCount; - if (aboveStart >= firstIndex) { - final aboveEnd = (aboveStart + crossAxisCount) - .clamp(firstIndex, visibleStart) - .toInt(); - for (var index = aboveStart; index < aboveEnd; index++) { - indexes.add(index); - } - } - - final belowStart = visibleEnd + (distance - 1) * crossAxisCount; - if (belowStart < lastIndexExclusive) { - final belowEnd = (belowStart + crossAxisCount) - .clamp(belowStart, lastIndexExclusive) - .toInt(); - for (var index = belowStart; index < belowEnd; index++) { - indexes.add(index); - } - } - } - return indexes; -} - -/// Keeps game artwork ahead of the cache manager's non-cancellable FIFO. -/// -/// Only a small active batch is submitted at once. Replacing the viewport -/// discards work that has not started, while callbacks from an older viewport -/// cannot advance the new queue. -class GameArtworkLoadScheduler extends ChangeNotifier { - GameArtworkLoadScheduler({this.maxConcurrent = 4, this.maxFinished = 400}) - : assert(maxConcurrent > 0), - assert(maxFinished > 0); - - final int maxConcurrent; - - /// Upper bound on remembered "already loaded" keys, so a long browse of a - /// large library does not grow this set without limit. Only keys no longer in - /// the viewport are evicted, so on-screen artwork is never dropped; a scrolled - /// -back item simply re-requests (served from the image cache). - final int maxFinished; - final Set _finished = {}; - final Map _active = {}; - final Queue _pending = Queue(); - Set _viewport = {}; - int _generation = 0; - - bool get hasViewport => _viewport.isNotEmpty; - - @visibleForTesting - int get finishedCount => _finished.length; - - bool isEnabled(String key) => - _viewport.contains(key) && - (_finished.contains(key) || _active.containsKey(key)); - - int? generationFor(String key) => _active[key]; - - void showViewport(Iterable keys, {String? priorityKey}) { - final viewport = LinkedHashSet.of(keys); - if (setEquals(_viewport, viewport)) return; - - _generation++; - _viewport = viewport; - _active.clear(); - _pending.clear(); - - if (priorityKey != null && - viewport.contains(priorityKey) && - !_finished.contains(priorityKey)) { - _pending.add(priorityKey); - } - for (final key in viewport) { - if (key != priorityKey && !_finished.contains(key)) { - _pending.add(key); - } - } - - _pump(); - notifyListeners(); - } - - void markFinished(String key, int generation) { - if (_active[key] != generation) return; - _active.remove(key); - _finished.add(key); - _pump(); - _trimFinished(); - notifyListeners(); - } - - /// Evicts the oldest finished keys that are no longer visible once the set - /// exceeds [maxFinished]. Iterating a plain [Set] yields insertion order, so - /// this drops the least-recently-finished off-screen entries first and never - /// an entry still in the viewport. - void _trimFinished() { - final overflow = _finished.length - maxFinished; - if (overflow <= 0) return; - final toEvict = []; - for (final key in _finished) { - if (_viewport.contains(key)) continue; - toEvict.add(key); - if (toEvict.length >= overflow) break; - } - _finished.removeAll(toEvict); - } - - void clearViewport() { - if (_viewport.isEmpty && _active.isEmpty && _pending.isEmpty) return; - _generation++; - _viewport = {}; - _active.clear(); - _pending.clear(); - notifyListeners(); - } - - void _pump() { - while (_active.length < maxConcurrent && _pending.isNotEmpty) { - final key = _pending.removeFirst(); - _active[key] = _generation; - } - } -} diff --git a/lib/ui/widgets/game/game_poster_card.dart b/lib/ui/widgets/game/game_poster_card.dart index c7c24705f..0baf71ce5 100644 --- a/lib/ui/widgets/game/game_poster_card.dart +++ b/lib/ui/widgets/game/game_poster_card.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:moonfin_design/moonfin_design.dart'; import '../../../util/game_library.dart'; +import '../../../util/game_artwork_cache.dart'; import '../../../util/focus/dpad_keys.dart'; import '../../../util/platform_detection.dart'; import '../bounded_network_image.dart'; @@ -21,7 +23,7 @@ import 'game_card_focus_frame.dart'; class GamePosterCard extends StatefulWidget { const GamePosterCard({ super.key, - required this.imageUrl, + this.imageUrl, required this.title, required this.fileName, required this.seed, @@ -41,6 +43,9 @@ class GamePosterCard extends StatefulWidget { this.loadArtwork = true, this.onArtworkLoadFinished, this.autoScroll = true, + this.cacheManager, + this.artwork, + this.onArtworkError, }); final String? imageUrl; @@ -63,6 +68,12 @@ class GamePosterCard extends StatefulWidget { final bool loadArtwork; final VoidCallback? onArtworkLoadFinished; final bool autoScroll; + final BaseCacheManager? cacheManager; + + /// A protocol-2, bytes-backed artwork widget supplied by the owning screen. + /// When present it replaces the legacy cache-manager URL path entirely. + final Widget? artwork; + final ValueChanged? onArtworkError; @override State createState() => _GamePosterCardState(); @@ -185,7 +196,8 @@ class _GamePosterCardState extends State { height: widget.width * 1.34, child: ClipRRect( borderRadius: borders.cardRadius, - child: url == null || !widget.loadArtwork + child: + !widget.loadArtwork || (url == null && widget.artwork == null) ? _Fallback(seed: widget.seed, iconSize: widget.width * 0.3) : Stack( fit: StackFit.expand, @@ -196,14 +208,22 @@ class _GamePosterCardState extends State { seed: widget.seed, iconSize: widget.width * 0.3, ), - BoundedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - maxWidth: 1024, - onLoadFinished: widget.onArtworkLoadFinished, - // The fallback underneath remains visible on error. - errorBuilder: (_, _, _) => const SizedBox.shrink(), - ), + if (widget.artwork != null) + widget.artwork! + else + BoundedNetworkImage( + imageUrl: url!, + cacheManager: + widget.cacheManager ?? gameArtworkCacheManager, + fit: BoxFit.cover, + maxWidth: 1024, + onLoadFinished: widget.onArtworkLoadFinished, + // The fallback underneath remains visible on error. + errorBuilder: (_, _, error) { + widget.onArtworkError?.call(error); + return const SizedBox.shrink(); + }, + ), ], ), ), diff --git a/lib/ui/widgets/game/game_poster_rail.dart b/lib/ui/widgets/game/game_poster_rail.dart index 69bc75539..2fbf3581e 100644 --- a/lib/ui/widgets/game/game_poster_rail.dart +++ b/lib/ui/widgets/game/game_poster_rail.dart @@ -1,19 +1,30 @@ +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart' + show HttpExceptionWithStatus; import 'package:get_it/get_it.dart'; import 'package:moonfin_design/moonfin_design.dart'; import 'package:server_core/server_core.dart'; import '../../../preference/user_preferences.dart'; -import '../../../util/game_library.dart'; +import '../../../util/game_artwork_cache.dart'; +import '../bounded_network_image.dart'; +import '../../../data/services/retro_artwork/retro_artwork_activity_gate.dart'; +import '../../../data/services/retro_artwork/retro_artwork_data_source.dart'; +import '../../../data/services/retro_artwork/retro_artwork_transport.dart'; import 'game_poster_card.dart'; +import 'retro_artwork_image.dart'; /// A titled horizontal row of game box art (one system's games, or a "more like this" rail). class GamePosterRail extends StatelessWidget { const GamePosterRail({ super.key, required this.title, - required this.libraryId, required this.games, + required this.artworkScope, + required this.artworkDataSource, + required this.retroArtworkTransport, + required this.retroArtworkActivityGate, required this.onTapGame, this.trailingCount, this.cardWidth = 108, @@ -22,9 +33,11 @@ class GamePosterRail extends StatelessWidget { final String title; - /// The library the games belong to, needed to ask the server for their art. - final String libraryId; final List games; + final String artworkScope; + final RetroArtworkDataSource? artworkDataSource; + final RetroArtworkTransport? retroArtworkTransport; + final RetroArtworkActivityGate? retroArtworkActivityGate; final void Function(GameSummary game) onTapGame; /// Optional count shown next to the title (e.g. games in a system). @@ -73,21 +86,71 @@ class GamePosterRail extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 20), itemCount: games.length, separatorBuilder: (_, _) => const SizedBox(width: 12), - itemBuilder: (context, i) => GamePosterCard( - imageUrl: gameThumbUrl(libraryId, games[i].id), - title: games[i].title, - fileName: games[i].fileName, - seed: games[i].id, - width: cardWidth, - autofocus: autofocusFirst && i == 0, - focusColor: focusColor, - cardFocusExpansion: cardFocusExpansion, - suppressFocusGlow: isNeon, - onTap: () => onTapGame(games[i]), - ), + itemBuilder: (context, i) { + final game = games[i]; + final reference = artworkDataSource?.imageFor( + game.id, + role: 'boxart', + ); + final source = reference?.source; + final transport = retroArtworkTransport; + final gate = retroArtworkActivityGate; + return GamePosterCard( + imageUrl: reference?.legacyUrl, + artwork: source == null || transport == null || gate == null + ? null + : RetroArtworkImage( + source: source, + transport: transport, + activityGate: gate, + // Decode to the pixels actually painted, the rule the + // rest of the app's bounded images already follow. + maxDecodeWidth: BoundedNetworkImage.cacheWidthFor( + cardWidth, + MediaQuery.devicePixelRatioOf(context), + ), + fit: BoxFit.cover, + onLoadFinished: () => artworkDataSource + ?.reportImageLoaded(game.id, role: 'boxart'), + errorBuilder: (_, error) { + artworkDataSource?.reportImageFailure( + game.id, + role: 'boxart', + statusCode: _artworkStatusCode(error), + ); + return const SizedBox.shrink(); + }, + ), + title: game.title, + fileName: game.fileName, + seed: game.id, + width: cardWidth, + autofocus: autofocusFirst && i == 0, + focusColor: focusColor, + cardFocusExpansion: cardFocusExpansion, + suppressFocusGlow: isNeon, + cacheManager: gameArtworkCacheManagerForScope(artworkScope), + loadArtwork: reference != null, + onArtworkLoadFinished: () => artworkDataSource + ?.reportImageLoaded(game.id, role: 'boxart'), + onArtworkError: (error) => + artworkDataSource?.reportImageFailure( + game.id, + role: 'boxart', + statusCode: _artworkStatusCode(error), + ), + onTap: () => onTapGame(game), + ); + }, ), ), ], ); } } + +int? _artworkStatusCode(Object error) { + if (error is DioException) return error.response?.statusCode; + if (error is HttpExceptionWithStatus) return error.statusCode; + return null; +} diff --git a/lib/ui/widgets/game/game_system_card.dart b/lib/ui/widgets/game/game_system_card.dart index e317adfee..a5559e160 100644 --- a/lib/ui/widgets/game/game_system_card.dart +++ b/lib/ui/widgets/game/game_system_card.dart @@ -3,21 +3,21 @@ import 'package:flutter/services.dart'; import 'package:moonfin_design/moonfin_design.dart'; import 'package:server_core/server_core.dart'; +import '../../../data/services/retro_artwork/retro_artwork_activity_gate.dart'; +import '../../../data/services/retro_artwork/retro_artwork_transport.dart'; import '../../../l10n/app_localizations.dart'; import '../../../util/game_library.dart'; import '../../../util/focus/dpad_keys.dart'; import '../../../util/platform_detection.dart'; -import '../bounded_network_image.dart'; import 'game_card_focus_frame.dart'; +import 'retro_artwork_image.dart'; /// A focusable, artwork-backed platform tile used at the root of a retro-game /// library. class GameSystemCard extends StatefulWidget { const GameSystemCard({ super.key, - required this.libraryId, required this.system, - required this.games, required this.gameCount, required this.onTap, this.autofocus = false, @@ -26,11 +26,13 @@ class GameSystemCard extends StatefulWidget { this.suppressFocusGlow = false, this.focusNode, this.onKeyEvent, + this.retroArtworkTransport, + this.retroArtworkActivityGate, + this.libraryId, + this.serverIdentity, }); - final String libraryId; final GameSystem system; - final List games; final int? gameCount; final VoidCallback onTap; final bool autofocus; @@ -39,6 +41,10 @@ class GameSystemCard extends StatefulWidget { final bool suppressFocusGlow; final FocusNode? focusNode; final FocusOnKeyEventCallback? onKeyEvent; + final RetroArtworkTransport? retroArtworkTransport; + final RetroArtworkActivityGate? retroArtworkActivityGate; + final String? libraryId; + final String? serverIdentity; @override State createState() => _GameSystemCardState(); @@ -74,9 +80,11 @@ class _GameSystemCardState extends State { fit: StackFit.expand, children: [ _SystemArtworkStrip( + system: widget.system, + transport: widget.retroArtworkTransport, + activityGate: widget.retroArtworkActivityGate, libraryId: widget.libraryId, - games: widget.games, - fallbackColor: seedColor, + serverIdentity: widget.serverIdentity, ), DecoratedBox( decoration: BoxDecoration( @@ -241,33 +249,43 @@ class _GameSystemCardState extends State { } } +/// Renders the four server-selected preview panels for a system card. +/// +/// Missing, pending, and failed descriptors remain deterministic local +/// placeholders. The client deliberately does not discover alternatives. class _SystemArtworkStrip extends StatelessWidget { const _SystemArtworkStrip({ + required this.system, + required this.transport, + required this.activityGate, required this.libraryId, - required this.games, - required this.fallbackColor, + required this.serverIdentity, }); - final String libraryId; - final List games; - final Color fallbackColor; + final GameSystem system; + final RetroArtworkTransport? transport; + final RetroArtworkActivityGate? activityGate; + final String? libraryId; + final String? serverIdentity; @override Widget build(BuildContext context) { - if (games.isEmpty) { - return ColoredBox( - color: Color.lerp(fallbackColor, AppColorScheme.background, 0.28)!, - ); - } - + final panels = system.previewArtwork?.panels ?? const []; return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - for (final game in games) + for (var index = 0; index < 4; index++) Expanded( - child: _SystemGameArtwork( - imageUrl: gameThumbUrl(libraryId, game.id), - fallbackColor: gameFallbackColor(game.id), + child: _SystemPreviewArtwork( + panel: index < panels.length ? panels[index] : null, + fallbackSeed: + index < panels.length && panels[index].gameId.isNotEmpty + ? panels[index].gameId + : '${system.id}:$index', + transport: transport, + activityGate: activityGate, + libraryId: libraryId, + serverIdentity: serverIdentity, ), ), ], @@ -275,31 +293,68 @@ class _SystemArtworkStrip extends StatelessWidget { } } -class _SystemGameArtwork extends StatelessWidget { - const _SystemGameArtwork({ - required this.imageUrl, - required this.fallbackColor, +class _SystemPreviewArtwork extends StatelessWidget { + const _SystemPreviewArtwork({ + required this.panel, + required this.fallbackSeed, + required this.transport, + required this.activityGate, + required this.libraryId, + required this.serverIdentity, }); - final String? imageUrl; - final Color fallbackColor; + final GameSystemPreviewPanel? panel; + final String fallbackSeed; + final RetroArtworkTransport? transport; + final RetroArtworkActivityGate? activityGate; + final String? libraryId; + final String? serverIdentity; @override Widget build(BuildContext context) { - final url = imageUrl; - if (url == null) return _fallback(); + final descriptor = panel?.artwork; + if (descriptor == null || !descriptor.isRenderable) return _fallback(); - return BoundedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - maxWidth: 320, - errorBuilder: (_, _, _) => _fallback(), - ); + // A revision-bearing preview is protocol 2. It must use the screen-owned + // cancellable transport rather than CachedNetworkImage's request queue. + RetroArtworkSource? source; + if (transport != null && + activityGate != null && + libraryId != null && + serverIdentity != null && + panel!.gameId.isNotEmpty) { + try { + source = RetroArtworkSource.fromDescriptor( + serverIdentity: serverIdentity!, + libraryId: libraryId!, + gameId: panel!.gameId, + role: 'boxart', + descriptor: descriptor, + ); + } on FormatException { + source = null; + } + } + if (source != null) { + return RetroArtworkImage( + source: source, + transport: transport!, + activityGate: activityGate!, + maxDecodeWidth: 320, + fit: BoxFit.cover, + errorBuilder: (_, _) => _fallback(), + ); + } + + // Preview panels exist only in protocol 2. Missing transport context or a + // malformed/unversioned descriptor is therefore a placeholder, never a + // fallback request through CachedNetworkImage. + return _fallback(); } Widget _fallback() { return ColoredBox( - color: fallbackColor, + color: gameFallbackColor(fallbackSeed), child: const Center( child: Icon(Icons.videogame_asset, color: Colors.white54, size: 26), ), diff --git a/lib/ui/widgets/game/retro_artwork_image.dart b/lib/ui/widgets/game/retro_artwork_image.dart new file mode 100644 index 000000000..783430e04 --- /dev/null +++ b/lib/ui/widgets/game/retro_artwork_image.dart @@ -0,0 +1,275 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; + +import '../../../data/services/retro_artwork/retro_artwork_activity_gate.dart'; +import '../../../data/services/retro_artwork/retro_artwork_transport.dart'; + +/// Protocol-2 artwork rendered from transport-owned compressed bytes. +/// +/// This deliberately bypasses CachedNetworkImage and flutter_cache_manager: +/// [RetroArtworkTransport] owns authenticated I/O, source replacement, and +/// compressed-byte residency. Flutter's image cache only receives the bounded +/// decoded image created below. +class RetroArtworkImage extends StatefulWidget { + const RetroArtworkImage({ + super.key, + required this.source, + required this.transport, + required this.activityGate, + required this.maxDecodeWidth, + this.fit = BoxFit.cover, + this.alignment = Alignment.center, + this.settleDelay = defaultSettleDelay, + this.onLoadFinished, + this.errorBuilder, + }); + + /// How long a card must stay on screen before it asks for its bytes. + /// + /// A fling, or flipping quickly through the alphabet, builds and destroys + /// rows in far less than this, and a card that is disposed first never issues + /// its request at all. Short enough to read as immediate when the grid comes + /// to rest, including for artwork already in the cache. + static const Duration defaultSettleDelay = Duration(milliseconds: 100); + + final RetroArtworkSource source; + final RetroArtworkTransport transport; + final RetroArtworkActivityGate activityGate; + final int maxDecodeWidth; + final Duration settleDelay; + final BoxFit fit; + final Alignment alignment; + final VoidCallback? onLoadFinished; + final Widget Function(BuildContext context, Object error)? errorBuilder; + + @override + State createState() => _RetroArtworkImageState(); +} + +class _RetroArtworkImageState extends State { + Timer? _settleTimer; + RetroArtworkActivityPermit? _providerPermit; + RetroArtworkActivityPermit? _decodePermit; + ImageProvider? _decodedProvider; + Object? _error; + int _epoch = 0; + bool _rebuildQueued = false; + + @override + void initState() { + super.initState(); + widget.activityGate.addListener(_onGateChanged); + // Registering the source is immediate: it makes this revision + // authoritative and evicts a superseded one. Only the transfer waits. + widget.transport.adoptSource(widget.source); + _scheduleStart(); + } + + /// Defers the transfer by [RetroArtworkImage.settleDelay]. + /// + /// The timer is unconditional -- it is cancelled only by dispose or by a + /// replacement that reschedules it -- so a card that stays on screen always + /// ends up loading. + void _scheduleStart() { + _settleTimer?.cancel(); + _settleTimer = null; + if (widget.settleDelay <= Duration.zero) { + _start(); + return; + } + _settleTimer = Timer(widget.settleDelay, () { + _settleTimer = null; + if (mounted) _start(); + }); + } + + @override + void didUpdateWidget(covariant RetroArtworkImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.activityGate != widget.activityGate) { + oldWidget.activityGate.removeListener(_onGateChanged); + widget.activityGate.addListener(_onGateChanged); + } + if (oldWidget.source != widget.source || + oldWidget.transport != widget.transport || + oldWidget.maxDecodeWidth != widget.maxDecodeWidth || + oldWidget.activityGate != widget.activityGate) { + // Flutter rebuilds this State immediately after didUpdateWidget. Calling + // setState here is redundant and is illegal when the parent is building. + _cancelAndEvict('Artwork widget was replaced', notify: false); + widget.transport.adoptSource(widget.source); + _scheduleStart(); + } + } + + void _start() { + if (!widget.activityGate.isOpen || + _providerPermit != null || + _decodedProvider != null) { + return; + } + _error = null; + final epoch = ++_epoch; + final permit = widget.activityGate.tryAcquire( + RetroArtworkActivityKind.provider, + ); + if (permit == null) return; + _providerPermit = permit; + permit.signal.addListener(_onCancelled); + unawaited(_load(epoch, permit)); + } + + Future _load( + int epoch, + RetroArtworkActivityPermit providerPermit, + ) async { + final result = await widget.transport.load(widget.source); + final current = + _isCurrent(epoch, providerPermit) && !providerPermit.signal.isCancelled; + if (!current || result.bytes == null) { + final failed = + current && result.outcome == RetroArtworkLoadOutcome.failed; + _releaseProvider(); + if (failed) { + _fail(result.error ?? StateError('Artwork transfer failed')); + } + return; + } + + final decodePermit = widget.activityGate.tryAcquire( + RetroArtworkActivityKind.decode, + ); + if (decodePermit == null || !_isCurrent(epoch, providerPermit)) { + decodePermit?.dispose(); + _releaseProvider(); + return; + } + _decodePermit = decodePermit; + decodePermit.signal.addListener(_onCancelled); + + // This is the Image.memory provider path written out so cancellation can + // evict the exact decoded key, including the bounded ResizeImage wrapper. + final provider = ResizeImage.resizeIfNeeded( + widget.maxDecodeWidth, + null, + MemoryImage(result.bytes!), + ); + if (!_isCurrent(epoch, providerPermit) || decodePermit.signal.isCancelled) { + PaintingBinding.instance.imageCache.evict(provider); + _releaseProvider(); + return; + } + if (!mounted) { + _releaseProvider(); + return; + } + _releaseProvider(); + setState(() { + _decodedProvider = provider; + }); + } + + bool _isCurrent(int epoch, RetroArtworkActivityPermit permit) => + mounted && + epoch == _epoch && + identical(_providerPermit, permit) && + !permit.signal.isCancelled; + + void _onCancelled() { + _cancelAndEvict('Artwork activity was cancelled'); + } + + void _onGateChanged() { + if (!mounted) return; + if (!widget.activityGate.isOpen) { + _cancelAndEvict('Artwork activity was blocked'); + return; + } + _start(); + } + + void _cancelAndEvict(Object reason, {bool notify = true}) { + ++_epoch; + _releaseProvider(reason: reason); + final decodePermit = _decodePermit; + _decodePermit = null; + decodePermit?.signal.removeListener(_onCancelled); + decodePermit?.cancel(reason); + decodePermit?.dispose(); + final provider = _decodedProvider; + _decodedProvider = null; + _error = null; + if (provider != null) { + PaintingBinding.instance.imageCache.evict(provider); + } + if (notify) _scheduleRebuild(); + } + + /// Cancellation can be signalled while an ancestor is building. Defer the + /// repaint so it never marks this descendant dirty during that build pass. + void _scheduleRebuild() { + if (!mounted || _rebuildQueued) return; + _rebuildQueued = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _rebuildQueued = false; + if (mounted) setState(() {}); + }); + } + + void _releaseDecode() { + final permit = _decodePermit; + _decodePermit = null; + permit?.signal.removeListener(_onCancelled); + permit?.dispose(); + } + + void _releaseProvider({Object? reason}) { + final permit = _providerPermit; + _providerPermit = null; + permit?.signal.removeListener(_onCancelled); + if (reason != null) permit?.cancel(reason); + permit?.dispose(); + } + + void _fail(Object error) { + _releaseDecode(); + if (!mounted) return; + setState(() => _error = error); + } + + @override + void dispose() { + _settleTimer?.cancel(); + widget.activityGate.removeListener(_onGateChanged); + _cancelAndEvict('Artwork widget was disposed', notify: false); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final error = _error; + if (error != null) { + return widget.errorBuilder?.call(context, error) ?? + const SizedBox.shrink(); + } + final provider = _decodedProvider; + if (provider == null) return const SizedBox.shrink(); + return Image( + image: provider, + fit: widget.fit, + alignment: widget.alignment, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (frame != null || wasSynchronouslyLoaded) { + _releaseDecode(); + widget.onLoadFinished?.call(); + } + return child; + }, + errorBuilder: (context, error, stackTrace) { + _releaseDecode(); + return widget.errorBuilder?.call(context, error) ?? + const SizedBox.shrink(); + }, + ); + } +} diff --git a/lib/util/game_artwork_cache.dart b/lib/util/game_artwork_cache.dart new file mode 100644 index 000000000..e1fce0be9 --- /dev/null +++ b/lib/util/game_artwork_cache.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; + +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +/// Prefix for per-system game-artwork caches. Sharing the global media cache +/// would let game browsing evict movie, TV, and music artwork. +const gameArtworkCacheKey = 'moonfin-game-artwork'; + +/// Game artwork deliberately has a dedicated global disk ceiling. It must not +/// consume the user-configured media image cache budget, which is reserved for +/// Movies, TV, and Music artwork. +const gameArtworkCacheBudgetBytes = 150 * 1024 * 1024; +const gameArtworkCacheMaxObjectsPerSystem = 900; + +/// A compact, path-safe cache key for one `libraryId/systemId` scope. +/// +/// Base64-url encoding avoids leaking directory separators into the cache +/// directory name while retaining a deterministic, collision-free mapping. +String gameArtworkCacheKeyForScope(String scope) { + final encoded = base64Url.encode(utf8.encode(scope)).replaceAll('=', ''); + return '$gameArtworkCacheKey-$encoded'; +} + +String gameArtworkScope(String libraryId, String systemId) => + '$libraryId/$systemId'; + +bool isGameArtworkCacheDirectoryName(String name) => + name == gameArtworkCacheKey || name.startsWith('$gameArtworkCacheKey-'); + +final Map _gameArtworkCacheManagers = + {}; + +/// Returns the cache dedicated to one game system. A system's art remains +/// available while the user filters or scrolls within it; inactive systems are +/// evicted as whole units only when the shared game-art budget needs room. +BaseCacheManager gameArtworkCacheManagerForScope(String scope) { + final cacheKey = gameArtworkCacheKeyForScope(scope); + return _gameArtworkCacheManagers.putIfAbsent( + cacheKey, + () => CacheManager( + Config( + cacheKey, + stalePeriod: const Duration(days: 14), + maxNrOfCacheObjects: gameArtworkCacheMaxObjectsPerSystem, + ), + ), + ); +} + +/// Clears a cache managed in this process without invalidating the manager's +/// own metadata database. Returns false for a cache created by an older app +/// session, where deleting its directory is safe. +Future clearLiveGameArtworkCache(String cacheKey) async { + final manager = _gameArtworkCacheManagers[cacheKey]; + if (manager == null) return false; + await manager.emptyCache(); + return true; +} + +/// Backward-compatible fallback for callers that do not know a system. New +/// game screens should use [gameArtworkCacheManagerForScope] instead. +final BaseCacheManager gameArtworkCacheManager = + gameArtworkCacheManagerForScope('_unscoped'); + +/// Systems-list tiles use a handful of preview thumbnails per system (up to +/// four) drawn from every system the user has ever browsed. Kept in its own +/// cache manager, with its own tiny budget, so the per-system sweep below +/// never touches it: revisiting the systems list must never re-download tile +/// art, even right after switching away from a system purges its own cache. +const gameSystemArtworkCacheKey = 'moonfin-game-systems'; + +/// Tens of systems times up to four preview thumbnails each is a small, +/// roughly fixed working set, so this budget is deliberately small. +const gameSystemArtworkCacheBudgetBytes = 24 * 1024 * 1024; + +final BaseCacheManager gameSystemArtworkCacheManager = CacheManager( + Config( + gameSystemArtworkCacheKey, + stalePeriod: const Duration(days: 14), + maxNrOfCacheObjects: 300, + ), +); diff --git a/lib/util/game_core_licenses.dart b/lib/util/game_core_licenses.dart index 9563ef14f..85f5d1035 100644 --- a/lib/util/game_core_licenses.dart +++ b/lib/util/game_core_licenses.dart @@ -26,6 +26,7 @@ const Map _coreLicenseNames = { 'mednafen_wswan': 'Mednafen (WonderSwan core)', 'mednafen_ngp': 'Mednafen (Neo Geo Pocket core)', 'mednafen_vb': 'Mednafen (Virtual Boy core)', + 'fbneo': 'FinalBurn Neo (Arcade core)', }; /// Adds the emulator cores present on this device to LicenseRegistry so they @@ -48,9 +49,10 @@ Set _presentCoreIds() { if (PlatformDetection.isAppleTV || PlatformDetection.isIOS) { return appleBundledCores; } - // macOS ships every downloadable core inside the app bundle. - if (bundlesGameCores) { - return downloadableCores.map((core) => core.coreId).toSet(); + // macOS bundles only the cores fetch_cores.sh actually fetches, not every + // downloadable core (see macosBundledCores' doc comment in game_cores.dart). + if (PlatformDetection.isMacOS) { + return macosBundledCores; } if (!GetIt.instance.isRegistered()) return const {}; final installed = GetIt.instance() diff --git a/lib/util/game_cores.dart b/lib/util/game_cores.dart index f63b1a1c3..c818d5f35 100644 --- a/lib/util/game_cores.dart +++ b/lib/util/game_cores.dart @@ -1,7 +1,9 @@ import 'dart:io'; import 'package:get_it/get_it.dart'; +import 'package:jellyfin_preference/jellyfin_preference.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:server_core/server_core.dart'; import '../preference/user_preferences.dart'; import 'game_cores_abi_stub.dart' @@ -9,41 +11,12 @@ import 'game_cores_abi_stub.dart' import 'game_storage.dart'; import 'platform_detection.dart'; -/// EmulatorJS core name to libretro core id, for the native libretro backend -/// (Android, desktop, tvOS). The plugin names cores in EmulatorJS terms (nes, -/// snes, gb, ...) and the native backend loads the matching libretro core. -const Map _libretroCores = { - 'nes': 'fceumm', - 'snes': 'snes9x', - 'gb': 'gambatte', - 'gba': 'mgba', - 'segaMD': 'genesis_plus_gx', - 'segaMS': 'genesis_plus_gx', - 'segaGG': 'genesis_plus_gx', - 'atari2600': 'stella', - 'atari7800': 'prosystem', - 'lynx': 'handy', - 'ws': 'mednafen_wswan', - 'ngp': 'mednafen_ngp', - 'pce': 'mednafen_pce_fast', - 'vb': 'mednafen_vb', - 'psx': 'pcsx_rearmed', - 'n64': 'mupen64plus_next', - 'psp': 'ppsspp', - 'nds': 'melonds', -}; - -/// The subset shipped inside the tvOS and iOS apps. The App Store can't -/// download executable code, so these targets bundle a fixed set that also -/// avoids JIT. -const Set appleBundledCores = { - 'fceumm', - 'snes9x', - 'gambatte', - 'mgba', - 'genesis_plus_gx', - 'pcsx_rearmed', -}; +/// Whether [core] is one of the server's arcade-family core names ("arcade" +/// for FBNeo, "mame" for MAME), mirroring +/// `GamesService.IsArcadeFamilyCore` on the server. Arcade ROMs are +/// multi-file ZIPs that the core must receive intact, unlike every other +/// system's single-file ROMs. +bool isArcadeFamilyCore(String core) => core == 'arcade' || core == 'mame'; /// Preference key holding the list of downloaded core ids on Android and /// desktop. @@ -98,6 +71,9 @@ class GameCore { required this.system, required this.approxSizeMb, this.needsJit = false, + this.emulatorJsSystemCores = const {}, + this.bundledOnApple = false, + this.bundledOnMacOS = false, }); /// The libretro core id, matching [_libretroCores] values and the buildbot @@ -114,31 +90,186 @@ class GameCore { /// Cores that recompile guest code at runtime. They only run where a JIT is /// allowed (Android and desktop, not the App Store platforms). final bool needsJit; + + /// Server core names that load this libretro core when native playback is + /// selected. Keeping aliases here makes the catalog the single source of + /// truth for routing, download metadata, and bundled-core inventories. + final Set emulatorJsSystemCores; + + /// Whether this interpreter-only core ships in the iOS and tvOS apps. + final bool bundledOnApple; + + /// Whether this core is fetched into the macOS app bundle. + final bool bundledOnMacOS; } -/// The cores offered in the download manager, ordered roughly by how common the -/// system is. One entry per core, so Genesis, Master System, and Game Gear -/// share the single Sega core. -const List downloadableCores = [ - GameCore(coreId: 'fceumm', system: 'Nintendo Entertainment System', approxSizeMb: 1), - GameCore(coreId: 'snes9x', system: 'Super Nintendo', approxSizeMb: 3), - GameCore(coreId: 'gambatte', system: 'Game Boy and Game Boy Color', approxSizeMb: 1), - GameCore(coreId: 'mgba', system: 'Game Boy Advance', approxSizeMb: 3), - GameCore(coreId: 'genesis_plus_gx', system: 'Sega Genesis, Master System, and Game Gear', approxSizeMb: 2), - GameCore(coreId: 'pcsx_rearmed', system: 'PlayStation', approxSizeMb: 2), - GameCore(coreId: 'mupen64plus_next', system: 'Nintendo 64', approxSizeMb: 6, needsJit: true), - // 18 MB core plus the PPSSPP support files, which are fetched with it. - GameCore(coreId: 'ppsspp', system: 'PlayStation Portable', approxSizeMb: 29, needsJit: true), - GameCore(coreId: 'melonds', system: 'Nintendo DS', approxSizeMb: 4, needsJit: true), - GameCore(coreId: 'mednafen_pce_fast', system: 'PC Engine and TurboGrafx-16', approxSizeMb: 2), - GameCore(coreId: 'stella', system: 'Atari 2600', approxSizeMb: 2), - GameCore(coreId: 'prosystem', system: 'Atari 7800', approxSizeMb: 1), - GameCore(coreId: 'handy', system: 'Atari Lynx', approxSizeMb: 1), - GameCore(coreId: 'mednafen_wswan', system: 'WonderSwan', approxSizeMb: 2), - GameCore(coreId: 'mednafen_ngp', system: 'Neo Geo Pocket', approxSizeMb: 1), - GameCore(coreId: 'mednafen_vb', system: 'Virtual Boy', approxSizeMb: 2), +/// Native core capabilities, ordered as they appear in the download manager. +/// +/// Each entry owns its server-core aliases, download presentation, JIT policy, +/// and Apple bundle membership. MAME deliberately has no entry: it is an +/// EmulatorJS-only server core, while `arcade` maps to FBNeo. +const List gameCoreCatalog = [ + GameCore( + coreId: 'fceumm', + system: 'Nintendo Entertainment System', + approxSizeMb: 1, + emulatorJsSystemCores: {'nes'}, + bundledOnApple: true, + bundledOnMacOS: true, + ), + GameCore( + coreId: 'snes9x', + system: 'Super Nintendo', + approxSizeMb: 3, + emulatorJsSystemCores: {'snes'}, + bundledOnApple: true, + bundledOnMacOS: true, + ), + GameCore( + coreId: 'gambatte', + system: 'Game Boy and Game Boy Color', + approxSizeMb: 1, + emulatorJsSystemCores: {'gb'}, + bundledOnApple: true, + bundledOnMacOS: true, + ), + GameCore( + coreId: 'mgba', + system: 'Game Boy Advance', + approxSizeMb: 3, + emulatorJsSystemCores: {'gba'}, + bundledOnApple: true, + bundledOnMacOS: true, + ), + GameCore( + coreId: 'genesis_plus_gx', + system: 'Sega Genesis, Master System, and Game Gear', + approxSizeMb: 2, + emulatorJsSystemCores: {'segaMD', 'segaMS', 'segaGG'}, + bundledOnApple: true, + bundledOnMacOS: true, + ), + GameCore( + coreId: 'pcsx_rearmed', + system: 'PlayStation', + approxSizeMb: 2, + emulatorJsSystemCores: {'psx'}, + bundledOnApple: true, + bundledOnMacOS: true, + ), + GameCore( + coreId: 'fbneo', + system: 'Arcade (FBNeo)', + approxSizeMb: 16, + emulatorJsSystemCores: {'arcade'}, + bundledOnApple: true, + ), + GameCore( + coreId: 'mupen64plus_next', + system: 'Nintendo 64', + approxSizeMb: 6, + needsJit: true, + emulatorJsSystemCores: {'n64'}, + bundledOnMacOS: true, + ), + GameCore( + coreId: 'ppsspp', + system: 'PlayStation Portable', + approxSizeMb: 18, + needsJit: true, + emulatorJsSystemCores: {'psp'}, + bundledOnMacOS: true, + ), + GameCore( + coreId: 'melonds', + system: 'Nintendo DS', + approxSizeMb: 4, + needsJit: true, + emulatorJsSystemCores: {'nds'}, + bundledOnMacOS: true, + ), + GameCore( + coreId: 'mednafen_pce_fast', + system: 'PC Engine and TurboGrafx-16', + approxSizeMb: 2, + emulatorJsSystemCores: {'pce'}, + bundledOnMacOS: true, + ), + GameCore( + coreId: 'stella', + system: 'Atari 2600', + approxSizeMb: 2, + emulatorJsSystemCores: {'atari2600'}, + bundledOnMacOS: true, + ), + GameCore( + coreId: 'prosystem', + system: 'Atari 7800', + approxSizeMb: 1, + emulatorJsSystemCores: {'atari7800'}, + bundledOnMacOS: true, + ), + GameCore( + coreId: 'handy', + system: 'Atari Lynx', + approxSizeMb: 1, + emulatorJsSystemCores: {'lynx'}, + bundledOnMacOS: true, + ), + GameCore( + coreId: 'mednafen_wswan', + system: 'WonderSwan', + approxSizeMb: 2, + emulatorJsSystemCores: {'ws'}, + bundledOnMacOS: true, + ), + GameCore( + coreId: 'mednafen_ngp', + system: 'Neo Geo Pocket', + approxSizeMb: 1, + emulatorJsSystemCores: {'ngp'}, + bundledOnMacOS: true, + ), + GameCore( + coreId: 'mednafen_vb', + system: 'Virtual Boy', + approxSizeMb: 2, + emulatorJsSystemCores: {'vb'}, + bundledOnMacOS: true, + ), ]; +/// The cores offered in the download manager. Kept as a named view for its +/// existing consumers; every entry is defined by [gameCoreCatalog]. +final List downloadableCores = List.unmodifiable(gameCoreCatalog); + +/// The subset shipped inside the tvOS and iOS apps. The App Store can't +/// download executable code, so these targets bundle a fixed set that also +/// avoids JIT. Derived from [gameCoreCatalog] and checked against both fetch +/// scripts in the focused tests. +final Set appleBundledCores = Set.unmodifiable( + gameCoreCatalog + .where((core) => core.bundledOnApple) + .map((core) => core.coreId), +); + +/// The libretro core ids macOS actually bundles, derived from +/// [gameCoreCatalog] and checked against `macos/game_host/fetch_cores.sh`. +final Set macosBundledCores = Set.unmodifiable( + gameCoreCatalog + .where((core) => core.bundledOnMacOS) + .map((core) => core.coreId), +); + +/// EmulatorJS core name to libretro core id for the native backend. The +/// mapping is derived from [gameCoreCatalog] so routing cannot drift from core +/// availability or download metadata. +final Map _libretroCores = Map.unmodifiable({ + for (final capability in gameCoreCatalog) + for (final systemCore in capability.emulatorJsSystemCores) + systemCore: capability.coreId, +}); + /// Whether this platform has the native libretro backend at all (tvOS, iOS, /// Android, desktop). iOS bundles a fixed core set and falls back to the /// EmulatorJS WebView for systems it can't play natively. @@ -148,9 +279,11 @@ bool get nativeGameBackendSupported => PlatformDetection.isAndroid || PlatformDetection.isDesktop; -/// Whether the EmulatorJS WebView backend works on this platform. Linux has no -/// flutter_inappwebview implementation and tvOS has no WebKit. +/// Whether EmulatorJS works in the browser or in this platform's embedded +/// WebView. Linux has no flutter_inappwebview implementation and tvOS has no +/// WebKit. bool get emulatorJsAvailable => + PlatformDetection.isWeb || PlatformDetection.isIOS || PlatformDetection.isAndroid || PlatformDetection.isWindows || @@ -203,20 +336,77 @@ bool get usesOnScreenControls => (PlatformDetection.isAndroid || PlatformDetection.isIOS) && !PlatformDetection.isTV; -/// The save-state key for a game. Native libretro states are namespaced so they -/// don't collide with an EmulatorJS state of the same game. Keyed on the core -/// because a single device can route some systems to native and others to -/// EmulatorJS. -String gameStateKey(String gameId, String core) => - usesNativeGameBackendFor(core) ? 'lr-$gameId' : gameId; +/// The save-state key for a game, isolated by emulator core. +/// +/// A state produced by one core cannot safely be loaded by another. The +/// backend prefix also prevents the native libretro and EmulatorJS state +/// formats from colliding for the same game/core pair. +String gameStateKey( + String gameId, + String core, { + bool forceEmulatorJs = false, +}) => !forceEmulatorJs && usesNativeGameBackendFor(core) + ? 'lr-$core-$gameId' + : 'ejs-$core-$gameId'; + +/// The save-state key [gameStateKey] replaced. The native backend used to key +/// by game id alone (`lr-$gameId`, no core segment) and EmulatorJS used the +/// bare [gameId] (no prefix, no core segment at all). Kept only so +/// [loadGameStateWithMigration] can find a save written under the old scheme +/// and copy it forward; nothing should write here anymore. +String legacyGameStateKey( + String gameId, + String core, { + bool forceEmulatorJs = false, +}) => + !forceEmulatorJs && usesNativeGameBackendFor(core) ? 'lr-$gameId' : gameId; + +/// Reads a game's save state, transparently migrating it off the legacy key +/// scheme ([legacyGameStateKey]) the first time it's found. +/// +/// Tries [gameStateKey] first. On a miss, falls back to the legacy key; a hit +/// there is copied forward to the new key (best-effort — the legacy bytes are +/// still returned even if that write fails, so the game loads either way) and +/// returned. A miss at both keys returns null. +/// +/// Centralizing this here means every read call site gets the migration for +/// free instead of duplicating the fallback logic. +Future?> loadGameStateWithMigration( + GamesApi games, + String gameId, + String core, { + bool forceEmulatorJs = false, +}) async { + final newKey = gameStateKey(gameId, core, forceEmulatorJs: forceEmulatorJs); + final current = await games.getSave(newKey); + if (current != null && current.isNotEmpty) return current; + + final legacyKey = legacyGameStateKey( + gameId, + core, + forceEmulatorJs: forceEmulatorJs, + ); + if (legacyKey == newKey) return current; + final legacy = await games.getSave(legacyKey); + if (legacy == null || legacy.isEmpty) return current; + + try { + await games.putSave(newKey, legacy); + } catch (_) { + // Best-effort: the user's game should still load from the legacy save + // even if the migration copy didn't stick this time. + } + return legacy; +} /// The libretro core id for an EmulatorJS core name, or null if there's no /// mapping for it. String? libretroCoreId(String core) => _libretroCores[core]; /// Whether the native backend on this platform can play the given system. The -/// bundled Apple targets only run their fixed set. macOS bundles every mapped -/// core, and Android and desktop download any mapped core on demand. +/// bundled Apple targets only run their fixed set. macOS, Android, and desktop +/// support every mapped core; [_nativeCoreIsAvailable] separately checks +/// whether its bundled or downloaded binary is present. bool nativeCanPlay(String core) { final id = _libretroCores[core]; if (id == null) return false; @@ -227,10 +417,64 @@ bool nativeCanPlay(String core) { } /// Whether a specific game plays through the native backend right now. Native -/// is used when it's selected and can play the system. Where it can't but -/// EmulatorJS exists, that one game falls back to the WebView. -bool usesNativeGameBackendFor(String core) => - usesNativeGameBackend && (nativeCanPlay(core) || !emulatorJsAvailable); +/// is used when it's selected, supports the system, and its core is actually +/// available. Android and desktop otherwise fall back to EmulatorJS instead of +/// opening the native player only to report that its core is not installed. +bool usesNativeGameBackendFor(String core) => resolveNativeGameBackend( + nativeSelected: usesNativeGameBackend, + nativeSupported: nativeCanPlay(core), + emulatorAvailable: emulatorJsAvailable, + nativeCoreAvailable: _nativeCoreIsAvailable(core), +); + +/// Pure routing decision shared with focused tests. +/// +/// A platform without EmulatorJS must retain the native route so its player +/// can present the appropriate unsupported/missing-core error. Where the +/// WebView is available, a missing downloadable core is a normal fallback. +bool resolveNativeGameBackend({ + required bool nativeSelected, + required bool nativeSupported, + required bool emulatorAvailable, + required bool nativeCoreAvailable, +}) { + if (!nativeSelected) return false; + if (!emulatorAvailable) return true; + return nativeSupported && nativeCoreAvailable; +} + +/// Whether the native backend can genuinely load [core] on this device right +/// now, ignoring the user's current native/EmulatorJS preference. Needed so +/// the core picker can always offer a forceable native option even when +/// EmulatorJS is currently preferred. +bool nativeCoreReachable(String core) => + nativeGameBackendSupported && _nativeCoreIsAvailable(core); + +bool _nativeCoreIsAvailable(String core) { + if (!nativeGameBackendSupported) return false; + if (!nativeCanPlay(core)) return false; + if (PlatformDetection.isAppleTV || PlatformDetection.isIOS) return true; + if (PlatformDetection.isMacOS) { + final id = libretroCoreId(core); + return id != null && macosBundledCores.contains(id); + } + + // Android and non-Apple desktop builds install cores on demand. The + // downloader records a core only after its binary has been written. + if (PlatformDetection.isAndroid || PlatformDetection.isDesktop) { + if (!supportsCoreDownloads || + !GetIt.instance.isRegistered()) { + return false; + } + final coreId = libretroCoreId(core); + final installed = GetIt.instance().getStringList( + installedCoresPreferenceKey, + ); + return coreId != null && (installed?.contains(coreId) ?? false); + } + + return true; +} /// Whether the game can be played at all here: natively, or through EmulatorJS /// where that backend exists. tvOS and Linux have no WebView, so only their diff --git a/lib/util/tv_image_cache_io.dart b/lib/util/tv_image_cache_io.dart index e74e04875..99588647c 100644 --- a/lib/util/tv_image_cache_io.dart +++ b/lib/util/tv_image_cache_io.dart @@ -4,16 +4,16 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:path_provider/path_provider.dart'; +import '../data/services/retro_artwork/retro_artwork_disk_cache_io.dart'; +import 'game_artwork_cache.dart'; import 'platform_detection.dart'; -bool _sweeping = false; -DateTime? _lastSweep; +final Set _sweepingCacheKeys = {}; +final Map _lastSweepByCacheKey = {}; // Point cached_network_image at a cache manager with a shorter stale period and -// a higher object count than the library default. The object count is a soft -// cap only, the real ceiling is the byte budget enforced by -// enforceImageCacheBudget. Files stay in the library's default directory so an -// existing cache is never orphaned on update. +// a higher object count than the library default. Files stay in the library's +// default directory so an existing cache is never orphaned on update. Future configureImageDiskCache() async { try { final key = DefaultCacheManager.key; @@ -37,36 +37,232 @@ Future configureImageDiskCache() async { maxNrOfCacheObjects: maxObjects, ); } - // Deliberately a plain CacheManager: nothing asks the disk layer to resize, - // and if anything ever does, this trips an assert rather than silently - // storing a second re-encoded copy of every image. CachedNetworkImageProvider.defaultCacheManager = CacheManager(config); } catch (_) {} } -// Trim the image cache directory to [budgetBytes] by deleting the least recently -// written files first. A missing file is a cache miss the manager re-downloads, -// so deleting directly is safe. Best effort only, so a failure never blocks. +// Game artwork has its own fixed budget, so browsing games never displaces +// movie, TV, or music artwork from the user's media cache allocation. Future enforceImageCacheBudget( int budgetBytes, { bool throttle = false, }) async { - if (budgetBytes <= 0 || _sweeping) return; + await _enforceCacheDirectoryBudget( + DefaultCacheManager.key, + budgetBytes, + throttle: throttle, + ); +} + +Future enforceGameArtworkCacheBudget({bool throttle = false}) => + _enforceGameArtworkCacheBudget(throttle: throttle); + +const _gameArtworkScopeAccessFileName = '.moonfin-scope-access'; +const _gameArtworkBudgetSweepKey = '$gameArtworkCacheKey-budget'; +final Map _activeGameArtworkScopes = {}; + +/// Marks a system as actively browsed. Active systems are never evicted by a +/// resume/startup cache sweep, even if the global game-art budget is exceeded. +Future retainGameArtworkCacheScope(String scope) async { + _activeGameArtworkScopes.update( + scope, + (count) => count + 1, + ifAbsent: () => 1, + ); + try { + final temp = await getTemporaryDirectory(); + final dir = Directory('${temp.path}/${gameArtworkCacheKeyForScope(scope)}'); + await dir.create(recursive: true); + await File( + '${dir.path}/$_gameArtworkScopeAccessFileName', + ).writeAsString('', flush: true); + } catch (_) {} +} + +void releaseGameArtworkCacheScope(String scope) { + final count = _activeGameArtworkScopes[scope]; + if (count == null || count <= 1) { + _activeGameArtworkScopes.remove(scope); + } else { + _activeGameArtworkScopes[scope] = count - 1; + } +} + +Future _enforceGameArtworkCacheBudget({required bool throttle}) async { + if (_sweepingCacheKeys.contains(_gameArtworkBudgetSweepKey)) return; final now = DateTime.now(); + final lastSweep = _lastSweepByCacheKey[_gameArtworkBudgetSweepKey]; if (throttle && - _lastSweep != null && - now.difference(_lastSweep!) < const Duration(minutes: 3)) { + lastSweep != null && + now.difference(lastSweep) < const Duration(minutes: 3)) { return; } - _sweeping = true; - _lastSweep = now; + + _sweepingCacheKeys.add(_gameArtworkBudgetSweepKey); + _lastSweepByCacheKey[_gameArtworkBudgetSweepKey] = now; try { final temp = await getTemporaryDirectory(); - final dir = Directory('${temp.path}/${DefaultCacheManager.key}'); - if (!await dir.exists()) return; + await evictInactiveGameArtworkCaches( + temp, + budgetBytes: gameArtworkCacheBudgetBytes, + protectedCacheKeys: _activeGameArtworkScopes.keys + .map(gameArtworkCacheKeyForScope) + .toSet(), + now: now, + ); + } catch (_) { + } finally { + _sweepingCacheKeys.remove(_gameArtworkBudgetSweepKey); + } +} + +/// Evicts complete inactive system caches least-recently-used first until the +/// game-art budget is met. Deleting a whole inactive scope avoids puncturing a +/// user's in-progress browse with scattered missing artwork. +/// +/// Exposed for deterministic filesystem tests. A recently modified inactive +/// directory is left alone because a non-cancellable image transfer from the +/// just-closed system may still be writing into it. +Future> evictInactiveGameArtworkCaches( + Directory temporaryDirectory, { + required int budgetBytes, + required Set protectedCacheKeys, + DateTime? now, +}) async { + if (budgetBytes <= 0 || !await temporaryDirectory.exists()) return const []; + final sweepTime = now ?? DateTime.now(); + final caches = <_GameArtworkCacheDirectory>[]; + await for (final entity in temporaryDirectory.list(followLinks: false)) { + if (entity is! Directory || + !isGameArtworkCacheDirectoryName(_directoryName(entity))) { + continue; + } + final stats = await _inspectGameArtworkCacheDirectory(entity); + if (stats != null) caches.add(stats); + } + + var total = caches.fold(0, (sum, cache) => sum + cache.bytes); + if (total <= budgetBytes) return const []; + + final target = (budgetBytes * 0.9).round(); + final inactive = + caches + .where((cache) => !protectedCacheKeys.contains(cache.key)) + .where( + (cache) => + sweepTime.difference(cache.newestModified) >= + const Duration(seconds: 30), + ) + .toList() + ..sort((a, b) => a.lastUsed.compareTo(b.lastUsed)); + + final evicted = []; + for (final cache in inactive) { + if (total <= target) break; + try { + final clearedLiveManager = await clearLiveGameArtworkCache(cache.key); + if (clearedLiveManager) { + // Keep the manager's metadata database intact; only remove our access + // marker so the empty directory no longer represents retained artwork. + final accessFile = File( + '${cache.directory.path}/$_gameArtworkScopeAccessFileName', + ); + if (await accessFile.exists()) await accessFile.delete(); + } else { + await cache.directory.delete(recursive: true); + } + total -= cache.bytes; + evicted.add(cache.key); + } catch (_) {} + } + return evicted; +} +class _GameArtworkCacheDirectory { + const _GameArtworkCacheDirectory({ + required this.directory, + required this.key, + required this.bytes, + required this.lastUsed, + required this.newestModified, + }); + + final Directory directory; + final String key; + final int bytes; + final DateTime lastUsed; + final DateTime newestModified; +} + +Future<_GameArtworkCacheDirectory?> _inspectGameArtworkCacheDirectory( + Directory directory, +) async { + try { + var bytes = 0; + DateTime? newestModified; + DateTime? lastUsed; + final accessFile = File( + '${directory.path}/$_gameArtworkScopeAccessFileName', + ); + if (await accessFile.exists()) { + lastUsed = (await accessFile.stat()).modified; + } + await for (final entity in directory.list( + recursive: true, + followLinks: false, + )) { + if (entity is! File) continue; + final stat = await entity.stat(); + newestModified = + newestModified == null || stat.modified.isAfter(newestModified) + ? stat.modified + : newestModified; + if (!entity.path.endsWith(_gameArtworkScopeAccessFileName)) { + bytes += stat.size; + } + } + final directoryStat = await directory.stat(); + return _GameArtworkCacheDirectory( + directory: directory, + key: _directoryName(directory), + bytes: bytes, + lastUsed: lastUsed ?? directoryStat.modified, + newestModified: newestModified ?? directoryStat.modified, + ); + } catch (_) { + return null; + } +} + +String _directoryName(Directory directory) { + final path = directory.path; + final separatorIndex = path.lastIndexOf(Platform.pathSeparator); + return separatorIndex == -1 ? path : path.substring(separatorIndex + 1); +} + +// A missing file is a cache miss the manager re-downloads, so deleting it +// directly is safe. Best effort only, so a failure never blocks the UI. +Future _enforceCacheDirectoryBudget( + String cacheKey, + int budgetBytes, { + required bool throttle, +}) async { + if (budgetBytes <= 0 || _sweepingCacheKeys.contains(cacheKey)) return; + final now = DateTime.now(); + final lastSweep = _lastSweepByCacheKey[cacheKey]; + if (throttle && + lastSweep != null && + now.difference(lastSweep) < const Duration(minutes: 3)) { + return; + } + _sweepingCacheKeys.add(cacheKey); + _lastSweepByCacheKey[cacheKey] = now; + try { + final temp = await getTemporaryDirectory(); final entries = <({File file, int size, DateTime modified})>[]; var total = 0; + final dir = Directory('${temp.path}/$cacheKey'); + if (!await dir.exists()) return; await for (final entity in dir.list(followLinks: false)) { if (entity is! File) continue; try { @@ -81,8 +277,9 @@ Future enforceImageCacheBudget( final target = (budgetBytes * 0.9).round(); for (final entry in entries) { if (total <= target) break; - // Never delete a file that may still be downloading. - if (now.difference(entry.modified) < const Duration(seconds: 30)) continue; + if (now.difference(entry.modified) < const Duration(seconds: 30)) { + continue; + } try { await entry.file.delete(); total -= entry.size; @@ -90,12 +287,34 @@ Future enforceImageCacheBudget( } } catch (_) { } finally { - _sweeping = false; + _sweepingCacheKeys.remove(cacheKey); } } Future clearImageDiskCache() async { try { await CachedNetworkImageProvider.defaultCacheManager.emptyCache(); + final temp = await getTemporaryDirectory(); + await for (final entity in temp.list(followLinks: false)) { + if (entity is Directory && + isGameArtworkCacheDirectoryName(_directoryName(entity))) { + await entity.delete(recursive: true); + } + } + final gameSystemCacheDir = Directory( + '${temp.path}/$gameSystemArtworkCacheKey', + ); + if (await gameSystemCacheDir.exists()) { + await gameSystemCacheDir.delete(recursive: true); + } + // Protocol-2 artwork lives under the temporary directory too, but in its + // own dedicated subdirectory rather than one the sweep above matches, so + // it needs its own explicit delete. It is by far the largest of these + // caches: a 150 MB budget against a few tens of MB for everything else + // here. + final retroArtworkCacheDir = await defaultRetroArtworkDiskCacheDirectory(); + if (await retroArtworkCacheDir.exists()) { + await retroArtworkCacheDir.delete(recursive: true); + } } catch (_) {} } diff --git a/lib/util/tv_image_cache_stub.dart b/lib/util/tv_image_cache_stub.dart index 567f0bc92..07d8f139a 100644 --- a/lib/util/tv_image_cache_stub.dart +++ b/lib/util/tv_image_cache_stub.dart @@ -5,4 +5,10 @@ Future enforceImageCacheBudget( bool throttle = false, }) async {} +Future enforceGameArtworkCacheBudget({bool throttle = false}) async {} + +Future retainGameArtworkCacheScope(String scope) async {} + +void releaseGameArtworkCacheScope(String scope) {} + Future clearImageDiskCache() async {} diff --git a/test/data/services/retro_artwork_data_source_test.dart b/test/data/services/retro_artwork_data_source_test.dart new file mode 100644 index 000000000..77d10add8 --- /dev/null +++ b/test/data/services/retro_artwork_data_source_test.dart @@ -0,0 +1,391 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_activity_gate.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_data_source.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_transport.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_cache.dart'; +import 'package:server_core/server_core.dart'; + +class _MockGamesApi extends Mock implements GamesApi {} + +class _MockMediaServerClient extends Mock implements MediaServerClient {} + +class _FakeArtworkHttpClient implements RetroArtworkHttpClient { + @override + void close() {} + + @override + Future getBytes( + Uri uri, { + required RetroArtworkCancellationSignal cancellation, + }) async => Uint8List(0); +} + +void main() { + late _MockGamesApi gamesApi; + late _MockMediaServerClient client; + late RetroArtworkActivityGate activityGate; + late RetroArtworkTransport transport; + + setUpAll(() { + registerFallbackValue( + const GameArtworkPriorityRequest( + systemId: 'fallback', + knownGeneration: 'fallback', + items: [], + ), + ); + }); + + setUp(() { + gamesApi = _MockGamesApi(); + client = _MockMediaServerClient(); + when(() => client.gamesApi).thenReturn(gamesApi); + when(() => client.baseUrl).thenReturn('https://server.example'); + when(() => client.userId).thenReturn('user'); + when(() => client.accessToken).thenReturn('token'); + activityGate = RetroArtworkActivityGate(); + transport = RetroArtworkTransport( + httpClient: _FakeArtworkHttpClient(), + cache: RetroArtworkByteLruCache(), + activityGate: activityGate, + ); + }); + + test( + 'selects and caches manifest capability once per client session', + () async { + when(() => gamesApi.getArtworkCapabilities()).thenAnswer( + (_) async => const GameArtworkCapabilities( + protocolVersion: 2, + manifest: true, + versionedAssets: true, + ), + ); + + final first = await RetroArtworkDataSourceFactory.create( + client: client, + activityGate: activityGate, + transport: transport, + ); + final second = await RetroArtworkDataSourceFactory.create( + client: client, + activityGate: activityGate, + transport: transport, + ); + addTearDown(() => first?.dispose()); + addTearDown(() => second?.dispose()); + + expect(first, isA()); + expect(second, isA()); + verify(() => gamesApi.getArtworkCapabilities()).called(1); + }, + ); + + test('manifest maps only ready descriptors to image references', () async { + _stubManifestFor( + gamesApi, + libraryId: 'retro', + systemId: 'nes', + manifest: GameArtworkManifest( + generation: 'g1', + entries: [ + GameArtworkManifestEntry( + gameId: 'zelda', + artwork: { + 'boxart': const GameArtworkDescriptor( + state: 'thumbnailReady', + revision: 'v1', + url: '/Moonfin/art/v1', + ), + 'snap': const GameArtworkDescriptor(state: 'pending'), + 'title': const GameArtworkDescriptor(state: 'missing'), + }, + ), + ], + ), + ); + final adapter = _buildAdapter(gamesApi, activityGate, transport); + addTearDown(adapter.dispose); + await adapter.refreshSystem(libraryId: 'retro', systemId: 'nes'); + + final reference = adapter.imageFor('zelda'); + expect(reference, isNotNull); + expect(reference?.source?.uri.toString(), '/Moonfin/art/v1'); + expect(adapter.imageFor('zelda', role: 'snap'), isNull); + expect(adapter.imageFor('zelda', role: 'title'), isNull); + }); + + test('manifest image transfer failure is retried once, then latches until ' + 'route re-entry', () async { + _stubManifestFor( + gamesApi, + libraryId: 'retro', + systemId: 'nes', + manifest: GameArtworkManifest( + generation: 'g1', + entries: [ + GameArtworkManifestEntry( + gameId: 'zelda', + artwork: { + 'boxart': const GameArtworkDescriptor( + state: 'thumbnailReady', + revision: 'v1', + url: '/Moonfin/art/v1', + ), + }, + ), + ], + ), + ); + final adapter = _buildAdapter( + gamesApi, + activityGate, + transport, + imageRetryBackoff: const Duration(milliseconds: 10), + ); + addTearDown(adapter.dispose); + await adapter.refreshSystem(libraryId: 'retro', systemId: 'nes'); + expect(adapter.imageFor('zelda'), isNotNull); + + // A transient failure withholds the image immediately (forcing the + // consumer widget to unmount and retry) and restores it once the + // bounded backoff elapses. + adapter.reportImageFailure('zelda', statusCode: 503); + expect(adapter.imageFor('zelda'), isNull); + await Future.delayed(const Duration(milliseconds: 40)); + expect(adapter.imageFor('zelda'), isNotNull); + + // The bounded retry is spent: a second failure on the same descriptor + // revision latches instead of retrying again. + adapter.reportImageFailure('zelda', statusCode: 503); + expect(adapter.imageFor('zelda'), isNull); + await Future.delayed(const Duration(milliseconds: 40)); + expect(adapter.imageFor('zelda'), isNull); + + // Re-entering the route clears the latch and gives it a fresh chance. + adapter.onRouteReentered(); + expect(adapter.imageFor('zelda'), isNotNull); + }); + + test( + 'manifest debounces one ordered priority request for pending art', + () async { + _stubManifestAny( + gamesApi, + GameArtworkManifest( + generation: 'g2', + entries: [ + GameArtworkManifestEntry( + gameId: 'first', + artwork: { + 'boxart': const GameArtworkDescriptor(state: 'pending'), + }, + ), + GameArtworkManifestEntry( + gameId: 'second', + artwork: { + 'boxart': const GameArtworkDescriptor(state: 'pending'), + }, + ), + ], + ), + ); + when( + () => gamesApi.submitArtworkPriority( + any(), + any(), + cancellationOwner: any(named: 'cancellationOwner'), + ), + ).thenAnswer((_) async {}); + + final adapter = _buildAdapter( + gamesApi, + activityGate, + transport, + priorityDebounce: Duration.zero, + ); + addTearDown(adapter.dispose); + await adapter.refreshSystem(libraryId: 'retro', systemId: 'nes'); + await adapter.submitActiveBandPriority(['second', 'first']); + + final request = + verify( + () => gamesApi.submitArtworkPriority( + 'retro', + captureAny(), + cancellationOwner: any(named: 'cancellationOwner'), + ), + ).captured.single + as GameArtworkPriorityRequest; + expect(request.knownGeneration, 'g2'); + expect(request.items.map((item) => item.gameId), ['second', 'first']); + }, + ); + + test( + 'manifest preserves nearer priority bands in serialized order', + () async { + _stubManifestAny( + gamesApi, + const GameArtworkManifest( + generation: 'g3', + entries: [ + GameArtworkManifestEntry( + gameId: 'near', + artwork: {'boxart': GameArtworkDescriptor(state: 'pending')}, + ), + GameArtworkManifestEntry( + gameId: 'far', + artwork: {'boxart': GameArtworkDescriptor(state: 'pending')}, + ), + ], + ), + ); + final firstStarted = Completer(); + final releaseFirst = Completer(); + final submitted = []; + when( + () => gamesApi.submitArtworkPriority( + any(), + any(), + cancellationOwner: any(named: 'cancellationOwner'), + ), + ).thenAnswer((invocation) async { + final request = + invocation.positionalArguments[1] as GameArtworkPriorityRequest; + submitted.add(request.items.single.gameId); + if (submitted.length == 1) { + firstStarted.complete(); + await releaseFirst.future; + } + }); + + final adapter = _buildAdapter( + gamesApi, + activityGate, + transport, + priorityDebounce: Duration.zero, + ); + addTearDown(adapter.dispose); + await adapter.refreshSystem(libraryId: 'retro', systemId: 'nes'); + + final near = adapter.submitActiveBandPriority([ + 'near', + ], planGeneration: 7); + final far = adapter.submitActiveBandPriority(['far'], planGeneration: 7); + await firstStarted.future; + expect(submitted, ['near']); + + releaseFirst.complete(); + await Future.wait(>[near, far]); + expect(submitted, ['near', 'far']); + }, + ); + + test( + 'legacy suppresses 404s for the screen and retries transient failures on re-entry', + () async { + when( + () => gamesApi.thumbUrl( + libraryId: any(named: 'libraryId'), + gameId: any(named: 'gameId'), + kind: any(named: 'kind'), + ), + ).thenReturn('https://server.example/thumb'); + final adapter = LegacyArtworkAdapter(gamesApi: gamesApi); + await adapter.refreshSystem(libraryId: 'retro', systemId: 'nes'); + + adapter.reportImageFailure('missing', statusCode: 404); + adapter.reportImageFailure('unstable', statusCode: 503); + expect(adapter.imageFor('missing'), isNull); + expect(adapter.imageFor('unstable'), isNull); + adapter.onRouteReentered(); + expect(adapter.imageFor('missing'), isNull); + expect(adapter.imageFor('unstable'), isNotNull); + }, + ); + + test( + 'app resume retries transient failures on active factory adapters', + () async { + when( + () => gamesApi.getArtworkCapabilities(), + ).thenAnswer((_) async => null); + when( + () => gamesApi.thumbUrl( + libraryId: any(named: 'libraryId'), + gameId: any(named: 'gameId'), + kind: any(named: 'kind'), + ), + ).thenReturn('https://server.example/thumb'); + final adapter = await RetroArtworkDataSourceFactory.create( + client: client, + activityGate: activityGate, + transport: transport, + ); + addTearDown(() => adapter?.dispose()); + await adapter!.refreshSystem(libraryId: 'retro', systemId: 'nes'); + adapter.reportImageFailure('unstable', statusCode: 503); + expect(adapter.imageFor('unstable'), isNull); + + RetroArtworkDataSourceFactory.notifyAppResumed(); + + expect(adapter.imageFor('unstable'), isNotNull); + }, + ); +} + +/// Stubs [GamesApi.getArtworkManifest] for a specific library/system pair. +void _stubManifestFor( + _MockGamesApi gamesApi, { + required String libraryId, + required String systemId, + required GameArtworkManifest manifest, +}) { + when( + () => gamesApi.getArtworkManifest( + libraryId, + systemId: systemId, + knownGeneration: any(named: 'knownGeneration'), + cancellationOwner: any(named: 'cancellationOwner'), + ), + ).thenAnswer((_) async => manifest); +} + +/// Stubs [GamesApi.getArtworkManifest] for any library/system pair, for +/// tests that only care about the response and not the request routing. +void _stubManifestAny(_MockGamesApi gamesApi, GameArtworkManifest manifest) { + when( + () => gamesApi.getArtworkManifest( + any(), + systemId: any(named: 'systemId'), + knownGeneration: any(named: 'knownGeneration'), + cancellationOwner: any(named: 'cancellationOwner'), + ), + ).thenAnswer((_) async => manifest); +} + +/// Builds a [ManifestArtworkAdapter] wired to [gamesApi]/[activityGate]/ +/// [transport], overriding only the timing knobs a given test needs. +/// Backoff/debounce defaults mirror ManifestArtworkAdapter's own defaults. +ManifestArtworkAdapter _buildAdapter( + _MockGamesApi gamesApi, + RetroArtworkActivityGate activityGate, + RetroArtworkTransport transport, { + Duration imageRetryBackoff = const Duration(milliseconds: 750), + Duration priorityDebounce = const Duration(milliseconds: 180), +}) { + return ManifestArtworkAdapter( + gamesApi: gamesApi, + serverIdentity: 'https://server.example', + activityGate: activityGate, + transport: transport, + supportsPriorityHints: true, + imageRetryBackoff: imageRetryBackoff, + priorityDebounce: priorityDebounce, + ); +} diff --git a/test/data/services/retro_artwork_disk_cache_test.dart b/test/data/services/retro_artwork_disk_cache_test.dart new file mode 100644 index 000000000..eecd6e33c --- /dev/null +++ b/test/data/services/retro_artwork_disk_cache_test.dart @@ -0,0 +1,152 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_cache.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_disk_cache.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_disk_cache_io.dart' + show RetroArtworkDiskLruCache; + +void main() { + late Directory temporaryDirectory; + + setUp(() async { + temporaryDirectory = await Directory.systemTemp.createTemp( + 'moonfin-retro-artwork-cache-', + ); + }); + + tearDown(() async { + if (await temporaryDirectory.exists()) { + await temporaryDirectory.delete(recursive: true); + } + }); + + test( + 'retains compressed artwork and exact accounting across restart', + () async { + final first = await openRetroArtworkDiskCacheAt( + temporaryDirectory.path, + maxBytes: 10, + ); + final key = _key('revision-1'); + await first.put(key, Uint8List.fromList([1, 2, 3])); + + final reopened = await openRetroArtworkDiskCacheAt( + temporaryDirectory.path, + maxBytes: 10, + ); + + expect(await reopened.get(key), [1, 2, 3]); + expect(reopened.currentBytes, 3); + expect(reopened.length, 1); + }, + ); + + test('persists least-recently-used eviction decisions', () async { + final cache = await openRetroArtworkDiskCacheAt( + temporaryDirectory.path, + maxBytes: 4, + ); + final first = _key('revision-1'); + final second = _key('revision-2'); + final third = _key('revision-3'); + await cache.put(first, Uint8List.fromList([1, 1])); + await cache.put(second, Uint8List.fromList([2, 2])); + await cache.get(first); + await cache.put(third, Uint8List.fromList([3, 3])); + + final reopened = await openRetroArtworkDiskCacheAt( + temporaryDirectory.path, + maxBytes: 4, + ); + + expect(await reopened.get(first), isNotNull); + expect(await reopened.get(second), isNull); + expect(await reopened.get(third), isNotNull); + expect(reopened.currentBytes, 4); + }); + + test('recovers entry files when the metadata index is corrupt', () async { + final cache = await openRetroArtworkDiskCacheAt( + temporaryDirectory.path, + maxBytes: 10, + ); + final key = _key('revision-1'); + await cache.put(key, Uint8List.fromList([4, 5, 6])); + await _indexFile( + temporaryDirectory, + ).writeAsString('{not-json', flush: true); + + final recovered = await openRetroArtworkDiskCacheAt( + temporaryDirectory.path, + maxBytes: 10, + ); + + expect(await recovered.get(key), [4, 5, 6]); + expect(recovered.currentBytes, 3); + expect(recovered.length, 1); + }); + + test('repeated cache hits do not rewrite the index each time', () async { + final cache = + await openRetroArtworkDiskCacheAt( + temporaryDirectory.path, + maxBytes: 1024, + ) + as RetroArtworkDiskLruCache; + final key = _key('revision-1'); + await cache.put(key, Uint8List.fromList([1, 2, 3])); + final writesAfterPut = cache.debugIndexWriteCount; + + // These are front-cache hits (the steady state while scrolling a + // system grid): the entry was just written, so it is still resident + // in the in-memory front cache. Only `lastAccess` bookkeeping + // changes -- the debounce must coalesce all twenty into at most one + // background write, not one write per hit. + for (var i = 0; i < 20; i++) { + await cache.get(key); + } + + expect( + cache.debugIndexWriteCount - writesAfterPut, + lessThanOrEqualTo(1), + reason: 'LRU bookkeeping must not fsync per read', + ); + }); + + test( + 'recovers the atomic metadata backup after an interrupted replace', + () async { + final cache = await openRetroArtworkDiskCacheAt( + temporaryDirectory.path, + maxBytes: 10, + ); + final key = _key('revision-1'); + await cache.put(key, Uint8List.fromList([7, 8])); + final index = _indexFile(temporaryDirectory); + await index.rename('${index.path}.bak'); + await File('${index.path}.tmp').writeAsString('partial', flush: true); + + final recovered = await openRetroArtworkDiskCacheAt( + temporaryDirectory.path, + maxBytes: 10, + ); + + expect(await recovered.get(key), [7, 8]); + expect(await File('${index.path}.tmp').exists(), isFalse); + expect(await File('${index.path}.bak').exists(), isFalse); + }, + ); +} + +File _indexFile(Directory directory) => + File('${directory.path}${Platform.pathSeparator}index.json'); + +RetroArtworkCacheKey _key(String revision) => RetroArtworkCacheKey( + serverIdentity: 'server-record-1', + libraryId: 'library', + gameId: 'game', + role: 'boxart', + revision: revision, +); diff --git a/test/data/services/retro_artwork_transport_test.dart b/test/data/services/retro_artwork_transport_test.dart new file mode 100644 index 000000000..454ed1a23 --- /dev/null +++ b/test/data/services/retro_artwork_transport_test.dart @@ -0,0 +1,377 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_activity_gate.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_cache.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_transport.dart'; +import 'package:server_core/server_core.dart'; + +void main() { + group('RetroArtworkByteLruCache', () { + test('evicts least-recently-used bytes to stay within budget', () { + final cache = RetroArtworkByteLruCache(maxBytes: 5); + final a = _key('a'); + final b = _key('b'); + final c = _key('c'); + + cache.put(a, Uint8List.fromList([1, 1])); + cache.put(b, Uint8List.fromList([2, 2])); + expect(cache.get(a), isNotNull); + cache.put(c, Uint8List.fromList([3, 3])); + + expect(cache.containsKey(a), isTrue); + expect(cache.containsKey(b), isFalse); + expect(cache.containsKey(c), isTrue); + expect(cache.currentBytes, 4); + }); + + test('does not retain an entry larger than the entire budget', () { + final cache = RetroArtworkByteLruCache(maxBytes: 2); + + expect( + cache.put(_key('large'), Uint8List.fromList([1, 2, 3])), + isFalse, + ); + expect(cache.currentBytes, 0); + expect(cache.length, 0); + }); + }); + + group('RetroArtworkCacheKey', () { + test('is independent of descriptor URL and authentication token', () { + const first = RetroArtworkCacheKey( + serverIdentity: 'server-record-1', + libraryId: 'library', + gameId: 'game', + role: 'boxart', + revision: 'revision-2', + ); + const second = RetroArtworkCacheKey( + serverIdentity: 'server-record-1', + libraryId: 'library', + gameId: 'game', + role: 'boxart', + revision: 'revision-2', + ); + + expect(first, second); + expect(first.storageKey, second.storageKey); + expect(first.storageKey, isNot(contains('token'))); + }); + + test('normalizes a base URL without credentials or query state', () { + expect( + normalizeRetroArtworkServerIdentity( + 'HTTPS://user:secret@Example.COM:443/jellyfin/?ApiKey=secret#part', + ), + 'https://example.com/jellyfin', + ); + }); + + test('ignores authentication query changes in source identity', () { + final withoutToken = _source('game', uri: Uri.parse('/art/game/rev')); + final withRotatedToken = _source( + 'game', + uri: Uri.parse('/art/game/rev?ApiKey=rotated-secret'), + ); + + expect(withoutToken, withRotatedToken); + expect(withoutToken.cacheKey, withRotatedToken.cacheKey); + }); + + test('maps only ready versioned protocol-2 descriptors', () { + final ready = RetroArtworkSource.fromDescriptor( + serverIdentity: 'server-record-1', + libraryId: 'library', + gameId: 'game', + role: 'boxart', + descriptor: const GameArtworkDescriptor( + state: 'thumbnailReady', + url: '/art/game/boxart/revision-2', + revision: 'revision-2', + ), + ); + final missing = RetroArtworkSource.fromDescriptor( + serverIdentity: 'server-record-1', + libraryId: 'library', + gameId: 'game', + role: 'boxart', + descriptor: const GameArtworkDescriptor(state: 'missing'), + ); + + expect(ready?.state, RetroArtworkReadyState.thumbnailReady); + expect(missing, isNull); + }); + }); + + group('RetroArtworkActivityGate', () { + test('cancels every activity kind and suppresses new permits', () { + final gate = RetroArtworkActivityGate(); + final permits = RetroArtworkActivityKind.values + .map(gate.tryAcquire) + .whereType() + .toList(growable: false); + + gate.setRouteCovered(true); + + expect(permits.every((permit) => permit.signal.isCancelled), isTrue); + expect(gate.tryAcquire(RetroArtworkActivityKind.decode), isNull); + gate.setRouteCovered(false); + expect(gate.tryAcquire(RetroArtworkActivityKind.decode), isNotNull); + }); + + test('overlapping gameplay owners release the blocker independently', () { + final gate = RetroArtworkActivityGate(); + + gate.setGameplayActive(true); + gate.setGameplayActive(true); + gate.setGameplayActive(false); + + expect(gate.isGameplayActive, isTrue); + expect(gate.isOpen, isFalse); + + gate.setGameplayActive(false); + + expect(gate.isGameplayActive, isFalse); + expect(gate.isOpen, isTrue); + }); + }); + + group('RetroArtworkTransport', () { + test('coalesces duplicate demand for one source', () async { + final http = _ControlledHttpClient(); + final transport = _buildTransport(http: http); + final source = _source('game'); + + transport.adoptSource(source); + final first = transport.load(source); + final second = transport.load(source); + await Future.delayed(Duration.zero); + expect(http.active, 1); + http.complete(source.uri, [1]); + + expect((await first).outcome, RetroArtworkLoadOutcome.downloaded); + expect((await second).outcome, RetroArtworkLoadOutcome.downloaded); + transport.dispose(); + }); + + test('defaults to four transfers and drains its queue', () async { + final http = _ControlledHttpClient(); + final transport = _buildTransport(http: http); + final sources = List.generate( + RetroArtworkTransport.defaultMaxConcurrentTransfers + 1, + (index) => _source('game-$index'), + ); + + for (final source in sources) { + transport.adoptSource(source); + } + final futures = sources.map(transport.load).toList(growable: false); + await Future.delayed(Duration.zero); + expect(http.active, RetroArtworkTransport.defaultMaxConcurrentTransfers); + expect( + http.maxActive, + RetroArtworkTransport.defaultMaxConcurrentTransfers, + ); + expect(transport.queuedTransfers, 1); + + http.complete(sources.first.uri, [1]); + await Future.delayed(Duration.zero); + expect(http.active, RetroArtworkTransport.defaultMaxConcurrentTransfers); + expect(http.requested, contains(sources.last.uri)); + + for (final source in sources.skip(1)) { + http.complete(source.uri, [1]); + } + final results = await Future.wait(futures); + + expect( + results.every( + (result) => result.outcome == RetroArtworkLoadOutcome.downloaded, + ), + isTrue, + ); + expect( + http.maxActive, + RetroArtworkTransport.defaultMaxConcurrentTransfers, + ); + transport.dispose(); + }); + + test( + 'gameplay cancels queued and active work and suppresses new work', + () async { + final gate = RetroArtworkActivityGate(); + final http = _ControlledHttpClient(); + final cache = RetroArtworkByteLruCache(maxBytes: 100); + final transport = _buildTransport( + http: http, + cache: cache, + gate: gate, + maxConcurrentTransfers: 3, + ); + final sources = List.generate( + 4, + (index) => _source('game-$index'), + ); + for (final source in sources) { + transport.adoptSource(source); + } + final futures = sources.map(transport.load).toList(growable: false); + + await Future.delayed(Duration.zero); + expect(http.active, 3); + expect(transport.queuedTransfers, 1); + gate.setGameplayActive(true); + final cancelled = await Future.wait(futures); + + expect( + cancelled.every( + (result) => result.outcome == RetroArtworkLoadOutcome.cancelled, + ), + isTrue, + ); + expect(cache.length, 0); + final suppressedSource = _source('suppressed'); + transport.adoptSource(suppressedSource); + expect( + (await transport.load(suppressedSource)).outcome, + RetroArtworkLoadOutcome.suppressed, + ); + transport.dispose(); + }, + ); + + test( + 'source promotion evicts and replaces the previous ready source', + () async { + final gate = RetroArtworkActivityGate(); + final http = _ControlledHttpClient(); + final cache = RetroArtworkByteLruCache(maxBytes: 100); + final transport = _buildTransport(http: http, cache: cache, gate: gate); + final original = _source( + 'game', + state: RetroArtworkReadyState.originalReady, + uri: Uri.parse('/art/game/original'), + ); + transport.adoptSource(original); + final originalFuture = transport.load(original); + await Future.delayed(Duration.zero); + http.complete(original.uri, [1, 2]); + expect( + (await originalFuture).outcome, + RetroArtworkLoadOutcome.downloaded, + ); + expect(cache.containsKey(original.cacheKey), isTrue); + + final thumbnail = _source( + 'game', + state: RetroArtworkReadyState.thumbnailReady, + uri: Uri.parse('/art/game/thumbnail'), + ); + transport.adoptSource(thumbnail); + expect(cache.containsKey(original.cacheKey), isFalse); + + final thumbnailFuture = transport.load(thumbnail); + await Future.delayed(Duration.zero); + http.complete(thumbnail.uri, [3]); + expect( + (await thumbnailFuture).outcome, + RetroArtworkLoadOutcome.downloaded, + ); + expect(cache.get(thumbnail.cacheKey), [3]); + expect( + (await transport.load(original)).outcome, + RetroArtworkLoadOutcome.staleSource, + ); + transport.dispose(); + }, + ); + }); + + test('authenticated artwork requests never follow redirects', () { + expect(DioRetroArtworkHttpClient.followsRedirects, isFalse); + }); +} + +RetroArtworkTransport _buildTransport({ + required RetroArtworkHttpClient http, + RetroArtworkByteLruCache? cache, + RetroArtworkActivityGate? gate, + int maxConcurrentTransfers = + RetroArtworkTransport.defaultMaxConcurrentTransfers, +}) { + return RetroArtworkTransport( + httpClient: http, + cache: cache ?? RetroArtworkByteLruCache(maxBytes: 100), + activityGate: gate ?? RetroArtworkActivityGate(), + maxConcurrentTransfers: maxConcurrentTransfers, + ); +} + +RetroArtworkCacheKey _key(String revision) => RetroArtworkCacheKey( + serverIdentity: 'server-record-1', + libraryId: 'library', + gameId: 'game', + role: 'boxart', + revision: revision, +); + +RetroArtworkSource _source( + String gameId, { + RetroArtworkReadyState state = RetroArtworkReadyState.originalReady, + Uri? uri, +}) { + return RetroArtworkSource( + serverIdentity: 'server-record-1', + libraryId: 'library', + gameId: gameId, + role: 'boxart', + revision: 'revision-1', + state: state, + uri: uri ?? Uri.parse('/art/$gameId/revision-1'), + ); +} + +class _ControlledHttpClient implements RetroArtworkHttpClient { + final Map> _requests = + >{}; + final Set requested = {}; + int active = 0; + int maxActive = 0; + + @override + Future getBytes( + Uri uri, { + required RetroArtworkCancellationSignal cancellation, + }) { + final completer = Completer(); + _requests[uri] = completer; + requested.add(uri); + active++; + if (active > maxActive) maxActive = active; + + void cancel() { + if (!completer.isCompleted) { + completer.completeError( + RetroArtworkCancelledException(cancellation.reason), + ); + } + } + + cancellation.addListener(cancel); + return completer.future.whenComplete(() { + cancellation.removeListener(cancel); + active--; + _requests.remove(uri); + }); + } + + void complete(Uri uri, List bytes) { + _requests[uri]?.complete(Uint8List.fromList(bytes)); + } + + @override + void close() {} +} diff --git a/test/data/viewmodels/game_system_browse_view_model_test.dart b/test/data/viewmodels/game_system_browse_view_model_test.dart index 2fd84a685..29391f975 100644 --- a/test/data/viewmodels/game_system_browse_view_model_test.dart +++ b/test/data/viewmodels/game_system_browse_view_model_test.dart @@ -2,6 +2,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:moonfin/data/viewmodels/game_system_browse_view_model.dart'; import 'package:server_core/server_core.dart'; +// Added on top of the view model's own debounce durations so a wait is +// guaranteed to land after the timer fires, without hard-coding unrelated +// millisecond counts that silently drift out of sync if a duration changes. +const _settleBuffer = Duration(milliseconds: 25); + void main() { late _FakeGamesApi api; late GameSystemBrowseViewModel viewModel; @@ -33,12 +38,14 @@ void main() { test('debounces filtering and folds accents', () async { await viewModel.load(); + const searchDebounce = GameSystemBrowseViewModel.searchDebounceDuration; viewModel.updateSearch('elite'); - await Future.delayed(const Duration(milliseconds: 100)); + // Before the debounce elapses, the unfiltered list is still showing. + await Future.delayed(searchDebounce - _settleBuffer); expect(viewModel.visibleGames, hasLength(2)); - await Future.delayed(const Duration(milliseconds: 75)); + await Future.delayed(_settleBuffer * 2); expect(viewModel.visibleGames.map((game) => game.title), ['Élite']); }); @@ -52,20 +59,24 @@ void main() { expect(viewModel.selectedLetter, isEmpty); expect(viewModel.visibleGames, hasLength(2)); - await Future.delayed(const Duration(milliseconds: 175)); + await Future.delayed( + GameSystemBrowseViewModel.searchDebounceDuration + _settleBuffer, + ); expect(viewModel.visibleGames.map((game) => game.title), ['Élite']); }); test('only requests details when the caller displays the HUD', () async { await viewModel.load(); final game = viewModel.visibleGames.first; + final afterDetailDebounce = + GameSystemBrowseViewModel.detailDebounceDuration + _settleBuffer; viewModel.activateGame(game, showBackdrop: false, loadDetails: false); - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed(afterDetailDebounce); expect(api.detailRequests, 0); viewModel.activateGame(game, showBackdrop: false, loadDetails: true); - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed(afterDetailDebounce); expect(api.detailRequests, 1); expect(viewModel.hudGame?.id, game.id); expect(viewModel.gameDetails[game.id]?.overview, 'Game overview'); @@ -81,7 +92,9 @@ void main() { expect(viewModel.activeGame?.id, game.id); expect(viewModel.hudGame, isNull); - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed( + GameSystemBrowseViewModel.detailDebounceDuration + _settleBuffer, + ); expect(viewModel.hudGame?.id, game.id); expect(viewModel.gameDetails[game.id]?.overview, 'Game overview'); }, @@ -90,14 +103,22 @@ void main() { test('loads description details before changing the backdrop', () async { await viewModel.load(); final game = viewModel.visibleGames.first; + final afterDetailDebounce = + GameSystemBrowseViewModel.detailDebounceDuration + _settleBuffer; viewModel.activateGame(game, showBackdrop: true, loadDetails: true); - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed(afterDetailDebounce); expect(api.detailRequests, 1); expect(viewModel.backdropGame, isNull); - await Future.delayed(const Duration(milliseconds: 575)); + // The backdrop debounce is measured from activation, not from the point + // above: wait out the remainder so the cumulative delay clears it. + await Future.delayed( + GameSystemBrowseViewModel.backdropDebounceDuration - + afterDetailDebounce + + _settleBuffer, + ); expect(viewModel.backdropGame?.id, game.id); }); @@ -188,7 +209,7 @@ void main() { }); } -class _FakeGamesApi implements GamesApi { +class _FakeGamesApi extends GamesApi { _FakeGamesApi({List? games}) : games = games ?? _defaultGames; int detailRequests = 0; @@ -215,7 +236,12 @@ class _FakeGamesApi implements GamesApi { Future> getGames( String libraryId, { String? system, - }) async => [...games]; + }) async => + // Unmodifiable, not a growable spread copy: the view model's defensive + // copy (List.of(...)) has no regression coverage + // otherwise, since a growable list here would silently tolerate a + // caller that mutated or held onto the API result directly. + List.unmodifiable(games); @override Future> getSystems(String libraryId) async => [ @@ -243,6 +269,20 @@ class _FakeGamesApi implements GamesApi { ); } + @override + Future setGameCoreOverride( + String libraryId, + String gameId, { + String? core, + }) async => null; + + @override + Future setGameBackendOverride( + String libraryId, + String gameId, { + String? backend, + }) async => null; + @override Future> getLibraries() async => const []; @@ -258,9 +298,11 @@ class _FakeGamesApi implements GamesApi { required String libraryId, required String gameId, required String core, + String? romFileName, String? biosId, String? gameName, bool includeSaveUrl = false, + String? saveId, }) => ''; @override diff --git a/test/playback/native_game_player_test.dart b/test/playback/native_game_player_test.dart new file mode 100644 index 000000000..961e871d3 --- /dev/null +++ b/test/playback/native_game_player_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:moonfin/playback/native_game_player_channel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const controlChannel = MethodChannel('moonfin/native_game_control'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(controlChannel, null); + }); + + test( + 'restart() propagates a restart_unavailable PlatformException to the caller', + () async { + // Mirrors the native side genuinely rejecting a restart for cores that + // don't support it. Regression test for the bug where _invoke()'s + // blanket `catch (_) {}` swallowed this before it could reach + // native_game_player_screen's _restart() handler, making the "Restart + // is not available for this core" message unreachable. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(controlChannel, (call) async { + if (call.method == 'restart') { + throw PlatformException(code: 'restart_unavailable'); + } + return null; + }); + + final player = NativeGamePlayerChannel(); + + await expectLater( + player.restart(), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'restart_unavailable', + ), + ), + ); + }, + ); + + test( + 'lifecycle calls other than restart() swallow platform errors', + () async { + // start/pause/resume/stop are fire-and-forget with no error handler + // downstream, so _invoke() must keep swallowing for them -- only + // restart() was carved out. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(controlChannel, (call) async { + throw PlatformException(code: 'boom'); + }); + + final player = NativeGamePlayerChannel(); + + await expectLater(player.start(), completes); + await expectLater(player.pause(), completes); + await expectLater(player.resume(), completes); + await expectLater(player.stop(), completes); + }, + ); +} diff --git a/test/ui/navigation/player_route_observer_test.dart b/test/ui/navigation/player_route_observer_test.dart index 489299bcf..0203a5f36 100644 --- a/test/ui/navigation/player_route_observer_test.dart +++ b/test/ui/navigation/player_route_observer_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_activity_gate.dart'; import 'package:moonfin/ui/navigation/app_router.dart'; // Guards the contract the Android TV surface fix depends on: the fullscreen @@ -12,6 +13,14 @@ Route _route(String? name) => pageBuilder: (_, a, b) => const SizedBox.shrink(), ); +// Real dialogs (showDialog) push a non-opaque PopupRoute -- unlike _route(), +// which builds an opaque PageRouteBuilder even when unnamed. +Route _popupRoute(String? name) => PageRouteBuilder( + settings: RouteSettings(name: name), + opaque: false, + pageBuilder: (_, a, b) => const SizedBox.shrink(), +); + void main() { test('player routes flip isPlayerActive; others do not', () { final observer = PlayerRouteObserver(); @@ -56,4 +65,70 @@ void main() { observer.didPop(audio, null); expect(observer.isPlayerActive.value, isFalse); }); + + test('artwork coverage follows only the effective top route', () { + final gate = RetroArtworkActivityGate(); + final observer = RetroArtworkRouteObserver(activityGate: gate); + final games = _route('/games/library/system/nes'); + final dialog = _route(null); + final player = _route('/game-player/library/game'); + + observer.didPush(games, null); + expect(gate.isRouteCovered, isFalse); + expect(gate.isOpen, isTrue); + + final detail = _route('/game/library/game'); + observer.didPush(detail, games); + expect(gate.isRouteCovered, isFalse); + observer.didPop(detail, games); + expect(gate.isRouteCovered, isFalse); + + final permit = gate.tryAcquire(RetroArtworkActivityKind.transfer)!; + observer.didPush(dialog, games); + expect(gate.isRouteCovered, isTrue); + expect(permit.signal.isCancelled, isTrue); + + // A hidden screen using the legacy/default owner cannot clear the + // observer's authoritative top-route coverage. + gate.setRouteCovered(false); + expect(gate.isRouteCovered, isTrue); + + observer.didPop(dialog, games); + expect(gate.isRouteCovered, isFalse); + expect(gate.isOpen, isTrue); + + gate.setGameplayActive(true); + observer.didPush(player, games); + expect(gate.isRouteCovered, isTrue); + expect(gate.isGameplayActive, isTrue); + + observer.didPop(player, games); + expect(gate.isRouteCovered, isFalse); + expect(gate.isOpen, isFalse); + + gate.setGameplayActive(false); + expect(gate.isOpen, isTrue); + }); + + test( + 'a dialog opened over a game route does not cover the artwork gate', + () { + final gate = RetroArtworkActivityGate(); + final observer = RetroArtworkRouteObserver(activityGate: gate); + final detail = _route('/game/library/game'); + + observer.didPush(detail, null); + expect(gate.isRouteCovered, isFalse); + + // "Choose player" is a showDialog -- a non-opaque route -- not a real + // navigation away from the detail screen. + final picker = _popupRoute(null); + observer.didPush(picker, detail); + expect(gate.isRouteCovered, isFalse); + expect(gate.isOpen, isTrue); + + observer.didPop(picker, detail); + expect(gate.isRouteCovered, isFalse); + }, + ); } diff --git a/test/ui/screens/games/game_detail_screen_test.dart b/test/ui/screens/games/game_detail_screen_test.dart new file mode 100644 index 000000000..db587a3f4 --- /dev/null +++ b/test/ui/screens/games/game_detail_screen_test.dart @@ -0,0 +1,981 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:jellyfin_preference/jellyfin_preference.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_activity_gate.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_cache.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_data_source.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_transport.dart'; +import 'package:moonfin/l10n/app_localizations.dart'; +import 'package:moonfin/preference/user_preferences.dart'; +import 'package:moonfin/ui/screens/games/game_detail_screen.dart'; +import 'package:moonfin/ui/widgets/game/retro_artwork_image.dart'; +import 'package:moonfin/util/game_cores.dart'; +import 'package:server_core/server_core.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MockMediaServerClient extends Mock implements MediaServerClient {} + +class _MockGamesApi extends Mock implements GamesApi {} + +/// Fully controllable [RetroArtworkDataSource] fake. [notifyChanged] lets a +/// test simulate an async artwork update completing while the widget tree is +/// otherwise idle, without a real network round trip. +class _FakeArtworkDataSource implements RetroArtworkDataSource { + final Map _images = + {}; + final Set _listeners = + {}; + RetroArtworkSystemSnapshot? _snapshot; + + void seedImage(String gameId, RetroArtworkSource source) { + _images[gameId] = RetroArtworkImageReference.manifest(source); + } + + void notifyChanged() { + for (final listener + in List.of(_listeners)) { + listener(); + } + } + + @override + RetroArtworkProtocol get protocol => RetroArtworkProtocol.manifest; + + @override + RetroArtworkSystemSnapshot? get snapshot => _snapshot; + + @override + void addSnapshotListener(RetroArtworkSnapshotListener listener) => + _listeners.add(listener); + + @override + void removeSnapshotListener(RetroArtworkSnapshotListener listener) => + _listeners.remove(listener); + + @override + Future refreshSystem({ + required String libraryId, + required String systemId, + }) async { + return _snapshot ??= RetroArtworkSystemSnapshot( + libraryId: libraryId, + systemId: systemId, + generation: 'gen-1', + ); + } + + @override + RetroArtworkImageReference? imageFor( + String gameId, { + String role = 'boxart', + }) => _images[gameId]; + + @override + Future submitActiveBandPriority( + Iterable orderedGameIds, { + Iterable roles = const ['boxart'], + int? planGeneration, + }) async {} + + @override + void reportImageFailure( + String gameId, { + String role = 'boxart', + int? statusCode, + }) {} + + @override + void reportImageLoaded(String gameId, {String role = 'boxart'}) {} + + @override + void onRouteCovered() {} + + @override + void onRouteReentered() {} + + @override + void onAppResumed() {} + + @override + void dispose() {} +} + +/// A transport whose HTTP layer is entirely in-memory, so this widget test +/// never touches the network. +class _FakeArtworkHttpClient implements RetroArtworkHttpClient { + @override + Future getBytes( + Uri uri, { + required RetroArtworkCancellationSignal cancellation, + }) async => Uint8List(0); + + @override + void close() {} +} + +/// Registers a [PreferenceStore] with [installedCores] recorded as +/// downloaded, so [nativeCoreReachable] reports true for any core mapped to +/// one of them. +Future _registerInstalledCores(List installedCores) async { + SharedPreferences.setMockInitialValues(const {}); + final store = PreferenceStore(); + await store.init(); + await store.setStringList(installedCoresPreferenceKey, installedCores); + GetIt.instance.registerSingleton(store); + GetIt.instance.registerSingleton(UserPreferences(store)); +} + +/// Builds a [GameDetail] fixture for these tests. Named defaults mirror +/// [GameDetail]'s own constructor defaults (null/const[]/false) for every +/// optional field EXCEPT the always-required identity fields, which default +/// to this file's common "BurgerTime" fixture purely for brevity. +/// +/// Deliberately does NOT default `recommendedCore`/`availableCores` to +/// non-null values: several tests below rely on the server *omitting* those +/// fields (e.g. the "predates backend overrides" regression guard), and a +/// convenience default here would silently paper over exactly the kind of +/// default-value gap this builder replaced 7+ near-identical literals to +/// stop hiding (see Task B1). +GameDetail _gameDetail({ + String id = 'game', + String title = 'BurgerTime', + String system = 'MAME', + String core = 'arcade', + String fileName = 'btime2.zip', + int sizeBytes = 1, + List bios = const [], + String? recommendedCore, + List availableCores = const [], + bool? supportsCoreOverrides, + bool supportsBackendOverrides = false, + String? userCoreOverride, + String? userBackendOverride, + String? coreCompatibilityReason, +}) => GameDetail( + id: id, + title: title, + system: system, + core: core, + fileName: fileName, + sizeBytes: sizeBytes, + bios: bios, + recommendedCore: recommendedCore, + availableCores: availableCores, + supportsCoreOverrides: supportsCoreOverrides, + supportsBackendOverrides: supportsBackendOverrides, + userCoreOverride: userCoreOverride, + userBackendOverride: userBackendOverride, + coreCompatibilityReason: coreCompatibilityReason, +); + +/// Stubs `setGameBackendOverride` to succeed with the detail already +/// carrying the EmulatorJS backend override, matching what picking a +/// non-native row commits server-side. Several picker tests below share this +/// exact response shape. +void _stubBackendOverrideToEmulatorJs(_MockGamesApi gamesApi) { + when( + () => gamesApi.setGameBackendOverride( + 'library', + 'game', + backend: 'emulatorjs', + ), + ).thenAnswer( + (_) async => _gameDetail( + system: 'Arcade', + recommendedCore: 'arcade', + availableCores: const ['arcade', 'mame'], + userBackendOverride: 'emulatorjs', + ), + ); +} + +/// Stubs `setGameCoreOverride` to succeed with the detail carrying both the +/// MAME core override and the EmulatorJS backend override set by +/// [_stubBackendOverrideToEmulatorJs], matching the "picking MAME forces +/// EmulatorJS" flow used by several picker tests. +void _stubCoreOverrideToMame(_MockGamesApi gamesApi) { + when( + () => gamesApi.setGameCoreOverride('library', 'game', core: 'mame'), + ).thenAnswer( + (_) async => _gameDetail( + system: 'Arcade', + core: 'mame', + recommendedCore: 'arcade', + userCoreOverride: 'mame', + userBackendOverride: 'emulatorjs', + availableCores: const ['arcade', 'mame'], + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _MockMediaServerClient client; + late _MockGamesApi gamesApi; + + setUp(() async { + await GetIt.instance.reset(); + client = _MockMediaServerClient(); + gamesApi = _MockGamesApi(); + + when(() => client.gamesApi).thenReturn(gamesApi); + when(() => gamesApi.getGame('library', 'game')).thenAnswer( + (_) async => _gameDetail( + recommendedCore: 'arcade', + availableCores: const ['arcade', 'mame'], + ), + ); + when(() => gamesApi.getSave(any())).thenAnswer((_) async => null); + when( + () => gamesApi.getGames(any(), system: any(named: 'system')), + ).thenAnswer((_) async => const []); + when( + () => gamesApi.thumbUrl( + libraryId: any(named: 'libraryId'), + gameId: any(named: 'gameId'), + kind: any(named: 'kind'), + ), + ).thenReturn('https://example.invalid/game-art'); + + GetIt.instance.registerSingleton(client); + }); + + tearDown(() async { + debugDefaultTargetPlatformOverride = null; + await GetIt.instance.reset(); + }); + + Future pumpDetailScreen(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const GameDetailScreen(libraryId: 'library', gameId: 'game'), + ), + ); + await tester.pump(); + await tester.pump(); + } + + testWidgets('related art stays on the detail screen legacy adapter', ( + tester, + ) async { + await _registerInstalledCores(const []); + when(() => gamesApi.getGames('library', system: 'MAME')).thenAnswer( + (_) async => const [ + GameSummary( + id: 'related', + title: 'Tapper', + system: 'MAME', + core: 'arcade', + fileName: 'tapper.zip', + ), + ], + ); + + await pumpDetailScreen(tester); + await tester.pump(); + + expect(find.text('More in MAME'), findsOneWidget); + verify( + () => gamesApi.thumbUrl( + libraryId: 'library', + gameId: 'related', + kind: 'boxart', + ), + ).called(greaterThan(0)); + }); + + testWidgets('Down from the app bar focuses the primary play action', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1280, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await pumpDetailScreen(tester); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(find.byType(BackButton), findsOneWidget); + expect( + FocusManager.instance.primaryFocus!.context! + .findAncestorWidgetOfExactType(), + isNotNull, + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + final playButton = tester.widget( + find.widgetWithText(FilledButton, 'Play'), + ); + expect(playButton.focusNode, isNotNull); + expect(playButton.focusNode!.hasFocus, isTrue); + }); + + testWidgets('selecting a core stores the override and updates the detail', ( + tester, + ) async { + // No PreferenceStore is registered, so neither 'arcade' nor 'mame' is a + // reachable native core here — both rows land on EmulatorJS, matching + // the picker's "only genuinely reachable outcomes" rule. + _stubBackendOverrideToEmulatorJs(gamesApi); + _stubCoreOverrideToMame(gamesApi); + + await pumpDetailScreen(tester); + + await tester.tap( + find.descendant( + of: find.byType(FilledButton), + matching: find.text('FBNeo'), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Choose player'), findsOneWidget); + final mameOption = find.descendant( + of: find.byType(AlertDialog), + matching: find.text('MAME'), + ); + expect(mameOption, findsOneWidget); + + await tester.tap(mameOption); + await tester.pump(); + + // Picking the MAME row must force both the backend and the core. + verify( + () => gamesApi.setGameBackendOverride( + 'library', + 'game', + backend: 'emulatorjs', + ), + ).called(1); + verify( + () => gamesApi.setGameCoreOverride('library', 'game', core: 'mame'), + ).called(1); + expect(find.text('MAME'), findsAtLeastNWidgets(1)); + }); + + testWidgets('keeps the detail current when core update fails after backend', ( + tester, + ) async { + _stubBackendOverrideToEmulatorJs(gamesApi); + when( + () => gamesApi.setGameCoreOverride('library', 'game', core: 'mame'), + ).thenThrow(StateError('core update failed')); + + await pumpDetailScreen(tester); + await tester.tap( + find.descendant( + of: find.byType(FilledButton), + matching: find.text('FBNeo'), + ), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.text('MAME'), + ), + ); + await tester.pump(); + + expect(find.text('WebView · EmulatorJS'), findsOneWidget); + expect(find.textContaining('Could not change player'), findsOneWidget); + }); + + testWidgets( + 'selecting a core from the details panel stores the override and updates the detail', + (tester) async { + _stubBackendOverrideToEmulatorJs(gamesApi); + _stubCoreOverrideToMame(gamesApi); + + await pumpDetailScreen(tester); + + // Scroll down to see the details panel + // Find _CoreDetailRow by its unique chevron_right icon (private class can't be referenced) + final coreDetailRowFinder = find.byIcon(Icons.chevron_right); + await tester.ensureVisible(coreDetailRowFinder); + await tester.pumpAndSettle(); + + // Tap the core row in the details panel + expect(coreDetailRowFinder, findsOneWidget); + await tester.tap(coreDetailRowFinder); + await tester.pumpAndSettle(); + + expect(find.text('Choose player'), findsOneWidget); + final mameOption = find.descendant( + of: find.byType(AlertDialog), + matching: find.text('MAME'), + ); + expect(mameOption, findsOneWidget); + + await tester.tap(mameOption); + await tester.pump(); + + verify( + () => gamesApi.setGameBackendOverride( + 'library', + 'game', + backend: 'emulatorjs', + ), + ).called(1); + verify( + () => gamesApi.setGameCoreOverride('library', 'game', core: 'mame'), + ).called(1); + expect(find.text('MAME'), findsAtLeastNWidgets(1)); + }, + ); + + testWidgets('core picker shows the server coreCompatibilityReason when present', ( + tester, + ) async { + when(() => gamesApi.getGame('library', 'game')).thenAnswer( + (_) async => _gameDetail( + recommendedCore: 'arcade', + availableCores: const ['arcade', 'mame'], + coreCompatibilityReason: + 'Validated against both installed FBNeo and MAME DATs; FBNeo is preferred.', + ), + ); + + await pumpDetailScreen(tester); + + await tester.tap( + find.descendant( + of: find.byType(FilledButton), + matching: find.text('FBNeo'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Choose player'), findsOneWidget); + expect( + find.text( + 'Validated against both installed FBNeo and MAME DATs; FBNeo is preferred.', + ), + findsOneWidget, + ); + }); + + group('generic (player, core) row generation', () { + testWidgets('1943 (arcade): native FBNeo installed produces three distinct, ' + 'unambiguous rows', (tester) async { + await _registerInstalledCores(['fbneo']); + + when(() => gamesApi.getGame('library', 'game')).thenAnswer( + (_) async => _gameDetail( + title: '1943', + fileName: '1943.zip', + recommendedCore: 'mame', + availableCores: const ['mame'], + ), + ); + when( + () => gamesApi.setGameBackendOverride( + 'library', + 'game', + backend: 'emulatorjs', + ), + ).thenAnswer( + (_) async => _gameDetail( + title: '1943', + core: 'mame', + recommendedCore: 'mame', + userCoreOverride: 'mame', + userBackendOverride: 'emulatorjs', + availableCores: const ['mame'], + fileName: '1943.zip', + ), + ); + when( + () => gamesApi.setGameCoreOverride('library', 'game', core: 'mame'), + ).thenAnswer( + (_) async => _gameDetail( + title: '1943', + core: 'mame', + recommendedCore: 'mame', + userCoreOverride: 'mame', + userBackendOverride: 'emulatorjs', + availableCores: const ['mame'], + fileName: '1943.zip', + ), + ); + + await pumpDetailScreen(tester); + + await tester.tap( + find.descendant( + of: find.byType(FilledButton), + matching: find.text('FBNeo'), + ), + ); + await tester.pumpAndSettle(); + + final dialog = find.byType(AlertDialog); + expect(dialog, findsOneWidget); + + // Exactly three rows: Native/FBNeo, EmulatorJS/MAME, EmulatorJS/FBNeo. + expect( + find.descendant(of: dialog, matching: find.text('Native core')), + findsOneWidget, + ); + expect( + find.descendant( + of: dialog, + matching: find.text('EmulatorJS (WebView)'), + ), + findsNWidgets(2), + ); + + // Not validated against FBNeo (only MAME is in availableCores). + expect( + find.descendant( + of: dialog, + matching: find.text( + 'This archive is not validated for FBNeo and may not launch correctly.', + ), + ), + findsNWidgets(2), + ); + + // MAME has no native mapping, so its recommendation lands on + // EmulatorJS only. + expect( + find.descendant(of: dialog, matching: find.text('MAME (Recommended)')), + findsOneWidget, + ); + + // Native FBNeo is the current effective outcome. + expect( + find.descendant( + of: dialog, + matching: find.byIcon(Icons.radio_button_checked), + ), + findsOneWidget, + ); + + // Selecting the MAME row must set backend to EmulatorJS explicitly. + await tester.tap( + find.descendant(of: dialog, matching: find.text('MAME (Recommended)')), + ); + await tester.pump(); + + verify( + () => gamesApi.setGameBackendOverride( + 'library', + 'game', + backend: 'emulatorjs', + ), + ).called(1); + verify( + () => gamesApi.setGameCoreOverride('library', 'game', core: 'mame'), + ).called(1); + }); + + testWidgets( + 'Joust (MAME-only arcade game): FBNeo installed but not validated ' + 'still produces a native FBNeo row and shows the picker', + (tester) async { + // MAME is the only core the server validated for this game. + await _registerInstalledCores(['fbneo']); + + when(() => gamesApi.getGame('library', 'game')).thenAnswer( + (_) async => _gameDetail( + title: 'Joust', + core: 'mame', + fileName: 'joustr.zip', + recommendedCore: 'mame', + availableCores: const ['mame'], + ), + ); + + await pumpDetailScreen(tester); + + expect(find.text('Choose player'), findsNothing); + await tester.tap( + find.descendant( + of: find.byType(FilledButton), + matching: find.text('MAME'), + ), + ); + await tester.pumpAndSettle(); + + final dialog = find.byType(AlertDialog); + expect(dialog, findsOneWidget); + + // Three rows: Native/FBNeo, EmulatorJS/MAME, EmulatorJS/FBNeo. + expect( + find.descendant(of: dialog, matching: find.text('Native core')), + findsOneWidget, + ); + expect( + find.descendant( + of: dialog, + matching: find.text('EmulatorJS (WebView)'), + ), + findsNWidgets(2), + ); + + // Only MAME was validated, so both FBNeo rows carry the warning and + // neither is tagged Recommended. + expect( + find.descendant( + of: dialog, + matching: find.text( + 'This archive is not validated for FBNeo and may not launch correctly.', + ), + ), + findsNWidgets(2), + ); + expect( + find.descendant( + of: dialog, + matching: find.text('FBNeo (Recommended)'), + ), + findsNothing, + ); + expect( + find.descendant( + of: dialog, + matching: find.text('MAME (Recommended)'), + ), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'Atari 2600: backend support produces native and EmulatorJS rows ' + 'without writing an arcade core override', + (tester) async { + await _registerInstalledCores(['stella']); + + when(() => gamesApi.getGame('library', 'game')).thenAnswer( + (_) async => _gameDetail( + title: 'Adventure', + system: 'Atari 2600', + core: 'atari2600', + fileName: 'adventure.a26', + supportsBackendOverrides: true, + ), + ); + when( + () => gamesApi.setGameBackendOverride( + 'library', + 'game', + backend: 'emulatorjs', + ), + ).thenAnswer( + (_) async => _gameDetail( + title: 'Adventure', + system: 'Atari 2600', + core: 'atari2600', + fileName: 'adventure.a26', + userBackendOverride: 'emulatorjs', + supportsBackendOverrides: true, + ), + ); + + await pumpDetailScreen(tester); + + // The main screen's core button shows the raw core id here since + // _coreLabel only special-cases the arcade family. + await tester.tap( + find.descendant( + of: find.byType(FilledButton), + matching: find.text('atari2600'), + ), + ); + await tester.pumpAndSettle(); + + final dialog = find.byType(AlertDialog); + expect(dialog, findsOneWidget); + + // Exactly two rows: one native, one EmulatorJS. + expect( + find.descendant(of: dialog, matching: find.text('Native core')), + findsOneWidget, + ); + expect( + find.descendant( + of: dialog, + matching: find.text('EmulatorJS (WebView)'), + ), + findsOneWidget, + ); + + // availableCores is empty, so neither row carries a warning. + expect(find.textContaining('not validated'), findsNothing); + + // Stella is mapped and installed, so native is Recommended. No + // friendly display name for 'atari2600', so the subtitle is the raw + // core id. + expect( + find.descendant( + of: dialog, + matching: find.text('atari2600 (Recommended)'), + ), + findsOneWidget, + ); + expect( + find.descendant(of: dialog, matching: find.text('atari2600')), + findsOneWidget, + ); + + // Exactly one row is selected overall. + expect( + find.descendant( + of: dialog, + matching: find.byIcon(Icons.radio_button_checked), + ), + findsOneWidget, + ); + + await tester.tap( + find.descendant( + of: dialog, + matching: find.text('EmulatorJS (WebView)'), + ), + ); + await tester.pump(); + + verify( + () => gamesApi.setGameBackendOverride( + 'library', + 'game', + backend: 'emulatorjs', + ), + ).called(1); + verifyNever( + () => gamesApi.setGameCoreOverride( + any(), + any(), + core: any(named: 'core'), + ), + ); + }, + ); + + testWidgets( + 'EmulatorJS row is offered when the server predates backend overrides ' + '(non-arcade, availableCores only)', + (tester) async { + // Regression guard: supportsBackendOverrides defaults to false with + // no fallback (unlike supportsCoreOverrides, which falls back to + // availableCores.isNotEmpty). A legacy server that has not rolled + // out backendOverrideSupported must still surface the EmulatorJS + // row wherever the core-override channel is usable. + await _registerInstalledCores(['stella']); + + when(() => gamesApi.getGame('library', 'game')).thenAnswer( + (_) async => _gameDetail( + title: 'Adventure', + system: 'Atari 2600', + core: 'atari2600', + fileName: 'adventure.a26', + availableCores: const ['atari2600'], + // supportsBackendOverrides intentionally omitted (defaults to + // false, same as GameDetail's own constructor default) to + // simulate a server that predates backendOverrideSupported. + ), + ); + + await pumpDetailScreen(tester); + + await tester.tap( + find.descendant( + of: find.byType(FilledButton), + matching: find.text('atari2600'), + ), + ); + await tester.pumpAndSettle(); + + final dialog = find.byType(AlertDialog); + expect(dialog, findsOneWidget); + + expect( + find.descendant( + of: dialog, + matching: find.text('EmulatorJS (WebView)'), + ), + findsOneWidget, + reason: 'a legacy server must not hide the browser backend', + ); + }, + ); + + testWidgets( + 'legacy game details do not expose unsupported core override actions', + (tester) async { + await _registerInstalledCores(['fbneo']); + when(() => gamesApi.getGame('library', 'game')).thenAnswer( + (_) async => + _gameDetail(title: 'Legacy arcade game', fileName: 'legacy.zip'), + ); + + await pumpDetailScreen(tester); + + expect(find.widgetWithText(FilledButton, 'FBNeo'), findsNothing); + expect(find.text('Choose player'), findsNothing); + verifyNever( + () => gamesApi.setGameBackendOverride( + any(), + any(), + backend: any(named: 'backend'), + ), + ); + verifyNever( + () => gamesApi.setGameCoreOverride( + any(), + any(), + core: any(named: 'core'), + ), + ); + }, + ); + + testWidgets( + 'without EmulatorJS (tvOS/Linux-like), a single reachable option keeps ' + 'the picker hidden', + (tester) async { + // Reset before the test body returns rather than via tearDown(): + // TestWidgetsFlutterBinding asserts debug variables are back to + // default before any tearDown callback runs. + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + await _registerInstalledCores(['fbneo']); + + when(() => gamesApi.getGame('library', 'game')).thenAnswer( + (_) async => _gameDetail( + title: '1943', + fileName: '1943.zip', + recommendedCore: 'mame', + availableCores: const ['mame'], + ), + ); + + await pumpDetailScreen(tester); + + // Only FBNeo is natively reachable, so the picker button is hidden. + expect(find.widgetWithText(FilledButton, 'FBNeo'), findsNothing); + expect(find.text('Choose player'), findsNothing); + + debugDefaultTargetPlatformOverride = null; + }, + ); + + testWidgets( + 'without EmulatorJS (tvOS/Linux-like), the picker still appears with ' + 'multiple reachable native options and renders only native rows', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + // Synthetic: two unrelated cores both installed, to exercise the + // "more than one native row" path with no WebView backend. + await _registerInstalledCores(['fceumm', 'snes9x']); + + when(() => gamesApi.getGame('library', 'game')).thenAnswer( + (_) async => _gameDetail( + title: 'Test Game', + system: 'NES', + core: 'nes', + fileName: 'test.nes', + recommendedCore: 'snes', + availableCores: const ['nes', 'snes'], + ), + ); + + await pumpDetailScreen(tester); + + await tester.tap( + find.descendant( + of: find.byType(FilledButton), + matching: find.text('nes'), + ), + ); + await tester.pumpAndSettle(); + + final dialog = find.byType(AlertDialog); + expect(dialog, findsOneWidget, reason: 'more than one reachable row'); + + // Both reachable rows are native; no EmulatorJS row at all since + // emulatorJsAvailable is false on this platform. + expect( + find.descendant(of: dialog, matching: find.text('Native core')), + findsNWidgets(2), + ); + expect( + find.descendant( + of: dialog, + matching: find.text('EmulatorJS (WebView)'), + ), + findsNothing, + ); + + debugDefaultTargetPlatformOverride = null; + }, + ); + }); + + testWidgets('artwork completing while idle still repaints the poster', ( + tester, + ) async { + final fakeDataSource = _FakeArtworkDataSource(); + final fakeTransport = RetroArtworkTransport( + httpClient: _FakeArtworkHttpClient(), + cache: RetroArtworkByteLruCache(), + activityGate: RetroArtworkActivityGate(), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: GameDetailScreen( + libraryId: 'library', + gameId: 'game', + debugArtworkDataSource: fakeDataSource, + debugArtworkTransport: fakeTransport, + debugArtworkActivityGate: RetroArtworkActivityGate(), + ), + ), + ); + await tester + .pumpAndSettle(); // settle: no frames pending, poster on fallback + + expect( + find.byType(RetroArtworkImage), + findsNothing, + reason: 'no artwork is seeded yet, so the fallback is shown', + ); + + // Seed the image the fake now has available, then fire the snapshot + // listener exactly as an async artwork completion would while the app + // is otherwise idle (no animation, nothing else pending). + fakeDataSource.seedImage( + 'game', + RetroArtworkSource( + serverIdentity: 'srv', + libraryId: 'library', + gameId: 'game', + role: 'boxart', + revision: 'r1', + state: RetroArtworkReadyState.thumbnailReady, + uri: Uri.parse('https://example.test/art/game.png'), + ), + ); + fakeDataSource.notifyChanged(); + await tester.pumpAndSettle(); + + expect( + find.byType(RetroArtworkImage), + findsWidgets, + reason: + 'scheduleFrame() must wake the idle binding so the queued ' + 'postFrameCallback actually runs and repaints the poster', + ); + }); +} diff --git a/test/ui/screens/games/game_library_screen_test.dart b/test/ui/screens/games/game_library_screen_test.dart new file mode 100644 index 000000000..dceba8ca2 --- /dev/null +++ b/test/ui/screens/games/game_library_screen_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:jellyfin_preference/jellyfin_preference.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:moonfin/l10n/app_localizations.dart'; +import 'package:moonfin/preference/user_preferences.dart'; +import 'package:moonfin/ui/screens/games/game_library_screen.dart'; +import 'package:moonfin/ui/theme/app_theme.dart'; +import 'package:moonfin_design/moonfin_design.dart'; +import 'package:server_core/server_core.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MockMediaServerClient extends Mock implements MediaServerClient {} + +class _MockGamesApi extends Mock implements GamesApi {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _MockMediaServerClient client; + late _MockGamesApi gamesApi; + + setUp(() async { + await GetIt.instance.reset(); + SharedPreferences.setMockInitialValues(const {}); + final store = PreferenceStore(); + await store.init(); + GetIt.instance.registerSingleton(store); + GetIt.instance.registerSingleton(UserPreferences(store)); + + client = _MockMediaServerClient(); + gamesApi = _MockGamesApi(); + when(() => client.gamesApi).thenReturn(gamesApi); + GetIt.instance.registerSingleton(client); + ThemeRegistry.setActiveById(ThemeRegistry.moonfinId); + }); + + tearDown(() => GetIt.instance.reset()); + + testWidgets('uses system metadata and never loads games for card previews', ( + tester, + ) async { + when(() => gamesApi.getSystems('retro')).thenAnswer( + (_) async => const [ + GameSystem( + id: 'nes', + name: 'Nintendo Entertainment System', + core: 'fceumm', + gameCount: 12, + ), + ], + ); + + await tester.binding.setSurfaceSize(const Size(1280, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.buildTheme(ThemeRegistry.active), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const GameLibraryScreen(libraryId: 'retro'), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.text('12 items'), findsOneWidget); + verify(() => gamesApi.getSystems('retro')).called(1); + verifyNever(() => gamesApi.getGames(any(), system: any(named: 'system'))); + }); +} diff --git a/test/ui/screens/games/game_system_screen_test.dart b/test/ui/screens/games/game_system_screen_test.dart new file mode 100644 index 000000000..15ee3b202 --- /dev/null +++ b/test/ui/screens/games/game_system_screen_test.dart @@ -0,0 +1,323 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:jellyfin_preference/jellyfin_preference.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_activity_gate.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_cache.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_data_source.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_transport.dart'; +import 'package:moonfin/l10n/app_localizations.dart'; +import 'package:moonfin/preference/user_preferences.dart'; +import 'package:moonfin/ui/screens/games/game_system_screen.dart'; +import 'package:moonfin/ui/widgets/game/game_poster_card.dart'; +import 'package:moonfin/ui/widgets/game/retro_artwork_image.dart'; +import 'package:server_core/server_core.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MockMediaServerClient extends Mock implements MediaServerClient {} + +class _MockGamesApi extends Mock implements GamesApi {} + +/// Fully controllable [RetroArtworkDataSource] fake. [onSubmitPriority], when +/// set, lets a test hold the priority hint's future open to prove artwork +/// fetches do not wait on it. +class _FakeArtworkDataSource implements RetroArtworkDataSource { + final Map _images = + {}; + final Set _listeners = + {}; + RetroArtworkSystemSnapshot? _snapshot; + + /// Optional hook a test can use to hold [submitActiveBandPriority] open. + Future Function()? onSubmitPriority; + int submitPriorityCallCount = 0; + + void seedImage(String gameId, RetroArtworkSource source) { + _images[gameId] = RetroArtworkImageReference.manifest(source); + } + + /// Fires the registered snapshot listener exactly as an async artwork + /// completion would, so a test can simulate that happening while the app + /// is otherwise idle (no animation, nothing else pending). + void notifyChanged() { + for (final listener + in List.of(_listeners)) { + listener(); + } + } + + @override + RetroArtworkProtocol get protocol => RetroArtworkProtocol.manifest; + + @override + RetroArtworkSystemSnapshot? get snapshot => _snapshot; + + @override + void addSnapshotListener(RetroArtworkSnapshotListener listener) => + _listeners.add(listener); + + @override + void removeSnapshotListener(RetroArtworkSnapshotListener listener) => + _listeners.remove(listener); + + @override + Future refreshSystem({ + required String libraryId, + required String systemId, + }) async { + return _snapshot ??= RetroArtworkSystemSnapshot( + libraryId: libraryId, + systemId: systemId, + generation: 'gen-1', + ); + } + + @override + RetroArtworkImageReference? imageFor( + String gameId, { + String role = 'boxart', + }) => _images[gameId]; + + @override + Future submitActiveBandPriority( + Iterable orderedGameIds, { + Iterable roles = const ['boxart'], + int? planGeneration, + }) async { + submitPriorityCallCount++; + final hook = onSubmitPriority; + if (hook != null) await hook(); + } + + @override + void reportImageFailure( + String gameId, { + String role = 'boxart', + int? statusCode, + }) {} + + @override + void reportImageLoaded(String gameId, {String role = 'boxart'}) {} + + @override + void onRouteCovered() {} + + @override + void onRouteReentered() {} + + @override + void onAppResumed() {} + + @override + void dispose() {} +} + +/// A transport whose HTTP layer is entirely in-memory, so widget tests never +/// touch the network, plus call counting on [load] to observe when artwork +/// fetches actually start. +class _RecordingArtworkTransport extends RetroArtworkTransport { + _RecordingArtworkTransport() + : super( + httpClient: _FakeArtworkHttpClient(), + cache: RetroArtworkByteLruCache(), + activityGate: RetroArtworkActivityGate(), + ); + + int loadCallCount = 0; + + @override + Future load(RetroArtworkSource source) { + loadCallCount++; + return super.load(source); + } +} + +class _FakeArtworkHttpClient implements RetroArtworkHttpClient { + @override + Future getBytes( + Uri uri, { + required RetroArtworkCancellationSignal cancellation, + }) async => Uint8List(0); + + @override + void close() {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _MockGamesApi gamesApi; + + setUp(() async { + await GetIt.instance.reset(); + SharedPreferences.setMockInitialValues(const {}); + final store = PreferenceStore(); + await store.init(); + GetIt.instance.registerSingleton(store); + GetIt.instance.registerSingleton(UserPreferences(store)); + + final client = _MockMediaServerClient(); + gamesApi = _MockGamesApi(); + when(() => client.gamesApi).thenReturn(gamesApi); + when( + () => gamesApi.getGames(any(), system: any(named: 'system')), + ).thenAnswer( + (_) async => const [ + GameSummary( + id: 'sonic', + title: 'Sonic', + system: 'sms', + core: 'genesis_plus_gx', + fileName: 'Sonic.sms', + ), + ], + ); + when(() => gamesApi.getSystems(any())).thenAnswer( + (_) async => const [ + GameSystem( + id: 'sms', + name: 'Master System', + core: 'genesis_plus_gx', + gameCount: 1, + ), + ], + ); + GetIt.instance.registerSingleton(client); + }); + + tearDown(() => GetIt.instance.reset()); + + testWidgets('loads games before constructing the selected-system grid', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const GameSystemScreen(libraryId: 'retro', systemId: 'sms'), + ), + ); + await tester.pump(); + await tester.pump(); + + verify(() => gamesApi.getGames('retro', system: 'sms')).called(1); + expect(find.byType(GamePosterCard), findsOneWidget); + }); + + testWidgets( + 'artwork loads start without waiting for the priority hint', + (tester) async { + final fakeDataSource = _FakeArtworkDataSource(); + final fakeTransport = _RecordingArtworkTransport(); + final source = RetroArtworkSource( + serverIdentity: 'srv', + libraryId: 'retro', + gameId: 'sonic', + role: 'boxart', + revision: 'r1', + state: RetroArtworkReadyState.thumbnailReady, + uri: Uri.parse('https://example.test/art/sonic.png'), + ); + fakeDataSource.seedImage('sonic', source); + + final priority = Completer(); + fakeDataSource.onSubmitPriority = () => priority.future; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: GameSystemScreen( + libraryId: 'retro', + systemId: 'sms', + debugArtworkDataSource: fakeDataSource, + debugArtworkTransport: fakeTransport, + // Production always supplies these together, and the card only + // builds its image when both are present. + debugArtworkActivityGate: RetroArtworkActivityGate(), + ), + ), + ); + // Let the games load, the debug data source get wired in, and the grid + // build the card that owns the transfer. + await tester.pump(); + await tester.pump(); + await tester.pump(); + // Past RetroArtworkImage.defaultSettleDelay. + await tester.pump(const Duration(milliseconds: 150)); + + expect( + fakeDataSource.submitPriorityCallCount, + greaterThan(0), + reason: 'the priority hint must still be requested', + ); + expect( + fakeTransport.loadCallCount, + greaterThan(0), + reason: 'artwork must not wait on the debounced priority round trip', + ); + + priority.complete(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets( + 'artwork completing while idle still repaints the grid', + (tester) async { + final fakeDataSource = _FakeArtworkDataSource(); + final fakeTransport = _RecordingArtworkTransport(); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: GameSystemScreen( + libraryId: 'retro', + systemId: 'sms', + debugArtworkDataSource: fakeDataSource, + debugArtworkTransport: fakeTransport, + debugArtworkActivityGate: RetroArtworkActivityGate(), + ), + ), + ); + await tester.pumpAndSettle(); // settle: no frames pending, no art yet + + expect( + find.byType(RetroArtworkImage), + findsNothing, + reason: 'no artwork is seeded yet', + ); + + // Seed the image the fake now has available, then fire the snapshot + // listener exactly as an async artwork completion would while the app + // is otherwise idle (no animation, nothing else pending). + fakeDataSource.seedImage( + 'sonic', + RetroArtworkSource( + serverIdentity: 'srv', + libraryId: 'retro', + gameId: 'sonic', + role: 'boxart', + revision: 'r1', + state: RetroArtworkReadyState.thumbnailReady, + uri: Uri.parse('https://example.test/art/sonic.png'), + ), + ); + fakeDataSource.notifyChanged(); + await tester.pumpAndSettle(); + + expect( + find.byType(RetroArtworkImage), + findsWidgets, + reason: + 'scheduleFrame() must wake the idle binding so the queued ' + 'postFrameCallback actually runs and repaints the grid', + ); + }, + ); +} diff --git a/test/ui/screens/playback/game_emulator_screen_test.dart b/test/ui/screens/playback/game_emulator_screen_test.dart new file mode 100644 index 000000000..2b0d6a7a5 --- /dev/null +++ b/test/ui/screens/playback/game_emulator_screen_test.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:playback_core/playback_core.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_activity_gate.dart'; +import 'package:moonfin/l10n/app_localizations.dart'; +import 'package:moonfin/ui/screens/playback/game_emulator_screen.dart'; +import 'package:server_core/server_core.dart'; +import 'package:wakelock_plus_platform_interface/wakelock_plus_platform_interface.dart'; + +class _MockMediaServerClient extends Mock implements MediaServerClient {} + +/// Real wakelock plugins talk to a native platform channel that has no +/// handler in the widget-test harness. `_exit()` awaits `WakelockPlus.disable()` +/// outside its own try/catch, so an unmocked plugin call here would throw and +/// prevent the pop this test is checking for -- unrelated to the bug under +/// test. Swap in a no-op platform implementation instead of a channel mock. +class _FakeWakelockPlatform extends WakelockPlusPlatformInterface { + @override + Future toggle({required bool enable}) async {} + + @override + Future get enabled async => false; +} + +/// Counts actual Navigator pops, distinct from pop *attempts*: the bug under +/// test is a second `context.pop()` reaching the Navigator, not merely a +/// second call to `_exit()`. +class _CountingObserver extends NavigatorObserver { + int pops = 0; + + @override + void didPop(Route route, Route? previousRoute) { + pops++; + super.didPop(route, previousRoute); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _MockMediaServerClient client; + + setUp(() async { + WakelockPlusPlatformInterface.instance = _FakeWakelockPlatform(); + // _exit() awaits _restoreSystemUi()'s SystemChrome calls before popping; + // without a handler these unmocked platform-channel calls would throw + // and, like the wakelock stub above, block the pop this test checks for. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + SystemChannels.platform, + (call) async => null, + ); + await GetIt.instance.reset(); + client = _MockMediaServerClient(); + // No games API: _prepare() takes its "server does not support games" + // error branch immediately, so the screen never builds the InAppWebView + // (that only happens once _playerUrl is set). The menu button and the + // pause overlay -- including Exit -- are always present regardless, which + // is all this regression test needs to reach _exit(). + when(() => client.gamesApi).thenReturn(null); + // Both game screens mix in GameAudioOwner, which resolves this. + GetIt.instance.registerSingleton(PlaybackArbiter()); + GetIt.instance.registerSingleton(client); + GetIt.instance.registerSingleton( + RetroArtworkActivityGate(), + ); + }); + + tearDown(() => GetIt.instance.reset()); + + testWidgets( + 'a second Exit tap arriving before the first completes pops only once', + (tester) async { + final observer = _CountingObserver(); + final router = GoRouter( + initialLocation: '/home', + observers: [observer], + routes: [ + GoRoute( + path: '/home', + builder: (context, state) => const Scaffold(body: Text('home')), + ), + GoRoute( + path: '/game', + builder: (context, state) => const GameEmulatorScreen( + libraryId: 'library', + gameId: 'game', + core: 'snes', + ), + ), + ], + ); + + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ), + ); + await tester.pumpAndSettle(); + + router.push('/game'); + await tester.pumpAndSettle(); + expect(find.byType(GameEmulatorScreen), findsOneWidget); + expect( + GetIt.instance().isGameplayActive, + isFalse, + ); + + // Open the pause overlay via the on-screen menu button (works on every + // platform/input device, unlike the gamepad combo). + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + expect(find.text('Exit'), findsOneWidget); + + // Two rapid taps on Exit, mirroring a gamepad auto-repeated confirm + // press reaching _exit() a second time before the first invocation's + // async persist/restore work has finished and popped the route. + await tester.tap(find.text('Exit')); + await tester.tap(find.text('Exit')); + await tester.pumpAndSettle(); + + expect(observer.pops, 1); + expect(find.byType(GameEmulatorScreen), findsNothing); + expect(find.text('home'), findsOneWidget); + expect( + GetIt.instance().isGameplayActive, + isFalse, + ); + }, + ); +} diff --git a/test/ui/screens/playback/game_playback_ui_test.dart b/test/ui/screens/playback/game_playback_ui_test.dart new file mode 100644 index 000000000..22f075938 --- /dev/null +++ b/test/ui/screens/playback/game_playback_ui_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:moonfin/ui/screens/playback/game_playback_ui.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('wrapGamePlaybackMenuSelection', () { + test('wraps forward and backward through index-driven menus', () { + expect(wrapGamePlaybackMenuSelection(2, 1, 3), 0); + expect(wrapGamePlaybackMenuSelection(0, -1, 3), 2); + expect(wrapGamePlaybackMenuSelection(1, 7, 3), 2); + }); + + test('keeps an empty menu on its safe default index', () { + expect(wrapGamePlaybackMenuSelection(4, -1, 0), 0); + }); + }); + + testWidgets('scroll helper reveals a selected row', (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 100, + child: ListView.builder( + controller: controller, + itemExtent: 20, + itemCount: 20, + itemBuilder: (_, index) => Text('Row $index'), + ), + ), + ), + ), + ); + + ensureGamePlaybackMenuSelectionVisible(controller, 8, rowExtent: 20); + await tester.pumpAndSettle(); + + expect(controller.offset, greaterThan(0)); + }); + + testWidgets('message helper uses the surrounding scaffold messenger', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (buildContext) { + context = buildContext; + return const SizedBox(); + }, + ), + ), + ), + ); + + showGamePlaybackMessage(context, 'Could not save state.'); + await tester.pump(); + + expect(find.text('Could not save state.'), findsOneWidget); + }); +} diff --git a/test/ui/screens/playback/native_game_player_screen_test.dart b/test/ui/screens/playback/native_game_player_screen_test.dart new file mode 100644 index 000000000..51a713f25 --- /dev/null +++ b/test/ui/screens/playback/native_game_player_screen_test.dart @@ -0,0 +1,345 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:playback_core/playback_core.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_activity_gate.dart'; +import 'package:moonfin/l10n/app_localizations.dart'; +import 'package:moonfin/playback/native_game_player.dart'; +import 'package:moonfin/ui/screens/playback/native_game_player_screen.dart'; +// Transitive via path_provider; not worth promoting to a direct pubspec.yaml +// dependency just for this test-only fake. +// ignore: depend_on_referenced_packages +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:server_core/server_core.dart'; +import 'package:wakelock_plus_platform_interface/wakelock_plus_platform_interface.dart'; + +class _MockMediaServerClient extends Mock implements MediaServerClient {} + +/// Real wakelock plugins talk to a native platform channel that has no +/// handler in the widget-test harness. See the identical stub in +/// game_emulator_screen_test.dart for why this is needed rather than a +/// channel mock. +class _FakeWakelockPlatform extends WakelockPlusPlatformInterface { + @override + Future toggle({required bool enable}) async {} + + @override + Future get enabled async => false; +} + +/// GameStorage resolves its directories through path_provider, which has no +/// real platform implementation in a widget test. Routes everything into a +/// throwaway temp directory instead of throwing MissingPluginException. +class _FakePathProviderPlatform extends PathProviderPlatform { + _FakePathProviderPlatform(this._root); + + final Directory _root; + + @override + Future getApplicationSupportPath() async => + '${_root.path}/support'; + + @override + Future getApplicationCachePath() async => '${_root.path}/cache'; +} + +/// Minimal GamesApi: only the calls _prepare() makes on the way to a live +/// texture are implemented meaningfully. downloadRom actually writes the ROM +/// bytes to disk so the "already downloaded" check that follows it succeeds. +class _FakeGamesApi extends GamesApi { + @override + Future> getLibraries() async => const []; + + @override + Future> getSystems(String libraryId) async => const []; + + @override + Future> getGames( + String libraryId, { + String? system, + }) async => const []; + + @override + Future getGame(String libraryId, String gameId) async => + const GameDetail( + id: 'game1', + title: 'Test Game', + system: 'snes', + core: 'snes', + fileName: 'game.sfc', + sizeBytes: 3, + bios: [], + ); + + @override + Future setGameCoreOverride( + String libraryId, + String gameId, { + String? core, + }) async => null; + + @override + Future setGameBackendOverride( + String libraryId, + String gameId, { + String? backend, + }) async => null; + + @override + String thumbUrl({ + required String libraryId, + required String gameId, + String kind = 'boxart', + }) => ''; + + @override + String playerUrl({ + required String libraryId, + required String gameId, + required String core, + String? romFileName, + String? biosId, + String? gameName, + bool includeSaveUrl = false, + String? saveId, + }) => ''; + + @override + Future downloadRom( + String libraryId, + String gameId, + String destPath, { + void Function(int received, int total)? onProgress, + }) async { + await File(destPath).writeAsBytes([1, 2, 3]); + } + + @override + Future downloadBios( + String libraryId, + String biosId, + String destPath, + ) async {} + + @override + Future?> getSave(String gameId, {String kind = 'state'}) async => + null; + + @override + Future putSave( + String gameId, + List data, { + String kind = 'state', + }) async {} +} + +/// Fake native player: lets the test drive _prepare() to a live texture and +/// then simulate a mid-game core crash by pushing an 'error' event, without a +/// native runner behind a method/event channel. +class _FakeNativeGamePlayer implements NativeGamePlayer { + final _eventsController = StreamController>.broadcast(); + int stopCount = 0; + + @override + Stream> get events => _eventsController.stream; + + void emitError(String message) { + _eventsController.add({'event': 'error', 'message': message}); + } + + void dispose() => _eventsController.close(); + + @override + Future load({ + required String core, + String? corePath, + required String romPath, + required String systemDir, + required String saveDir, + required String gameId, + Map? options, + }) async => const GameLoadInfo( + textureId: 7, + width: 256, + height: 224, + aspect: 4 / 3, + fps: 60, + sampleRate: 44100, + ); + + @override + Future start() async {} + @override + Future pause() async {} + @override + Future resume() async {} + @override + Future restart() async {} + @override + Future stop() async { + stopCount++; + } + + @override + Future saveState() async => null; + @override + Future loadState(Uint8List data) async => false; + @override + Future setFastForward(int factor) async {} + @override + Future pulseButton(int index, {int durationMs = 150}) async {} + @override + Future setInput(int port, int mask) async {} + @override + Future> getOptions() async => const []; + @override + Future setOption(String id, String value) async {} + @override + Future> getCurrentOptions() async => const {}; + @override + Future controllerCount() async => 1; +} + +/// Counts actual Navigator pops, distinct from pop *attempts* -- the bug +/// under test is a stranded route, not merely a call that never reaches it. +class _PopCountingObserver extends NavigatorObserver { + int pops = 0; + + @override + void didPop(Route route, Route? previousRoute) { + pops++; + super.didPop(route, previousRoute); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempRoot; + late _MockMediaServerClient client; + late _FakeNativeGamePlayer player; + + setUp(() async { + tempRoot = await Directory.systemTemp.createTemp('native_game_player_test'); + PathProviderPlatform.instance = _FakePathProviderPlatform(tempRoot); + WakelockPlusPlatformInterface.instance = _FakeWakelockPlatform(); + // _backOut() awaits _restoreSystemUi()'s SystemChrome calls before + // popping; without a handler these unmocked platform-channel calls would + // throw and block the pop this test checks for. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + SystemChannels.platform, + (call) async => null, + ); + + await GetIt.instance.reset(); + client = _MockMediaServerClient(); + when(() => client.gamesApi).thenReturn(_FakeGamesApi()); + // Both game screens mix in GameAudioOwner, which resolves this. + GetIt.instance.registerSingleton(PlaybackArbiter()); + GetIt.instance.registerSingleton(client); + GetIt.instance.registerSingleton( + RetroArtworkActivityGate(), + ); + player = _FakeNativeGamePlayer(); + }); + + tearDown(() async { + await GetIt.instance.reset(); + player.dispose(); + await tempRoot.delete(recursive: true); + }); + + testWidgets( + 'back button escapes the error screen after a mid-game fatal error', + (tester) async { + // macOS bundles its cores, so _prepare() skips the download-manager/ABI + // path entirely and goes straight from a resolved GamesApi through to + // _player.load() -- the shortest real route to a live texture. Reset in + // a finally block rather than addTearDown/tearDown: the binding's + // "foundation debug var" invariant check runs directly after this test + // body returns, before any package:test tearDown hook fires. + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + final observer = _PopCountingObserver(); + final router = GoRouter( + initialLocation: '/home', + observers: [observer], + routes: [ + GoRoute( + path: '/home', + builder: (context, state) => const Scaffold(body: Text('home')), + ), + GoRoute( + path: '/game', + builder: (context, state) => NativeGamePlayerScreen( + libraryId: 'lib1', + gameId: 'game1', + core: 'snes', + startFresh: true, + player: player, + ), + ), + ], + ); + + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ), + ); + await tester.pumpAndSettle(); + + router.push('/game'); + await tester.pump(); + // Not pumpAndSettle(): the loading screen's CircularProgressIndicator + // animates indefinitely, so it never "settles" on its own. _prepare() + // also does real (temp-dir-backed) file IO, which completes on the + // real event loop rather than flutter_test's fake clock -- runAsync() + // lets it actually progress between pumps that pick up the resulting + // setState calls, and the explicit duration on pump() lets the + // Cupertino-style route transition (macOS) finish too. + for (var i = 0; i < 60 && find.byType(Texture).evaluate().isEmpty; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(const Duration(milliseconds: 20)); + } + expect(find.byType(NativeGamePlayerScreen), findsOneWidget); + + // Reached a live-texture state through the real _prepare() flow + // (backed by the fake GamesApi/player above). Now fail the session + // the way a core crash does. + expect(find.byType(Texture), findsOneWidget); + + player.emitError('core crashed'); + await tester.pump(); + + expect(find.text('core crashed'), findsOneWidget); + expect(find.byType(Texture), findsNothing); + + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + + expect( + observer.pops, + 1, + reason: 'a fatal error must not strand the user on the error screen', + ); + expect(find.byType(NativeGamePlayerScreen), findsNothing); + expect(find.text('home'), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); +} diff --git a/test/ui/widgets/game/game_artwork_load_scheduler_test.dart b/test/ui/widgets/game/game_artwork_load_scheduler_test.dart deleted file mode 100644 index 0f9f025b2..000000000 --- a/test/ui/widgets/game/game_artwork_load_scheduler_test.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:moonfin/ui/widgets/game/game_artwork_load_scheduler.dart'; - -void main() { - test('orders nearby rows outward above then below the viewport', () { - expect( - gameArtworkLoadOrder( - firstIndex: 0, - lastIndexExclusive: 18, - visibleFirstIndex: 6, - visibleLastIndexExclusive: 12, - crossAxisCount: 3, - surroundingRows: 2, - ), - [ - 6, 7, 8, 9, 10, 11, // Visible rows. - 3, 4, 5, 12, 13, 14, // One row above, then one below. - 0, 1, 2, 15, 16, 17, // Two rows above, then two below. - ], - ); - }); - - test('limits submissions and advances as artwork finishes', () { - final scheduler = GameArtworkLoadScheduler(maxConcurrent: 2); - - scheduler.showViewport(const ['a', 'b', 'c'], priorityKey: 'b'); - - expect(scheduler.isEnabled('b'), isTrue); - expect(scheduler.isEnabled('a'), isTrue); - expect(scheduler.isEnabled('c'), isFalse); - - scheduler.markFinished('b', scheduler.generationFor('b')!); - - expect(scheduler.isEnabled('c'), isTrue); - }); - - test( - 'replaces pending work and ignores completions from an old viewport', - () { - final scheduler = GameArtworkLoadScheduler(maxConcurrent: 2); - scheduler.showViewport(const ['a', 'b', 'c']); - final oldGeneration = scheduler.generationFor('a')!; - - scheduler.showViewport(const ['z1', 'z2', 'z3'], priorityKey: 'z2'); - - expect(scheduler.isEnabled('c'), isFalse); - expect(scheduler.isEnabled('z2'), isTrue); - expect(scheduler.isEnabled('z1'), isTrue); - expect(scheduler.isEnabled('z3'), isFalse); - - scheduler.markFinished('a', oldGeneration); - expect(scheduler.isEnabled('z3'), isFalse); - - scheduler.markFinished('z2', scheduler.generationFor('z2')!); - expect(scheduler.isEnabled('z3'), isTrue); - }, - ); - - test('caps remembered finished keys but never drops an on-screen one', () { - final scheduler = GameArtworkLoadScheduler(maxConcurrent: 4, maxFinished: 3); - - void showAndFinish(List keys) { - scheduler.showViewport(keys); - for (final key in keys) { - final generation = scheduler.generationFor(key); - if (generation != null) scheduler.markFinished(key, generation); - } - } - - // 'persistent' stays visible in every viewport; the rest scroll away. - for (var batch = 0; batch < 20; batch++) { - showAndFinish(['persistent', 'a$batch', 'b$batch', 'c$batch']); - expect(scheduler.isEnabled('persistent'), isTrue); - } - - // The current viewport is still fully enabled after all the churn. - expect(scheduler.isEnabled('a19'), isTrue); - expect(scheduler.isEnabled('b19'), isTrue); - expect(scheduler.isEnabled('c19'), isTrue); - - // The remembered set is bounded by the on-screen keys rather than growing - // with every batch (20 * 4 = 80 keys would accumulate without the cap). - expect(scheduler.finishedCount, lessThanOrEqualTo(4)); - }); - - test('prioritizes current viewport keys ahead of nearby rows', () { - final scheduler = GameArtworkLoadScheduler(maxConcurrent: 4); - - scheduler.showViewport(const [ - 'current-1', - 'current-2', - 'current-3', - 'current-4', - 'above', - ], priorityKey: 'current-3'); - - expect(scheduler.isEnabled('current-3'), isTrue); - expect(scheduler.isEnabled('current-1'), isTrue); - expect(scheduler.isEnabled('current-2'), isTrue); - expect(scheduler.isEnabled('current-4'), isTrue); - expect(scheduler.isEnabled('above'), isFalse); - }); -} diff --git a/test/ui/widgets/game/game_system_card_test.dart b/test/ui/widgets/game/game_system_card_test.dart index 40ba2fb30..ed89faf14 100644 --- a/test/ui/widgets/game/game_system_card_test.dart +++ b/test/ui/widgets/game/game_system_card_test.dart @@ -1,7 +1,14 @@ +import 'dart:async'; +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_activity_gate.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_cache.dart'; +import 'package:moonfin/data/services/retro_artwork/retro_artwork_transport.dart'; import 'package:moonfin/l10n/app_localizations.dart'; import 'package:moonfin/ui/theme/app_theme.dart'; +import 'package:moonfin/ui/widgets/bounded_network_image.dart'; import 'package:moonfin/ui/widgets/game/game_system_card.dart'; import 'package:moonfin_design/moonfin_design.dart'; import 'package:server_core/server_core.dart'; @@ -9,8 +16,10 @@ import 'package:server_core/server_core.dart'; void main() { setUp(() => ThemeRegistry.setActiveById(ThemeRegistry.moonfinId)); - testWidgets('supports localized count, RTL, and large text', (tester) async { - await tester.pumpWidget( + // Shared stress conditions: RTL layout plus a large text scaler, the + // combination most likely to overflow the fixed-height card. + Future pumpUnderRtlAndLargeText(WidgetTester tester) { + return tester.pumpWidget( MaterialApp( theme: AppTheme.buildTheme(ThemeRegistry.active), localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -28,11 +37,217 @@ void main() { ), ), ); + } + + testWidgets('does not throw when RTL is combined with large text scaling', ( + tester, + ) async { + await pumpUnderRtlAndLargeText(tester); expect(tester.takeException(), isNull); + }); + + testWidgets('renders the localized item count under RTL and large text', ( + tester, + ) async { + await pumpUnderRtlAndLargeText(tester); + expect(find.text('707 items'), findsOneWidget); + }); + + testWidgets('mirrors the navigation chevron for right-to-left layouts', ( + tester, + ) async { + await pumpUnderRtlAndLargeText(tester); + expect(find.byIcon(Icons.chevron_left), findsOneWidget); }); + + group('artwork collage fallback', () { + Widget buildCard({ + required GameSystem system, + RetroArtworkTransport? transport, + RetroArtworkActivityGate? activityGate, + }) => MaterialApp( + theme: AppTheme.buildTheme(ThemeRegistry.active), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Center( + child: SizedBox( + width: 360, + height: 320, + child: GameSystemCard( + system: system, + gameCount: system.gameCount, + retroArtworkTransport: transport, + retroArtworkActivityGate: activityGate, + libraryId: transport == null ? null : 'retro', + serverIdentity: transport == null ? null : 'server-record-1', + onTap: () {}, + ), + ), + ), + ), + ); + + testWidgets( + 'invalid protocol-2 previews never fall through to network images', + (tester) async { + await tester.pumpWidget( + buildCard( + system: const GameSystem( + id: 'arcade', + name: 'Arcade', + core: 'arcade', + gameCount: 8, + previewArtwork: GameSystemPreviewArtwork( + selectionGeneration: 'selection-1', + panels: [ + GameSystemPreviewPanel( + gameId: 'ready-thumb', + artwork: GameArtworkDescriptor( + state: 'thumbnailReady', + url: '/artwork/ready-thumb', + ), + ), + GameSystemPreviewPanel( + gameId: 'pending', + artwork: GameArtworkDescriptor( + state: 'pending', + url: '/artwork/pending', + ), + ), + GameSystemPreviewPanel( + gameId: 'ready-original', + artwork: GameArtworkDescriptor( + state: 'originalReady', + url: '/artwork/ready-original', + ), + ), + ], + ), + ), + ), + ); + + expect(find.byType(BoundedNetworkImage), findsNothing); + expect(find.byIcon(Icons.videogame_asset), findsNWidgets(4)); + }, + ); + + testWidgets( + 'a legacy system with no preview uses all four deterministic placeholders', + (tester) async { + await tester.pumpWidget( + buildCard( + system: const GameSystem( + id: 'arcade', + name: 'Arcade', + core: 'arcade', + gameCount: 8, + ), + ), + ); + + expect(find.byType(BoundedNetworkImage), findsNothing); + expect(find.byIcon(Icons.videogame_asset), findsNWidgets(4)); + }, + ); + + testWidgets('a newer preview revision replaces the live source', ( + tester, + ) async { + final http = _TrackingArtworkHttpClient(); + final gate = RetroArtworkActivityGate(); + final transport = RetroArtworkTransport( + httpClient: http, + cache: RetroArtworkByteLruCache(maxBytes: 100), + activityGate: gate, + ); + addTearDown(transport.dispose); + + GameSystem system({ + required String state, + required String url, + required String revision, + }) => GameSystem( + id: 'arcade', + name: 'Arcade', + core: 'arcade', + gameCount: 1, + previewArtwork: GameSystemPreviewArtwork( + selectionGeneration: 'selection-$revision', + panels: [ + GameSystemPreviewPanel( + gameId: 'game', + artwork: GameArtworkDescriptor( + state: state, + url: url, + revision: revision, + ), + ), + ], + ), + ); + + await tester.pumpWidget( + buildCard( + system: system( + state: 'originalReady', + url: '/art/game/original', + revision: 'r1', + ), + transport: transport, + activityGate: gate, + ), + ); + // Past RetroArtworkImage.defaultSettleDelay, which holds the transfer + // back so a fling or fast letter change never issues it. + await tester.pump(const Duration(milliseconds: 150)); + expect(http.requested, contains(Uri.parse('/art/game/original'))); + + await tester.pumpWidget( + buildCard( + system: system( + state: 'thumbnailReady', + url: '/art/game/thumbnail', + revision: 'r2', + ), + transport: transport, + activityGate: gate, + ), + ); + await tester.pump(const Duration(milliseconds: 150)); + + expect(http.requested, contains(Uri.parse('/art/game/thumbnail'))); + expect(tester.takeException(), isNull); + }); + }); +} + +class _TrackingArtworkHttpClient implements RetroArtworkHttpClient { + final List requested = []; + + @override + Future getBytes( + Uri uri, { + required RetroArtworkCancellationSignal cancellation, + }) { + requested.add(uri); + final result = Completer(); + cancellation.addListener(() { + if (!result.isCompleted) { + result.completeError( + RetroArtworkCancelledException(cancellation.reason), + ); + } + }); + return result.future; + } + + @override + void close() {} } class _Card extends StatelessWidget { @@ -41,14 +256,12 @@ class _Card extends StatelessWidget { @override Widget build(BuildContext context) { return GameSystemCard( - libraryId: 'retro', system: const GameSystem( id: 'atari2600', name: 'Atari 2600 with a long localized name', core: 'stella', gameCount: 707, ), - games: const [], gameCount: 707, onTap: () {}, ); diff --git a/test/util/game_artwork_scope_sweep_test.dart b/test/util/game_artwork_scope_sweep_test.dart new file mode 100644 index 000000000..8804fbbf6 --- /dev/null +++ b/test/util/game_artwork_scope_sweep_test.dart @@ -0,0 +1,119 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:moonfin/util/game_artwork_cache.dart'; +import 'package:moonfin/util/tv_image_cache_io.dart'; + +void main() { + late Directory root; + final now = DateTime(2026, 1, 1, 12); + + setUp(() { + root = Directory.systemTemp.createTempSync('game_artwork_cache_test_'); + }); + + tearDown(() { + if (root.existsSync()) root.deleteSync(recursive: true); + }); + + Directory writeScope(String scope, int bytes, DateTime lastUsed) { + final key = gameArtworkCacheKeyForScope(scope); + final dir = Directory('${root.path}/$key')..createSync(); + final artwork = File('${dir.path}/art.png') + ..writeAsBytesSync(List.filled(bytes, 1)); + artwork.setLastModifiedSync(lastUsed); + final access = File('${dir.path}/.moonfin-scope-access') + ..writeAsStringSync(''); + access.setLastModifiedSync(lastUsed); + return dir; + } + + test('cache keys are deterministic and path safe', () { + final key = gameArtworkCacheKeyForScope('library/Arcade & MAME'); + + expect(key, startsWith('$gameArtworkCacheKey-')); + expect(key, isNot(contains('/'))); + expect(key, gameArtworkCacheKeyForScope('library/Arcade & MAME')); + }); + + test('does nothing below the global game-art budget', () async { + final arcade = writeScope( + 'library/Arcade', + 20, + now.subtract(const Duration(minutes: 2)), + ); + final sega = writeScope( + 'library/Sega', + 20, + now.subtract(const Duration(minutes: 1)), + ); + + final evicted = await evictInactiveGameArtworkCaches( + root, + budgetBytes: 64, + protectedCacheKeys: const {}, + now: now, + ); + + expect(evicted, isEmpty); + expect(arcade.existsSync(), isTrue); + expect(sega.existsSync(), isTrue); + }); + + test( + 'evicts the least-recent inactive system as a whole under pressure', + () async { + final arcade = writeScope( + 'library/Arcade', + 40, + now.subtract(const Duration(minutes: 3)), + ); + final sega = writeScope( + 'library/Sega', + 40, + now.subtract(const Duration(minutes: 2)), + ); + final nes = writeScope( + 'library/NES', + 40, + now.subtract(const Duration(minutes: 1)), + ); + + final evicted = await evictInactiveGameArtworkCaches( + root, + budgetBytes: 100, + protectedCacheKeys: const {}, + now: now, + ); + + expect(evicted, [gameArtworkCacheKeyForScope('library/Arcade')]); + expect(arcade.existsSync(), isFalse); + expect(sega.existsSync(), isTrue); + expect(nes.existsSync(), isTrue); + }, + ); + + test('never evicts the active system, even when it is the oldest', () async { + final arcade = writeScope( + 'library/Arcade', + 80, + now.subtract(const Duration(minutes: 3)), + ); + final sega = writeScope( + 'library/Sega', + 80, + now.subtract(const Duration(minutes: 2)), + ); + + final evicted = await evictInactiveGameArtworkCaches( + root, + budgetBytes: 100, + protectedCacheKeys: {gameArtworkCacheKeyForScope('library/Arcade')}, + now: now, + ); + + expect(evicted, [gameArtworkCacheKeyForScope('library/Sega')]); + expect(arcade.existsSync(), isTrue); + expect(sega.existsSync(), isFalse); + }); +} diff --git a/test/util/game_cores_test.dart b/test/util/game_cores_test.dart new file mode 100644 index 000000000..dd46e5f97 --- /dev/null +++ b/test/util/game_cores_test.dart @@ -0,0 +1,353 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:jellyfin_preference/jellyfin_preference.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:moonfin/util/game_cores.dart'; +import 'package:moonfin/preference/user_preferences.dart'; +import 'package:server_core/server_core.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MockGamesApi extends Mock implements GamesApi {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('resolveNativeGameBackend', () { + test('uses EmulatorJS when native emulation is disabled', () { + expect( + resolveNativeGameBackend( + nativeSelected: false, + nativeSupported: true, + emulatorAvailable: true, + nativeCoreAvailable: true, + ), + isFalse, + ); + }); + + test('uses native backend when its selected core is installed', () { + expect( + resolveNativeGameBackend( + nativeSelected: true, + nativeSupported: true, + emulatorAvailable: true, + nativeCoreAvailable: true, + ), + isTrue, + ); + }); + + test('falls back to EmulatorJS when downloadable core is absent', () { + expect( + resolveNativeGameBackend( + nativeSelected: true, + nativeSupported: true, + emulatorAvailable: true, + nativeCoreAvailable: false, + ), + isFalse, + ); + }); + + test('retains native route where EmulatorJS is unavailable', () { + expect( + resolveNativeGameBackend( + nativeSelected: true, + nativeSupported: false, + emulatorAvailable: false, + nativeCoreAvailable: false, + ), + isTrue, + ); + }); + }); + + group('core mappings', () { + test('every core id in the catalog is unique', () { + final coreIds = gameCoreCatalog.map((core) => core.coreId).toList(); + expect(coreIds.toSet(), hasLength(coreIds.length)); + }); + + test('every EmulatorJS system-core mapping in the catalog is unique', () { + final systemCores = gameCoreCatalog + .expand((core) => core.emulatorJsSystemCores) + .toList(); + expect(systemCores.toSet(), hasLength(systemCores.length)); + }); + + test('downloadableCores preserves the catalog order', () { + final coreIds = gameCoreCatalog.map((core) => core.coreId).toList(); + expect( + downloadableCores.map((core) => core.coreId), + orderedEquals(coreIds), + ); + }); + + test('MAME is never routed as an EmulatorJS system core', () { + final systemCores = gameCoreCatalog + .expand((core) => core.emulatorJsSystemCores) + .toList(); + expect(systemCores, isNot(contains('mame'))); + }); + + test('no Apple-bundled core requires JIT', () { + // Apple platforms cannot JIT in bundled/non-debug builds, so a core + // that needs it must never be marked bundledOnApple. + expect( + gameCoreCatalog + .where((core) => core.bundledOnApple) + .any((core) => core.needsJit), + isFalse, + ); + }); + + test('keeps established console systems mapped', () { + const expected = { + 'nes': 'fceumm', + 'snes': 'snes9x', + 'gb': 'gambatte', + 'gba': 'mgba', + 'segaMD': 'genesis_plus_gx', + 'segaMS': 'genesis_plus_gx', + 'segaGG': 'genesis_plus_gx', + 'atari2600': 'stella', + 'atari7800': 'prosystem', + 'lynx': 'handy', + 'ws': 'mednafen_wswan', + 'ngp': 'mednafen_ngp', + 'pce': 'mednafen_pce_fast', + 'vb': 'mednafen_vb', + 'psx': 'pcsx_rearmed', + 'n64': 'mupen64plus_next', + 'psp': 'ppsspp', + 'nds': 'melonds', + 'arcade': 'fbneo', + }; + + for (final entry in expected.entries) { + expect(libretroCoreId(entry.key), entry.value, reason: entry.key); + } + }); + + test('leaves MAME on EmulatorJS', () { + expect(libretroCoreId('mame'), isNull); + }); + + test('isArcadeFamilyCore recognizes both arcade core names', () { + expect(isArcadeFamilyCore('arcade'), isTrue); + expect(isArcadeFamilyCore('mame'), isTrue); + expect(isArcadeFamilyCore('nes'), isFalse); + }); + + test('Apple fetch scripts match the catalog bundle membership', () { + expect( + _defaultFetchCores('ios/game_host/fetch_cores.sh'), + appleBundledCores, + ); + expect( + _defaultFetchCores('tvos/scripts/cores/fetch_cores.sh'), + appleBundledCores, + ); + }); + + test('macOS fetch script matches the catalog bundle membership', () { + expect( + _defaultFetchCores('macos/game_host/fetch_cores.sh'), + macosBundledCores, + ); + }); + }); + + group('Android routing', () { + setUp(() async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + SharedPreferences.setMockInitialValues({ + 'pref_use_native_emulator': true, + }); + final store = PreferenceStore(); + await store.init(); + GetIt.instance.registerSingleton(store); + GetIt.instance.registerSingleton(UserPreferences(store)); + }); + + tearDown(() async { + debugDefaultTargetPlatformOverride = null; + await GetIt.instance.reset(); + }); + + test('falls back for Atari 2600 when Stella is not installed', () { + expect(usesNativeGameBackendFor('atari2600'), isFalse); + }); + + test('uses the native Atari 2600 core after installation', () async { + await GetIt.instance().setStringList( + installedCoresPreferenceKey, + ['stella'], + ); + + expect(usesNativeGameBackendFor('atari2600'), isTrue); + }); + + test('keeps MAME on EmulatorJS even when native is selected', () { + expect(usesNativeGameBackendFor('mame'), isFalse); + }); + + test('nativeCoreReachable ignores the native/EmulatorJS preference, unlike ' + 'usesNativeGameBackendFor', () async { + await GetIt.instance().setStringList( + installedCoresPreferenceKey, + ['stella'], + ); + // Even with EmulatorJS preferred, nativeCoreReachable must still say + // the native row is a real, selectable option. + await GetIt.instance().set( + UserPreferences.useNativeEmulator, + false, + ); + + expect(usesNativeGameBackendFor('atari2600'), isFalse); + expect(nativeCoreReachable('atari2600'), isTrue); + }); + + test('nativeCoreReachable is false for an uninstalled core', () { + expect(nativeCoreReachable('atari2600'), isFalse); + }); + + test('namespaces EmulatorJS save states by core', () { + expect(gameStateKey('opaque-token', 'arcade'), 'ejs-arcade-opaque-token'); + expect(gameStateKey('opaque-token', 'mame'), 'ejs-mame-opaque-token'); + }); + + test('forced EmulatorJS never shares a native save namespace', () { + expect( + gameStateKey('opaque-token', 'atari2600', forceEmulatorJs: true), + 'ejs-atari2600-opaque-token', + ); + }); + + test('recovers the old lr-gameId scheme for the native backend', () async { + await GetIt.instance().setStringList( + installedCoresPreferenceKey, + ['stella'], + ); + + expect( + legacyGameStateKey('opaque-token', 'atari2600'), + 'lr-opaque-token', + ); + }); + }); + + group('macOS routing', () { + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + }); + + tearDown(() async { + debugDefaultTargetPlatformOverride = null; + await GetIt.instance.reset(); + }); + + test('macOS does not claim an unbundled core is available', () { + expect(macosBundledCores.contains('fbneo'), isFalse); + expect(usesNativeGameBackendFor('arcade'), isFalse); + }); + + test('macOS still plays a core it actually bundles', () { + expect(macosBundledCores.contains('fceumm'), isTrue); + expect(usesNativeGameBackendFor('nes'), isTrue); + }); + }); + + group('legacyGameStateKey', () { + test('is the bare gameId for EmulatorJS-only cores', () { + // 'mame' has no libretro mapping, so it never routes through the + // native backend regardless of platform/preferences. + expect(legacyGameStateKey('opaque-token', 'mame'), 'opaque-token'); + }); + }); + + group('loadGameStateWithMigration', () { + late _MockGamesApi games; + + setUpAll(() { + registerFallbackValue([]); + }); + + setUp(() { + games = _MockGamesApi(); + }); + + test('returns the new-key save and never touches the legacy key', () async { + when( + () => games.getSave('ejs-arcade-game1'), + ).thenAnswer((_) async => [1, 2, 3]); + + final result = await loadGameStateWithMigration(games, 'game1', 'arcade'); + + expect(result, [1, 2, 3]); + verifyNever(() => games.getSave('game1')); + verifyNever(() => games.putSave(any(), any())); + }); + + test( + 'falls back to the legacy key on a miss and migrates it to the new key', + () async { + when( + () => games.getSave('ejs-arcade-game1'), + ).thenAnswer((_) async => null); + when(() => games.getSave('game1')).thenAnswer((_) async => [4, 5, 6]); + when( + () => games.putSave('ejs-arcade-game1', any()), + ).thenAnswer((_) async {}); + + final result = await loadGameStateWithMigration( + games, + 'game1', + 'arcade', + ); + + expect(result, [4, 5, 6]); + verify(() => games.putSave('ejs-arcade-game1', [4, 5, 6])).called(1); + }, + ); + + test( + 'returns null when neither the new nor the legacy key has a save', + () async { + when( + () => games.getSave('ejs-arcade-game1'), + ).thenAnswer((_) async => null); + when(() => games.getSave('game1')).thenAnswer((_) async => null); + + final result = await loadGameStateWithMigration( + games, + 'game1', + 'arcade', + ); + + expect(result, isNull); + verifyNever(() => games.putSave(any(), any())); + }, + ); + }); +} + +Set _defaultFetchCores(String relativePath) { + final script = File(relativePath).readAsStringSync(); + final declarations = RegExp( + r'(?:CORES|cores)=\(([^)]*)\)', + dotAll: true, + ).allMatches(script); + for (final declaration in declarations) { + final cores = RegExp( + r'[a-z0-9_]+', + ).allMatches(declaration.group(1)!).map((match) => match.group(0)!).toSet(); + if (cores.isNotEmpty) return cores; + } + + throw StateError('No default core declaration found in $relativePath.'); +} diff --git a/tvos/scripts/cores/fetch_cores.sh b/tvos/scripts/cores/fetch_cores.sh index e40113170..0cda35d85 100755 --- a/tvos/scripts/cores/fetch_cores.sh +++ b/tvos/scripts/cores/fetch_cores.sh @@ -10,7 +10,11 @@ set -euo pipefail CORES=("$@") if [ ${#CORES[@]} -eq 0 ]; then - CORES=(fceumm snes9x gambatte mgba genesis_plus_gx pcsx_rearmed) + # fbneo is the only arcade core available natively (MAME is EmulatorJS-only, + # and tvOS has no WebView). Interpreter-only, so it clears wrap_frameworks.sh's + # no-JIT gate. ~53 MB unpacked vs ~1 MB for the next biggest core -- keep in + # sync by hand with ios/game_host/fetch_cores.sh. + CORES=(fceumm snes9x gambatte mgba genesis_plus_gx pcsx_rearmed fbneo) fi BUILDBOT="https://buildbot.libretro.com/nightly/apple/tvos-arm64/latest" From 34c073986f016990f25e3fcab3a71017d5f6b34e Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Thu, 6 Aug 2026 23:17:51 -0400 Subject: [PATCH 5/9] feat(server): extend games API/models for artwork manifests and backend overrides --- .../server_core/lib/src/api/games_api.dart | 71 ++++ .../lib/src/api/moonbase_games_api.dart | 209 ++++++++++-- .../lib/src/models/games_models.dart | 321 ++++++++++++++++-- .../server_core/test/games_models_test.dart | 109 ++++++ pubspec.lock | 34 +- pubspec.yaml | 2 + test/data/moonbase_games_api_test.dart | 280 +++++++++++++++ 7 files changed, 980 insertions(+), 46 deletions(-) create mode 100644 packages/server_core/test/games_models_test.dart create mode 100644 test/data/moonbase_games_api_test.dart diff --git a/packages/server_core/lib/src/api/games_api.dart b/packages/server_core/lib/src/api/games_api.dart index a22cd0747..3165cd0fa 100644 --- a/packages/server_core/lib/src/api/games_api.dart +++ b/packages/server_core/lib/src/api/games_api.dart @@ -12,12 +12,77 @@ abstract class GamesApi { /// GET /Moonfin/Games/{libraryId}/Systems Future> getSystems(String libraryId); + /// GET /Moonfin/Games/ArtworkCapabilities. + /// + /// The default preserves protocol-1 behavior for non-Moonfin and legacy + /// implementations. A capable API overrides this with its server response. + Future getArtworkCapabilities() async => null; + + /// GET /Moonfin/Games/{libraryId}/ArtworkManifest?system=...&generation=... + /// + /// Null represents an unsupported protocol or an unchanged (304) manifest. + Future getArtworkManifest( + String libraryId, { + required String systemId, + String? knownGeneration, + Object? cancellationOwner, + }) async => null; + + /// POST /Moonfin/Games/{libraryId}/ArtworkPriority. + /// + /// The ordered hint only promotes deduplicated server work. Legacy APIs + /// intentionally ignore it. + Future submitArtworkPriority( + String libraryId, + GameArtworkPriorityRequest request, { + Object? cancellationOwner, + }) async {} + + /// Cancels active manifest and priority requests, when supported. + /// + /// Legacy implementations have no cancellable artwork metadata transport. + void cancelArtworkRequests({Object? cancellationOwner}) {} + + /// Resolves a server-provided artwork path for an image loader. + /// + /// Versioned artwork resources are authenticated just like the legacy thumb + /// endpoint, so implementations may append their access-token query value. + /// + /// This is legacy protocol-1 surface: it exists only for the unversioned + /// `/Thumb/{gameId}`-style path a pre-manifest server or client still uses. + /// Manifest-driven adapters (protocol 2+) must resolve artwork through + /// [GameArtworkDescriptor.url] instead, not through this method — that + /// descriptor URL is what carries the revision/versioning the manifest + /// protocol depends on. + String artworkUrl(String path) => path; + /// GET /Moonfin/Games/{libraryId}/Games?system=... Future> getGames(String libraryId, {String? system}); /// GET /Moonfin/Games/{libraryId}/Games/{gameId} Future getGame(String libraryId, String gameId); + /// PUT /Moonfin/Games/{libraryId}/Games/{gameId}/Core + /// + /// Stores the current user's arcade-core override. Passing null clears the + /// override and returns to the server's recommended core. The returned + /// detail includes the effective [GameDetail.core]. + Future setGameCoreOverride( + String libraryId, + String gameId, { + String? core, + }); + + /// PUT /Moonfin/Games/{libraryId}/Games/{gameId}/Backend + /// + /// Stores the user's player backend preference. `emulatorjs` forces the + /// EmulatorJS WebView; null restores normal native/fallback routing. + Future setGameBackendOverride( + String libraryId, + String gameId, { + String? backend, + }); + /// GET /Moonfin/Games/{libraryId}/Thumb/{gameId}: the game's art, fetched and cached /// by the plugin. [kind] is `boxart` (the poster), `snap` (an in-game shot) or /// `title`. Returns a URL even when the game has no art, so callers still need an @@ -29,13 +94,19 @@ abstract class GamesApi { }); /// Builds the authenticated EmulatorJS player shell URL with all params set. + /// + /// When a state URL is included, [saveId] identifies its server-side save + /// namespace. It defaults to [gameId] for callers that do not need a + /// core-specific state. String playerUrl({ required String libraryId, required String gameId, required String core, + String? romFileName, String? biosId, String? gameName, bool includeSaveUrl = false, + String? saveId, }); /// Streams GET /Moonfin/Games/{libraryId}/Rom/{gameId} to [destPath]. diff --git a/packages/server_core/lib/src/api/moonbase_games_api.dart b/packages/server_core/lib/src/api/moonbase_games_api.dart index e7734351d..fed950d8c 100644 --- a/packages/server_core/lib/src/api/moonbase_games_api.dart +++ b/packages/server_core/lib/src/api/moonbase_games_api.dart @@ -16,9 +16,16 @@ class MoonbaseGamesApi implements GamesApi { final String Function() _baseUrlProvider; final String? Function() _tokenProvider; final ServerType _serverType; + static final Object _defaultArtworkRequestOwner = Object(); + final Map> _artworkRequestTokens = + >{}; MoonbaseGamesApi( - this._dio, this._baseUrlProvider, this._tokenProvider, this._serverType); + this._dio, + this._baseUrlProvider, + this._tokenProvider, + this._serverType, + ); String get _base => _baseUrlProvider().replaceAll(RegExp(r'/+$'), ''); @@ -32,18 +39,126 @@ class MoonbaseGamesApi implements GamesApi { @override Future> getLibraries() async { final response = await _dio.get('/Moonfin/Games/Libraries'); - return _asList(response.data) - .map((m) => GameLibrary.fromJson(m)) - .toList(growable: false); + return _asList( + response.data, + ).map((m) => GameLibrary.fromJson(m)).toList(growable: false); } @override Future> getSystems(String libraryId) async { - final response = - await _dio.get('/Moonfin/Games/${Uri.encodeComponent(libraryId)}/Systems'); - return _asList(response.data) - .map((m) => GameSystem.fromJson(m)) - .toList(growable: false); + final response = await _dio.get( + '/Moonfin/Games/${Uri.encodeComponent(libraryId)}/Systems', + ); + return _asList( + response.data, + ).map((m) => GameSystem.fromJson(m)).toList(growable: false); + } + + @override + Future getArtworkCapabilities() async { + try { + final response = await _dio.get('/Moonfin/Games/ArtworkCapabilities'); + final data = response.data; + if (data is! Map) return null; + return GameArtworkCapabilities.fromJson(data.cast()); + } on DioException catch (error) { + if (error.response?.statusCode == 404) return null; + rethrow; + } + } + + @override + Future getArtworkManifest( + String libraryId, { + required String systemId, + String? knownGeneration, + Object? cancellationOwner, + }) async { + final owner = cancellationOwner ?? _defaultArtworkRequestOwner; + final cancelToken = _beginArtworkRequest(owner); + try { + final response = await _dio.get( + '/Moonfin/Games/${Uri.encodeComponent(libraryId)}/ArtworkManifest', + queryParameters: { + 'system': systemId, + if (knownGeneration != null && knownGeneration.isNotEmpty) + 'generation': knownGeneration, + }, + cancelToken: cancelToken, + options: Options( + validateStatus: (status) => status == 200 || status == 304, + ), + ); + if (response.statusCode == 304 || response.data is! Map) return null; + return GameArtworkManifest.fromJson( + (response.data as Map).cast(), + ); + } finally { + _endArtworkRequest(owner, cancelToken); + } + } + + @override + Future submitArtworkPriority( + String libraryId, + GameArtworkPriorityRequest request, { + Object? cancellationOwner, + }) async { + final owner = cancellationOwner ?? _defaultArtworkRequestOwner; + final cancelToken = _beginArtworkRequest(owner); + try { + await _dio.post( + '/Moonfin/Games/${Uri.encodeComponent(libraryId)}/ArtworkPriority', + data: request.toJson(), + cancelToken: cancelToken, + ); + } finally { + _endArtworkRequest(owner, cancelToken); + } + } + + @override + void cancelArtworkRequests({Object? cancellationOwner}) { + final active = cancellationOwner == null + ? [ + for (final tokens in _artworkRequestTokens.values) ...tokens, + ] + : _artworkRequestTokens[cancellationOwner]?.toList(growable: false) ?? + const []; + if (cancellationOwner == null) { + _artworkRequestTokens.clear(); + } else { + _artworkRequestTokens.remove(cancellationOwner); + } + for (final token in active) { + if (!token.isCancelled) { + token.cancel('Retro artwork activity was blocked'); + } + } + } + + CancelToken _beginArtworkRequest(Object owner) { + final cancelToken = CancelToken(); + (_artworkRequestTokens[owner] ??= {}).add(cancelToken); + return cancelToken; + } + + void _endArtworkRequest(Object owner, CancelToken cancelToken) { + final tokens = _artworkRequestTokens[owner]; + tokens?.remove(cancelToken); + if (tokens?.isEmpty ?? false) _artworkRequestTokens.remove(owner); + } + + @override + String artworkUrl(String path) { + if (path.isEmpty) return path; + final uri = Uri.tryParse(path); + final absolute = uri?.hasScheme == true + ? path + : path.startsWith('/') + ? '$_base$path' + : '$_base/$path'; + return _withApiKey(absolute); } @override @@ -53,9 +168,9 @@ class MoonbaseGamesApi implements GamesApi { queryParameters: (system != null && system.isNotEmpty) ? {'system': system} : null, ); - return _asList(response.data) - .map((m) => GameSummary.fromJson(m)) - .toList(growable: false); + return _asList( + response.data, + ).map((m) => GameSummary.fromJson(m)).toList(growable: false); } @override @@ -70,6 +185,40 @@ class MoonbaseGamesApi implements GamesApi { return null; } + @override + Future setGameCoreOverride( + String libraryId, + String gameId, { + String? core, + }) async { + final response = await _dio.put( + '/Moonfin/Games/${Uri.encodeComponent(libraryId)}/Games/${Uri.encodeComponent(gameId)}/Core', + data: {'core': core}, + ); + final data = response.data; + if (data is Map) { + return GameDetail.fromJson(data.cast()); + } + return null; + } + + @override + Future setGameBackendOverride( + String libraryId, + String gameId, { + String? backend, + }) async { + final response = await _dio.put( + '/Moonfin/Games/${Uri.encodeComponent(libraryId)}/Games/${Uri.encodeComponent(gameId)}/Backend', + data: {'backend': backend}, + ); + final data = response.data; + if (data is Map) { + return GameDetail.fromJson(data.cast()); + } + return null; + } + @override String thumbUrl({ required String libraryId, @@ -80,15 +229,21 @@ class MoonbaseGamesApi implements GamesApi { final id = Uri.encodeComponent(gameId); // The image loader requests this without Dio, so the token rides in the query the // same way it does for ROMs. + // artVersion=2 is a client-side cache-buster for CachedNetworkImage; the server ignores + // it. It was added after the Phase 2 TryGetCached thumbnail-resolution change so + // previously-cached (pre-change) art gets invalidated. Do not remove as dead/unused. return _withApiKey( - '$_base/Moonfin/Games/$lib/Thumb/$id?type=${Uri.encodeQueryComponent(kind)}', + '$_base/Moonfin/Games/$lib/Thumb/$id?type=${Uri.encodeQueryComponent(kind)}&artVersion=2', ); } - String _romUrl(String libraryId, String gameId) { + String _romUrl(String libraryId, String gameId, {String? fileName}) { final lib = Uri.encodeComponent(libraryId); final id = Uri.encodeComponent(gameId); - return _withApiKey('$_base/Moonfin/Games/$lib/Rom/$id'); + final suffix = fileName == null || fileName.isEmpty + ? '' + : '/${Uri.encodeComponent(fileName)}'; + return _withApiKey('$_base/Moonfin/Games/$lib/Rom/$id$suffix'); } String _biosUrl(String libraryId, String biosId) { @@ -115,7 +270,10 @@ class MoonbaseGamesApi implements GamesApi { @override Future downloadBios( - String libraryId, String biosId, String destPath) async { + String libraryId, + String biosId, + String destPath, + ) async { final lib = Uri.encodeComponent(libraryId); final id = Uri.encodeComponent(biosId); await _dio.download('/Moonfin/Games/$lib/Bios/$id', destPath); @@ -126,13 +284,21 @@ class MoonbaseGamesApi implements GamesApi { required String libraryId, required String gameId, required String core, + String? romFileName, String? biosId, String? gameName, bool includeSaveUrl = false, + String? saveId, }) { final params = { 'core': core, - 'rom': _romUrl(libraryId, gameId), + // Arcade cores identify a set by its ZIP filename. Other systems retain + // the legacy token-only route so their request behavior does not change. + 'rom': _romUrl( + libraryId, + gameId, + fileName: core == 'mame' || core == 'arcade' ? romFileName : null, + ), }; if (biosId != null && biosId.isNotEmpty) { params['bios'] = _biosUrl(libraryId, biosId); @@ -142,7 +308,7 @@ class MoonbaseGamesApi implements GamesApi { } if (includeSaveUrl) { params['save'] = _withApiKey( - '$_base/Moonfin/Games/Saves/${Uri.encodeComponent(gameId)}?kind=state', + '$_base/Moonfin/Games/Saves/${Uri.encodeComponent(saveId ?? gameId)}?kind=state', ); } final query = params.entries @@ -167,8 +333,11 @@ class MoonbaseGamesApi implements GamesApi { } @override - Future putSave(String gameId, List data, - {String kind = 'state'}) async { + Future putSave( + String gameId, + List data, { + String kind = 'state', + }) async { await _dio.put( '/Moonfin/Games/Saves/${Uri.encodeComponent(gameId)}', queryParameters: {'kind': kind}, diff --git a/packages/server_core/lib/src/models/games_models.dart b/packages/server_core/lib/src/models/games_models.dart index 2ec1ec1fc..b9129e186 100644 --- a/packages/server_core/lib/src/models/games_models.dart +++ b/packages/server_core/lib/src/models/games_models.dart @@ -1,6 +1,22 @@ // Models for the Moonbase plugin retro-games (EmulatorJS) API. // The plugin emits camelCase JSON (System.Text.Json [JsonPropertyName]). +/// A download target must be a single path segment. The server names these +/// files, so a traversal or absolute path here means the server is hostile or +/// compromised — reject rather than sanitize, matching the same decision made +/// for `game_id` in the native host's `lh_load`. +String sanitizeDownloadFileName(String raw) { + final name = raw.trim(); + if (name.isEmpty || name == '.' || name == '..') { + throw FormatException('Unusable download file name: "$raw"'); + } + if (name.contains('/') || name.contains('\\') || name.contains('\u0000')) { + throw FormatException( + 'Download file name must be a single segment: "$raw"'); + } + return name; +} + class GameLibrary { final String id; final String name; @@ -18,22 +34,224 @@ class GameSystem { final String name; final String core; final int gameCount; + final GameSystemPreviewArtwork? previewArtwork; const GameSystem({ required this.id, required this.name, required this.core, required this.gameCount, + this.previewArtwork, }); factory GameSystem.fromJson(Map json) => GameSystem( - id: (json['id'] as String?) ?? '', - name: (json['name'] as String?) ?? '', - core: (json['core'] as String?) ?? '', - gameCount: (json['gameCount'] as num?)?.toInt() ?? 0, + id: (json['id'] as String?) ?? '', + name: (json['name'] as String?) ?? '', + core: (json['core'] as String?) ?? '', + gameCount: _intValue(json['gameCount']) ?? 0, + previewArtwork: json['previewArtwork'] is Map + ? GameSystemPreviewArtwork.fromJson( + (json['previewArtwork'] as Map).cast(), + ) + : null, + ); +} + +/// Additive retro-artwork protocol support advertised by a Moonfin plugin. +/// A missing or malformed capability response is treated as protocol 1. +class GameArtworkCapabilities { + final int protocolVersion; + final bool manifest; + final bool versionedAssets; + final bool priorityHints; + final bool systemPreviews; + + const GameArtworkCapabilities({ + this.protocolVersion = 1, + this.manifest = false, + this.versionedAssets = false, + this.priorityHints = false, + this.systemPreviews = false, + }); + + bool get supportsManifest => protocolVersion >= 2 && manifest; + bool get supportsVersionedAssets => protocolVersion >= 2 && versionedAssets; + bool get supportsPriorityHints => protocolVersion >= 2 && priorityHints; + bool get supportsSystemPreviews => protocolVersion >= 2 && systemPreviews; + + factory GameArtworkCapabilities.fromJson(Map json) => + GameArtworkCapabilities( + protocolVersion: _intValue(json['protocolVersion']) ?? 1, + manifest: json['manifest'] is bool ? json['manifest'] as bool : false, + versionedAssets: json['versionedAssets'] is bool + ? json['versionedAssets'] as bool + : false, + priorityHints: json['priorityHints'] is bool + ? json['priorityHints'] as bool + : false, + systemPreviews: json['systemPreviews'] is bool + ? json['systemPreviews'] as bool + : false, + ); +} + +/// State and version of one server-owned game artwork artifact. +class GameArtworkDescriptor { + final String state; + final String? url; + final String? revision; + final int? retryAfterSeconds; + final int? refreshAfterSeconds; + + const GameArtworkDescriptor({ + required this.state, + this.url, + this.revision, + this.retryAfterSeconds, + this.refreshAfterSeconds, + }); + + bool get isRenderable => + (state == 'thumbnailReady' || state == 'originalReady') && + url != null && + url!.isNotEmpty; + + factory GameArtworkDescriptor.fromJson(Map json) => + GameArtworkDescriptor( + state: _stringValue(json['state']) ?? '', + url: _stringValue(json['url']), + revision: _stringValue(json['revision']), + retryAfterSeconds: _intValue(json['retryAfterSeconds']), + refreshAfterSeconds: _intValue(json['refreshAfterSeconds']), ); } +/// One game's artwork entry in a system-scoped manifest. +class GameArtworkManifestEntry { + final String gameId; + final Map artwork; + + const GameArtworkManifestEntry({required this.gameId, required this.artwork}); + + factory GameArtworkManifestEntry.fromJson(Map json) => + GameArtworkManifestEntry( + gameId: _stringValue(json['gameId']) ?? '', + artwork: _artworkByRole(json['artwork']), + ); +} + +/// Current artwork state for one system and one externally visible generation. +class GameArtworkManifest { + final String generation; + final List entries; + + const GameArtworkManifest({required this.generation, required this.entries}); + + factory GameArtworkManifest.fromJson( + Map json, + ) => GameArtworkManifest( + generation: _stringValue(json['generation']) ?? '', + entries: (_listValue(json['entries']) ?? const []) + .whereType() + .map( + (entry) => + GameArtworkManifestEntry.fromJson(entry.cast()), + ) + .toList(growable: false), + ); +} + +/// Ordered client hint that promotes existing server artwork work. +class GameArtworkPriorityRequest { + final String systemId; + final String knownGeneration; + final List items; + + const GameArtworkPriorityRequest({ + required this.systemId, + required this.knownGeneration, + required this.items, + }); + + Map toJson() => { + 'systemId': systemId, + 'knownGeneration': knownGeneration, + 'items': items.map((item) => item.toJson()).toList(growable: false), + }; +} + +/// One ordered game and its ordered semantic artwork roles in a priority hint. +class GameArtworkPriorityItem { + final String gameId; + final List roles; + + const GameArtworkPriorityItem({required this.gameId, required this.roles}); + + Map toJson() => {'gameId': gameId, 'roles': roles}; +} + +/// One ordered panel in a server-selected system preview. +class GameSystemPreviewPanel { + final String gameId; + final GameArtworkDescriptor artwork; + + const GameSystemPreviewPanel({required this.gameId, required this.artwork}); + + factory GameSystemPreviewPanel.fromJson(Map json) => + GameSystemPreviewPanel( + gameId: _stringValue(json['gameId']) ?? '', + artwork: json['artwork'] is Map + ? GameArtworkDescriptor.fromJson( + (json['artwork'] as Map).cast(), + ) + : const GameArtworkDescriptor(state: ''), + ); +} + +/// Stable server-selected artwork panels for a system card. +class GameSystemPreviewArtwork { + final String selectionGeneration; + final List panels; + + const GameSystemPreviewArtwork({ + required this.selectionGeneration, + required this.panels, + }); + + factory GameSystemPreviewArtwork.fromJson( + Map json, + ) => GameSystemPreviewArtwork( + selectionGeneration: _stringValue(json['selectionGeneration']) ?? '', + panels: (_listValue(json['panels']) ?? const []) + .whereType() + .map( + (panel) => + GameSystemPreviewPanel.fromJson(panel.cast()), + ) + .toList(growable: false), + ); +} + +String? _stringValue(Object? value) => value is String ? value : null; + +int? _intValue(Object? value) => + value is num && value.isFinite ? value.toInt() : null; + +List? _listValue(Object? value) => value is List ? value : null; + +Map _artworkByRole(Object? value) { + if (value is! Map) return const {}; + final artwork = {}; + for (final entry in value.entries) { + if (entry.key is String && entry.value is Map) { + artwork[entry.key as String] = GameArtworkDescriptor.fromJson( + (entry.value as Map).cast(), + ); + } + } + return artwork; +} + class GameSummary { final String id; final String title; @@ -81,6 +299,41 @@ class GameDetail { final String title; final String system; final String core; + + /// Server-selected core for this game before any per-user override is applied. + /// Present for arcade games when the server has compatibility data. + final String? recommendedCore; + + /// Compatible arcade cores the user may select. Empty on servers that do not + /// expose alternate-core support, and for non-arcade games. + final List availableCores; + + /// Whether the server explicitly advertised core-override support for this + /// detail response. Older plugins omit [availableCores], while a supporting + /// server can legitimately supply an empty list for a non-arcade game. + final bool? _coreOverridesAdvertised; + + bool get supportsCoreOverrides => + _coreOverridesAdvertised ?? availableCores.isNotEmpty; + + /// The server explicitly supports a per-game player choice between the + /// native libretro host and EmulatorJS. This is unrelated to the arcade-only + /// [availableCores] response field. + final bool supportsBackendOverrides; + + /// The current user's explicit core choice, or null when following the + /// server recommendation. + final String? userCoreOverride; + + /// Explicit player backend selection. `emulatorjs` forces the WebView even + /// when a native libretro core is available; null follows normal routing. + final String? userBackendOverride; + + /// Server-provided explanation for why [recommendedCore] (or the sole + /// available core) was chosen, e.g. "Validated against both installed + /// FBNeo and MAME DATs; FBNeo is preferred." Null when the server has no + /// compatibility data or doesn't expose this field. + final String? coreCompatibilityReason; final String fileName; final int sizeBytes; final List bios; @@ -104,6 +357,13 @@ class GameDetail { required this.fileName, required this.sizeBytes, required this.bios, + this.recommendedCore, + this.availableCores = const [], + bool? supportsCoreOverrides, + this.supportsBackendOverrides = false, + this.userCoreOverride, + this.userBackendOverride, + this.coreCompatibilityReason, this.genre, this.developer, this.publisher, @@ -113,27 +373,38 @@ class GameDetail { this.players, this.overview, this.rating, - }); + }) : _coreOverridesAdvertised = supportsCoreOverrides; factory GameDetail.fromJson(Map json) => GameDetail( - id: (json['id'] as String?) ?? '', - title: (json['title'] as String?) ?? '', - system: (json['system'] as String?) ?? '', - core: (json['core'] as String?) ?? '', - fileName: (json['fileName'] as String?) ?? '', - sizeBytes: (json['sizeBytes'] as num?)?.toInt() ?? 0, - bios: ((json['bios'] as List?) ?? const []) - .whereType() - .map((m) => GameBios.fromJson(m.cast())) - .toList(growable: false), - genre: json['genre'] as String?, - developer: json['developer'] as String?, - publisher: json['publisher'] as String?, - franchise: json['franchise'] as String?, - region: json['region'] as String?, - year: (json['year'] as num?)?.toInt(), - players: (json['players'] as num?)?.toInt(), - overview: json['overview'] as String?, - rating: (json['rating'] as num?)?.toDouble(), - ); + id: (json['id'] as String?) ?? '', + title: (json['title'] as String?) ?? '', + system: (json['system'] as String?) ?? '', + core: (json['core'] as String?) ?? '', + recommendedCore: json['recommendedCore'] as String?, + availableCores: ((json['availableCores'] as List?) ?? const []) + .whereType() + .toList(growable: false), + supportsCoreOverrides: json.containsKey('availableCores'), + supportsBackendOverrides: json['backendOverrideSupported'] is bool + ? json['backendOverrideSupported'] as bool + : false, + userCoreOverride: json['userCoreOverride'] as String?, + userBackendOverride: json['userBackendOverride'] as String?, + coreCompatibilityReason: json['coreCompatibilityReason'] as String?, + fileName: (json['fileName'] as String?) ?? '', + sizeBytes: (json['sizeBytes'] as num?)?.toInt() ?? 0, + bios: ((json['bios'] as List?) ?? const []) + .whereType() + .map((m) => GameBios.fromJson(m.cast())) + .toList(growable: false), + genre: json['genre'] as String?, + developer: json['developer'] as String?, + publisher: json['publisher'] as String?, + franchise: json['franchise'] as String?, + region: json['region'] as String?, + year: (json['year'] as num?)?.toInt(), + players: (json['players'] as num?)?.toInt(), + overview: json['overview'] as String?, + rating: (json['rating'] as num?)?.toDouble(), + ); } diff --git a/packages/server_core/test/games_models_test.dart b/packages/server_core/test/games_models_test.dart new file mode 100644 index 000000000..5313e588f --- /dev/null +++ b/packages/server_core/test/games_models_test.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; + +import 'package:server_core/server_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('GameDetail.fromJson', () { + test('parses coreCompatibilityReason when present', () { + final game = GameDetail.fromJson(const { + 'id': 'game', + 'title': 'BurgerTime', + 'system': 'MAME', + 'core': 'arcade', + 'fileName': 'btime2.zip', + 'sizeBytes': 1, + 'bios': [], + 'recommendedCore': 'arcade', + 'availableCores': ['arcade', 'mame'], + 'coreCompatibilityReason': + 'Validated against both installed FBNeo and MAME DATs; FBNeo is preferred.', + }); + + expect( + game.coreCompatibilityReason, + 'Validated against both installed FBNeo and MAME DATs; FBNeo is preferred.', + ); + expect(game.supportsCoreOverrides, isTrue); + }); + + test('non-arcade round-trip with no coreCompatibilityReason key yields null', () { + final game = GameDetail.fromJson(const { + 'id': 'game2', + 'title': 'Some Console Game', + 'system': 'SNES', + 'core': 'snes9x', + 'fileName': 'game2.sfc', + 'sizeBytes': 2048, + 'bios': [], + }); + + expect(game.coreCompatibilityReason, isNull); + expect(game.recommendedCore, isNull); + expect(game.availableCores, isEmpty); + expect(game.supportsCoreOverrides, isFalse); + expect(game.supportsBackendOverrides, isFalse); + }, + ); + + test('an explicit empty core list still advertises override support', () { + final game = GameDetail.fromJson(const { + 'id': 'game3', + 'title': 'Console Game', + 'system': 'SNES', + 'core': 'snes9x', + 'fileName': 'game3.sfc', + 'sizeBytes': 2048, + 'bios': [], + 'availableCores': [], + }); + + expect(game.availableCores, isEmpty); + expect(game.supportsCoreOverrides, isTrue); + }); + + test('backend override support is independent of arcade core support', () { + final game = GameDetail.fromJson(const { + 'id': 'game4', + 'title': 'Console Game', + 'system': 'NES', + 'core': 'nes', + 'fileName': 'game4.nes', + 'sizeBytes': 2048, + 'bios': [], + 'backendOverrideSupported': true, + }); + + expect(game.supportsCoreOverrides, isFalse); + expect(game.supportsBackendOverrides, isTrue); + }); + }); + + group('sanitizeDownloadFileName', () { + test('accepts an ordinary file name', () { + expect( + sanitizeDownloadFileName('Super Mario World.sfc'), + 'Super Mario World.sfc', + ); + }); + + for (final hostile in const [ + '../escape.bin', + '..\\escape.bin', + 'a/b.bin', + 'a\\b.bin', + '/etc/passwd', + 'C:\\windows\\system32\\drivers\\etc\\hosts', + '..', + '.', + '', + ]) { + test('rejects ${jsonEncode(hostile)}', () { + expect( + () => sanitizeDownloadFileName(hostile), + throwsA(isA()), + ); + }); + } + }); +} diff --git a/pubspec.lock b/pubspec.lock index f0ce9d69d..24a6e58fa 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1488,6 +1488,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointer_interceptor: + dependency: "direct main" + description: + name: pointer_interceptor + sha256: "57210410680379aea8b1b7ed6ae0c3ad349bfd56fe845b8ea934a53344b9d523" + url: "https://pub.dev" + source: hosted + version: "0.10.1+2" + pointer_interceptor_ios: + dependency: transitive + description: + name: pointer_interceptor_ios + sha256: "03c5fa5896080963ab4917eeffda8d28c90f22863a496fb5ba13bc10943e40e4" + url: "https://pub.dev" + source: hosted + version: "0.10.1+1" + pointer_interceptor_platform_interface: + dependency: transitive + description: + name: pointer_interceptor_platform_interface + sha256: "0597b0560e14354baeb23f8375cd612e8bd4841bf8306ecb71fcd0bb78552506" + url: "https://pub.dev" + source: hosted + version: "0.10.0+1" + pointer_interceptor_web: + dependency: transitive + description: + name: pointer_interceptor_web + sha256: "460b600e71de6fcea2b3d5f662c92293c049c4319e27f0829310e5a953b3ee2a" + url: "https://pub.dev" + source: hosted + version: "0.10.3" pool: dependency: transitive description: @@ -2226,7 +2258,7 @@ packages: source: hosted version: "1.3.3" wakelock_plus_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: wakelock_plus_platform_interface sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b diff --git a/pubspec.yaml b/pubspec.yaml index bda3a1a3f..0cd4d5c17 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -177,6 +177,7 @@ dependencies: sqflite_tvos: any firebase_core: ^4.11.0 firebase_messaging: ^16.4.1 + pointer_interceptor: ^0.10.1+2 dev_dependencies: flutter_test: @@ -190,6 +191,7 @@ dev_dependencies: drift_dev: ^2.28.0 mocktail: ^1.0.5 msix: ^3.16.12 + wakelock_plus_platform_interface: ^1.5.1 # Lets a test stand in for the Android side of path_provider. path_provider_platform_interface: ^2.1.2 plugin_platform_interface: ^2.1.8 diff --git a/test/data/moonbase_games_api_test.dart b/test/data/moonbase_games_api_test.dart new file mode 100644 index 000000000..13b38cef8 --- /dev/null +++ b/test/data/moonbase_games_api_test.dart @@ -0,0 +1,280 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_core/server_core.dart'; + +void main() { + MoonbaseGamesApi createApi() => MoonbaseGamesApi( + Dio(), + () => 'http://redstar.example:8101', + () => 'test-token', + ServerType.jellyfin, + ); + + for (final core in ['mame', 'arcade']) { + test('$core player URL preserves the original encoded ZIP filename', () { + final player = Uri.parse( + createApi().playerUrl( + libraryId: 'Retro Games', + gameId: 'opaque-token', + core: core, + romFileName: 'Atari Tetris (set 1).zip', + ), + ); + + final rom = Uri.parse(player.queryParameters['rom']!); + expect(rom.pathSegments.last, 'Atari Tetris (set 1).zip'); + expect( + Uri.decodeComponent(rom.path), + endsWith('/opaque-token/Atari Tetris (set 1).zip'), + ); + expect(rom.queryParameters['ApiKey'], 'test-token'); + }); + } + + test('non-arcade player URL retains the legacy token-only route', () { + final player = Uri.parse( + createApi().playerUrl( + libraryId: 'Retro Games', + gameId: 'opaque-token', + core: 'snes', + romFileName: 'Example Game.zip', + ), + ); + + final rom = Uri.parse(player.queryParameters['rom']!); + expect(rom.pathSegments.last, 'opaque-token'); + expect(rom.path, isNot(contains('Example Game.zip'))); + }); + + test('player URL uses the supplied core-specific save namespace', () { + final player = Uri.parse( + createApi().playerUrl( + libraryId: 'Retro Games', + gameId: 'opaque-token', + core: 'arcade', + includeSaveUrl: true, + saveId: 'ejs-arcade-opaque-token', + ), + ); + + final save = Uri.parse(player.queryParameters['save']!); + expect(save.path, endsWith('/Saves/ejs-arcade-opaque-token')); + expect(save.queryParameters['kind'], 'state'); + expect(save.queryParameters['ApiKey'], 'test-token'); + }); + + test( + 'artwork protocol models tolerate omitted and malformed optional fields', + () { + final system = GameSystem.fromJson({ + 'id': 'nes', + 'gameCount': 'not-a-number', + 'previewArtwork': { + 'selectionGeneration': 'inventory-7', + 'panels': [ + { + 'gameId': 'zelda', + 'artwork': { + 'state': 'thumbnailReady', + 'url': 42, + 'revision': 'rev-1', + 'retryAfterSeconds': 'not-a-number', + 'refreshAfterSeconds': {}, + }, + }, + {'gameId': 'empty', 'artwork': 'not-an-object'}, + ], + }, + }); + + expect(system.previewArtwork?.selectionGeneration, 'inventory-7'); + expect(system.gameCount, 0); + expect(system.previewArtwork?.panels, hasLength(2)); + expect(system.previewArtwork?.panels.first.artwork.isRenderable, isFalse); + expect( + system.previewArtwork?.panels.first.artwork.retryAfterSeconds, + isNull, + ); + expect( + system.previewArtwork?.panels.first.artwork.refreshAfterSeconds, + isNull, + ); + expect(system.previewArtwork?.panels.last.artwork.state, isEmpty); + + final manifest = GameArtworkManifest.fromJson({ + 'generation': 'artwork-7', + 'entries': [ + { + 'gameId': 'zelda', + 'artwork': { + 'boxart': {'state': 'thumbnailReady', 'url': '/art/box'}, + 'snap': {'state': 'missing'}, + 'title': 'malformed', + }, + }, + ], + }); + final capabilities = GameArtworkCapabilities.fromJson({ + 'protocolVersion': 2, + 'manifest': true, + 'versionedAssets': true, + 'priorityHints': true, + 'systemPreviews': true, + }); + final priority = GameArtworkPriorityRequest( + systemId: 'nes', + knownGeneration: 'artwork-7', + items: const [ + GameArtworkPriorityItem(gameId: 'zelda', roles: ['boxart', 'snap']), + ], + ); + + expect(manifest.entries.single.artwork.keys, ['boxart', 'snap']); + expect(manifest.entries.single.artwork['boxart']?.isRenderable, isTrue); + expect(capabilities.supportsManifest, isTrue); + expect(capabilities.supportsVersionedAssets, isTrue); + expect(capabilities.supportsPriorityHints, isTrue); + expect(capabilities.supportsSystemPreviews, isTrue); + expect(priority.toJson(), { + 'systemId': 'nes', + 'knownGeneration': 'artwork-7', + 'items': [ + { + 'gameId': 'zelda', + 'roles': ['boxart', 'snap'], + }, + ], + }); + expect(GameArtworkCapabilities.fromJson({}).supportsManifest, isFalse); + expect( + GameArtworkCapabilities.fromJson({ + 'protocolVersion': 'two', + }).supportsManifest, + isFalse, + ); + }, + ); + + test('versioned artwork URLs retain the authenticated server origin', () { + final artwork = Uri.parse( + createApi().artworkUrl('/Moonfin/Games/retro/Artwork/zelda/boxart/rev-1'), + ); + + expect(artwork.host, 'redstar.example'); + expect(artwork.path, '/Moonfin/Games/retro/Artwork/zelda/boxart/rev-1'); + expect(artwork.queryParameters['ApiKey'], 'test-token'); + }); + + test( + 'cancelArtworkRequests cancels active manifest and priority calls', + () async { + final adapter = _BlockingArtworkAdapter(); + final dio = Dio()..httpClientAdapter = adapter; + final api = MoonbaseGamesApi( + dio, + () => 'http://redstar.example:8101', + () => 'test-token', + ServerType.jellyfin, + ); + final manifest = api.getArtworkManifest('library', systemId: 'nes'); + final priority = api.submitArtworkPriority( + 'library', + const GameArtworkPriorityRequest( + systemId: 'nes', + knownGeneration: 'generation-1', + items: [], + ), + ); + final manifestExpectation = expectLater( + manifest, + throwsA(isA()), + ); + final priorityExpectation = expectLater( + priority, + throwsA(isA()), + ); + await adapter.bothStarted.future; + + api.cancelArtworkRequests(); + + await manifestExpectation; + await priorityExpectation; + await adapter.allCancelled.future; + expect(adapter.active, 0); + }, + ); + + test('artwork cancellation is isolated to its adapter owner', () async { + final adapter = _BlockingArtworkAdapter(); + final dio = Dio()..httpClientAdapter = adapter; + final api = MoonbaseGamesApi( + dio, + () => 'http://redstar.example:8101', + () => 'test-token', + ServerType.jellyfin, + ); + final manifestOwner = Object(); + final priorityOwner = Object(); + final manifest = api.getArtworkManifest( + 'library', + systemId: 'nes', + cancellationOwner: manifestOwner, + ); + final priority = api.submitArtworkPriority( + 'library', + const GameArtworkPriorityRequest( + systemId: 'nes', + knownGeneration: 'generation-1', + items: [], + ), + cancellationOwner: priorityOwner, + ); + final manifestExpectation = expectLater( + manifest, + throwsA(isA()), + ); + final priorityExpectation = expectLater( + priority, + throwsA(isA()), + ); + await adapter.bothStarted.future; + + api.cancelArtworkRequests(cancellationOwner: manifestOwner); + await manifestExpectation; + expect(adapter.active, 1); + + api.cancelArtworkRequests(cancellationOwner: priorityOwner); + await priorityExpectation; + await adapter.allCancelled.future; + expect(adapter.active, 0); + }); +} + +class _BlockingArtworkAdapter implements HttpClientAdapter { + final Completer bothStarted = Completer(); + final Completer allCancelled = Completer(); + int active = 0; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + active++; + if (active == 2 && !bothStarted.isCompleted) bothStarted.complete(); + try { + await cancelFuture; + } finally { + active--; + if (active == 0 && !allCancelled.isCompleted) allCancelled.complete(); + } + throw DioException(requestOptions: options, type: DioExceptionType.cancel); + } + + @override + void close({bool force = false}) {} +} From 8055bfb60e5c24b6c0d1445ac55c9430f149021c Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Thu, 6 Aug 2026 23:17:51 -0400 Subject: [PATCH 6/9] perf(android): skip the Watch Next background engine while the app is foregrounded --- .../org/moonfin/androidtv/WatchNextWorker.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/android/app/src/main/kotlin/org/moonfin/androidtv/WatchNextWorker.kt b/android/app/src/main/kotlin/org/moonfin/androidtv/WatchNextWorker.kt index fa74465d9..3a933ebca 100644 --- a/android/app/src/main/kotlin/org/moonfin/androidtv/WatchNextWorker.kt +++ b/android/app/src/main/kotlin/org/moonfin/androidtv/WatchNextWorker.kt @@ -1,5 +1,6 @@ package org.moonfin.androidtv +import android.app.ActivityManager import android.content.Context import android.os.Build import androidx.work.BackoffPolicy @@ -33,6 +34,15 @@ class WatchNextWorker( override suspend fun doWork(): Result { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return Result.success() if (!isTelevision(applicationContext)) return Result.success() + // While the app is foregrounded, WatchNextService.update() already + // publishes through MainActivity's channel, so this run would only + // duplicate that -- at the cost of building and destroying a second + // FlutterEngine on the main thread (see the Dispatchers.Main blocks + // below), which on Android is the same thread that drives the UI. + // Skipping loses nothing for the same reason, and it keeps the periodic + // schedule intact -- retrying here would instead re-poke the process on + // the backoff interval for as long as the app stays open. + if (isAppInForeground()) return Result.success() val done = CompletableDeferred() // Set when Dart reports a failure that retrying can't fix, so the @@ -128,6 +138,13 @@ class WatchNextWorker( } } + private fun isAppInForeground(): Boolean { + val state = ActivityManager.RunningAppProcessInfo() + ActivityManager.getMyMemoryState(state) + return state.importance <= + ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + } + // Gives up after a few attempts so a failing refresh can't retry forever. private fun retryOrFail(): Result = if (runAttemptCount >= MAX_RETRY_ATTEMPTS) Result.failure() else Result.retry() From 2b55d89911045f173f34bb8abc755fd43005ed08 Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Fri, 7 Aug 2026 09:28:50 -0400 Subject: [PATCH 7/9] Fix merge conflict screw-up. Revist and perhaps squash into fix(native) commit --- android/app/src/main/cpp/native_game_jni.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/android/app/src/main/cpp/native_game_jni.c b/android/app/src/main/cpp/native_game_jni.c index b4b25d938..0e9824886 100644 --- a/android/app/src/main/cpp/native_game_jni.c +++ b/android/app/src/main/cpp/native_game_jni.c @@ -178,6 +178,8 @@ static void fatal_error(void *user, const char *message) { (*env)->CallVoidMethod(env, c->bridge, c->on_error, jmessage); (*env)->DeleteLocalRef(env, jmessage); if (attached_here) (*c->vm)->DetachCurrentThread(c->vm); +} + static void core_message(void *user, const char *text) { native_ctx *c = (native_ctx *)user; if (!c->vm || !c->bridge || !c->on_core_message || !text) return; From 1039f85d1f2ccaa92ac22dec07d96352e1f13bf7 Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Sat, 8 Aug 2026 14:11:51 -0400 Subject: [PATCH 8/9] Add a more graceful failure path versus an NPE for cores that fail to load for whatever reason. --- .../kotlin/org/moonfin/androidtv/LibretroBridge.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/android/app/src/main/kotlin/org/moonfin/androidtv/LibretroBridge.kt b/android/app/src/main/kotlin/org/moonfin/androidtv/LibretroBridge.kt index 6c283c541..2ddd93726 100644 --- a/android/app/src/main/kotlin/org/moonfin/androidtv/LibretroBridge.kt +++ b/android/app/src/main/kotlin/org/moonfin/androidtv/LibretroBridge.kt @@ -163,6 +163,11 @@ class LibretroBridge( val av = nativeLoad(core, corePath, romPath, systemDir, saveDir, gameId, keys, values) if (av == null) { + // Handle load failures more gracefully. + // SurfaceTextureSurfaceProducer.release() unconditionally calls + // surface.release() with no null check, masking the real + // "load_failed" cause result with a crash. This "touches" it to avoid that. + producer.surface producer.release() surfaceProducer = null result.error("load_failed", null, null) @@ -204,6 +209,11 @@ class LibretroBridge( lastCoreMessage = null stopAudio() nativeStop() + // See the comment on the load() failure branch: release() NPEs inside the + // Flutter engine if .surface was never read first. A producer can reach + // here without ever having had its surface read -- e.g. one whose load() + // failed before nativeSetSurface(producer.surface) ran. + surfaceProducer?.surface surfaceProducer?.release() surfaceProducer = null portMask = 0 From d7f4fda9fa688a2f87eebac7573081c5239f4dc4 Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Sat, 8 Aug 2026 19:42:00 -0400 Subject: [PATCH 9/9] Fix for a REAL CUTE and absurd problem for Stella (Atari 2600) being unable to run games in native core. Stella requests a VFS interface and if it doesn't get it unconditionally reports every the ROM path as false... and thus blows up with Unrecognized ROM type. Added logging to emit WHY stella was blowing up in the 1st place. Then went down the libretro/stella rabbit-hole. This implements a VFS, leaned heavy on AI here for C help. Never a problem for EmulatorJS. Also added a "reset" button next to the emulator cores in settings -> playback -> cores. This is on the theory that if the user makes a setting change that prevents the core from starting - there is a reset path...which is what I thought may be causing this problem... but it wasn't but it's useful - though the UI design may not be all that good :-) --- lib/l10n/app_en.arb | 17 ++ lib/l10n/app_localizations.dart | 18 ++ lib/l10n/app_localizations_af.dart | 12 + lib/l10n/app_localizations_ar.dart | 12 + lib/l10n/app_localizations_be.dart | 12 + lib/l10n/app_localizations_bg.dart | 12 + lib/l10n/app_localizations_bn.dart | 12 + lib/l10n/app_localizations_ca.dart | 12 + lib/l10n/app_localizations_cs.dart | 12 + lib/l10n/app_localizations_cy.dart | 12 + lib/l10n/app_localizations_da.dart | 12 + lib/l10n/app_localizations_de.dart | 12 + lib/l10n/app_localizations_el.dart | 12 + lib/l10n/app_localizations_en.dart | 12 + lib/l10n/app_localizations_eo.dart | 12 + lib/l10n/app_localizations_es.dart | 12 + lib/l10n/app_localizations_et.dart | 12 + lib/l10n/app_localizations_fa.dart | 12 + lib/l10n/app_localizations_fi.dart | 12 + lib/l10n/app_localizations_fr.dart | 12 + lib/l10n/app_localizations_gl.dart | 12 + lib/l10n/app_localizations_he.dart | 12 + lib/l10n/app_localizations_hi.dart | 12 + lib/l10n/app_localizations_hr.dart | 12 + lib/l10n/app_localizations_hu.dart | 12 + lib/l10n/app_localizations_id.dart | 12 + lib/l10n/app_localizations_it.dart | 12 + lib/l10n/app_localizations_ja.dart | 12 + lib/l10n/app_localizations_kk.dart | 12 + lib/l10n/app_localizations_kn.dart | 12 + lib/l10n/app_localizations_ko.dart | 12 + lib/l10n/app_localizations_lt.dart | 12 + lib/l10n/app_localizations_lv.dart | 12 + lib/l10n/app_localizations_mk.dart | 12 + lib/l10n/app_localizations_ml.dart | 12 + lib/l10n/app_localizations_mn.dart | 12 + lib/l10n/app_localizations_nb.dart | 12 + lib/l10n/app_localizations_nl.dart | 12 + lib/l10n/app_localizations_pa.dart | 12 + lib/l10n/app_localizations_pl.dart | 12 + lib/l10n/app_localizations_pt.dart | 12 + lib/l10n/app_localizations_ro.dart | 12 + lib/l10n/app_localizations_ru.dart | 12 + lib/l10n/app_localizations_si.dart | 12 + lib/l10n/app_localizations_sk.dart | 12 + lib/l10n/app_localizations_sl.dart | 12 + lib/l10n/app_localizations_sq.dart | 12 + lib/l10n/app_localizations_sr.dart | 12 + lib/l10n/app_localizations_sv.dart | 12 + lib/l10n/app_localizations_sw.dart | 12 + lib/l10n/app_localizations_ta.dart | 12 + lib/l10n/app_localizations_te.dart | 12 + lib/l10n/app_localizations_th.dart | 12 + lib/l10n/app_localizations_tl.dart | 12 + lib/l10n/app_localizations_tr.dart | 12 + lib/l10n/app_localizations_ug.dart | 12 + lib/l10n/app_localizations_uk.dart | 12 + lib/l10n/app_localizations_vi.dart | 12 + lib/l10n/app_localizations_yue.dart | 12 + lib/l10n/app_localizations_zh.dart | 12 + .../playback/native_game_player_screen.dart | 9 +- .../settings/emulator_cores_screen.dart | 100 ++++--- lib/util/game_cores.dart | 11 + native/libretro_host/libretro_host.c | 248 +++++++++++++++++- 64 files changed, 1063 insertions(+), 36 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 37cef1756..192d076e5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -8155,6 +8155,23 @@ "emulatorCoreDownloading": "Downloading", "emulatorCoreUnavailable": "Not available for this device", "emulatorCoreDownloadFailed": "Could not download the core. Check your connection and try again.", + "emulatorCoreResetSettings": "Reset {system} settings to defaults", + "@emulatorCoreResetSettings": { + "description": "Semantic label for the button that resets one core's saved emulator settings", + "placeholders": { + "system": { + "type": "String" + } + } + }, + "emulatorCoreSettingsReset": "Settings reset to defaults.", + "@emulatorCoreSettingsReset": { + "description": "Snackbar shown after resetting a core's saved emulator settings" + }, + "emulatorCoreResetSettingsFailed": "Could not reset settings. Check your connection and try again.", + "@emulatorCoreResetSettingsFailed": { + "description": "Snackbar shown when resetting a core's saved emulator settings fails" + }, "downloadedGames": "Downloaded Games", "@downloadedGames": { "description": "Settings screen title for managing game files stored on the device" diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 44f4706f7..b64da2483 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -15244,6 +15244,24 @@ abstract class AppLocalizations { /// **'Could not download the core. Check your connection and try again.'** String get emulatorCoreDownloadFailed; + /// Semantic label for the button that resets one core's saved emulator settings + /// + /// In en, this message translates to: + /// **'Reset {system} settings to defaults'** + String emulatorCoreResetSettings(String system); + + /// Snackbar shown after resetting a core's saved emulator settings + /// + /// In en, this message translates to: + /// **'Settings reset to defaults.'** + String get emulatorCoreSettingsReset; + + /// Snackbar shown when resetting a core's saved emulator settings fails + /// + /// In en, this message translates to: + /// **'Could not reset settings. Check your connection and try again.'** + String get emulatorCoreResetSettingsFailed; + /// Settings screen title for managing game files stored on the device /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_af.dart b/lib/l10n/app_localizations_af.dart index e97af0ee7..600b81dbf 100644 --- a/lib/l10n/app_localizations_af.dart +++ b/lib/l10n/app_localizations_af.dart @@ -8556,6 +8556,18 @@ class AppLocalizationsAf extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index adf49f9de..b699dff1d 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -8536,6 +8536,18 @@ class AppLocalizationsAr extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_be.dart b/lib/l10n/app_localizations_be.dart index 9f82d8422..16a316dbd 100644 --- a/lib/l10n/app_localizations_be.dart +++ b/lib/l10n/app_localizations_be.dart @@ -8597,6 +8597,18 @@ class AppLocalizationsBe extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 950b5b0b2..953a9752f 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -8637,6 +8637,18 @@ class AppLocalizationsBg extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_bn.dart b/lib/l10n/app_localizations_bn.dart index 69a706ec7..4aa286a03 100644 --- a/lib/l10n/app_localizations_bn.dart +++ b/lib/l10n/app_localizations_bn.dart @@ -8535,6 +8535,18 @@ class AppLocalizationsBn extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_ca.dart b/lib/l10n/app_localizations_ca.dart index 6b66ea7ce..25f158562 100644 --- a/lib/l10n/app_localizations_ca.dart +++ b/lib/l10n/app_localizations_ca.dart @@ -8693,6 +8693,18 @@ class AppLocalizationsCa extends AppLocalizations { String get emulatorCoreDownloadFailed => 'No s\'ha pogut descarregar el nucli. Verifica la connexió i torna-ho a provar.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Jocs descarregats'; diff --git a/lib/l10n/app_localizations_cs.dart b/lib/l10n/app_localizations_cs.dart index 0867abe3a..1c9dbd76a 100644 --- a/lib/l10n/app_localizations_cs.dart +++ b/lib/l10n/app_localizations_cs.dart @@ -8581,6 +8581,18 @@ class AppLocalizationsCs extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_cy.dart b/lib/l10n/app_localizations_cy.dart index 84eb93fce..5f84ac07e 100644 --- a/lib/l10n/app_localizations_cy.dart +++ b/lib/l10n/app_localizations_cy.dart @@ -8600,6 +8600,18 @@ class AppLocalizationsCy extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_da.dart b/lib/l10n/app_localizations_da.dart index a419ee01b..747085c97 100644 --- a/lib/l10n/app_localizations_da.dart +++ b/lib/l10n/app_localizations_da.dart @@ -8547,6 +8547,18 @@ class AppLocalizationsDa extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index bd520d669..31f032943 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -8688,6 +8688,18 @@ class AppLocalizationsDe extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_el.dart b/lib/l10n/app_localizations_el.dart index 26a6c0326..3b955ac37 100644 --- a/lib/l10n/app_localizations_el.dart +++ b/lib/l10n/app_localizations_el.dart @@ -8686,6 +8686,18 @@ class AppLocalizationsEl extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Δεν ήταν δυνατή η λήψη του πυρήνα. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Ληφθέντα Παιχνίδια'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a7643a23c..691d080a2 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -8480,6 +8480,18 @@ class AppLocalizationsEn extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_eo.dart b/lib/l10n/app_localizations_eo.dart index 733da7611..416e1018e 100644 --- a/lib/l10n/app_localizations_eo.dart +++ b/lib/l10n/app_localizations_eo.dart @@ -8537,6 +8537,18 @@ class AppLocalizationsEo extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 525f13274..1fb001460 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -8650,6 +8650,18 @@ class AppLocalizationsEs extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_et.dart b/lib/l10n/app_localizations_et.dart index f029197e9..f426506b4 100644 --- a/lib/l10n/app_localizations_et.dart +++ b/lib/l10n/app_localizations_et.dart @@ -8559,6 +8559,18 @@ class AppLocalizationsEt extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_fa.dart b/lib/l10n/app_localizations_fa.dart index 530dbbe27..c787b31d0 100644 --- a/lib/l10n/app_localizations_fa.dart +++ b/lib/l10n/app_localizations_fa.dart @@ -8495,6 +8495,18 @@ class AppLocalizationsFa extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_fi.dart b/lib/l10n/app_localizations_fi.dart index 417df49cd..68308c253 100644 --- a/lib/l10n/app_localizations_fi.dart +++ b/lib/l10n/app_localizations_fi.dart @@ -8580,6 +8580,18 @@ class AppLocalizationsFi extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index b7d3cef12..a2ef5cec1 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -8667,6 +8667,18 @@ class AppLocalizationsFr extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Impossible de télécharger le core. Vérifiez votre connexion et réessayez.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Jeux téléchargés'; diff --git a/lib/l10n/app_localizations_gl.dart b/lib/l10n/app_localizations_gl.dart index d7f353ee8..b9abd7d27 100644 --- a/lib/l10n/app_localizations_gl.dart +++ b/lib/l10n/app_localizations_gl.dart @@ -8671,6 +8671,18 @@ class AppLocalizationsGl extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_he.dart b/lib/l10n/app_localizations_he.dart index f5ef79add..fded282c0 100644 --- a/lib/l10n/app_localizations_he.dart +++ b/lib/l10n/app_localizations_he.dart @@ -8434,6 +8434,18 @@ class AppLocalizationsHe extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 20cdfb19f..4f39bcaa4 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -8526,6 +8526,18 @@ class AppLocalizationsHi extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_hr.dart b/lib/l10n/app_localizations_hr.dart index 883bdbdb8..e92e1ad07 100644 --- a/lib/l10n/app_localizations_hr.dart +++ b/lib/l10n/app_localizations_hr.dart @@ -8765,6 +8765,18 @@ class AppLocalizationsHr extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index c82318137..274df3460 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -8631,6 +8631,18 @@ class AppLocalizationsHu extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 3db931e6b..c672b7999 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -8557,6 +8557,18 @@ class AppLocalizationsId extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index c0dc1ae92..55d3f233c 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -8617,6 +8617,18 @@ class AppLocalizationsIt extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Impossibile scaricare il core. Controlla la connessione e riprova.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Giochi scaricati'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 7f83e3e2a..7ff3b126a 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -8311,6 +8311,18 @@ class AppLocalizationsJa extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_kk.dart b/lib/l10n/app_localizations_kk.dart index 23dd8e8ca..f66675229 100644 --- a/lib/l10n/app_localizations_kk.dart +++ b/lib/l10n/app_localizations_kk.dart @@ -8591,6 +8591,18 @@ class AppLocalizationsKk extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_kn.dart b/lib/l10n/app_localizations_kn.dart index 584d9f634..f80f24930 100644 --- a/lib/l10n/app_localizations_kn.dart +++ b/lib/l10n/app_localizations_kn.dart @@ -8611,6 +8611,18 @@ class AppLocalizationsKn extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index ddd881a74..afa7f21b8 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -8299,6 +8299,18 @@ class AppLocalizationsKo extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_lt.dart b/lib/l10n/app_localizations_lt.dart index 808c34458..fd00b5719 100644 --- a/lib/l10n/app_localizations_lt.dart +++ b/lib/l10n/app_localizations_lt.dart @@ -8601,6 +8601,18 @@ class AppLocalizationsLt extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_lv.dart b/lib/l10n/app_localizations_lv.dart index 9e9d13dec..8eb15c061 100644 --- a/lib/l10n/app_localizations_lv.dart +++ b/lib/l10n/app_localizations_lv.dart @@ -8599,6 +8599,18 @@ class AppLocalizationsLv extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_mk.dart b/lib/l10n/app_localizations_mk.dart index c14375cdc..58909311c 100644 --- a/lib/l10n/app_localizations_mk.dart +++ b/lib/l10n/app_localizations_mk.dart @@ -8616,6 +8616,18 @@ class AppLocalizationsMk extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_ml.dart b/lib/l10n/app_localizations_ml.dart index 5a44c747c..2f2cd3e05 100644 --- a/lib/l10n/app_localizations_ml.dart +++ b/lib/l10n/app_localizations_ml.dart @@ -8656,6 +8656,18 @@ class AppLocalizationsMl extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_mn.dart b/lib/l10n/app_localizations_mn.dart index 8e048e898..28f0eefce 100644 --- a/lib/l10n/app_localizations_mn.dart +++ b/lib/l10n/app_localizations_mn.dart @@ -8570,6 +8570,18 @@ class AppLocalizationsMn extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_nb.dart b/lib/l10n/app_localizations_nb.dart index 0a29fdac3..8cd37a388 100644 --- a/lib/l10n/app_localizations_nb.dart +++ b/lib/l10n/app_localizations_nb.dart @@ -8545,6 +8545,18 @@ class AppLocalizationsNb extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Nedlastede spill'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 88d83ff5e..c8fe1a43a 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -8593,6 +8593,18 @@ class AppLocalizationsNl extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Het downloaden van de core is mislukt. Controleer uw internetverbinding en probeer het opnieuw.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Gedownloade spellen'; diff --git a/lib/l10n/app_localizations_pa.dart b/lib/l10n/app_localizations_pa.dart index eb790614f..e7bdd4b26 100644 --- a/lib/l10n/app_localizations_pa.dart +++ b/lib/l10n/app_localizations_pa.dart @@ -8517,6 +8517,18 @@ class AppLocalizationsPa extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 48cc0f796..c4565eee1 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -8598,6 +8598,18 @@ class AppLocalizationsPl extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Pobrane gry'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 32d53fa6c..640d8f594 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -8617,6 +8617,18 @@ class AppLocalizationsPt extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_ro.dart b/lib/l10n/app_localizations_ro.dart index 8e58222f9..98891182a 100644 --- a/lib/l10n/app_localizations_ro.dart +++ b/lib/l10n/app_localizations_ro.dart @@ -8620,6 +8620,18 @@ class AppLocalizationsRo extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 4ae1ad603..38b6b7fac 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -8620,6 +8620,18 @@ class AppLocalizationsRu extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_si.dart b/lib/l10n/app_localizations_si.dart index 16d20942a..9333d9c91 100644 --- a/lib/l10n/app_localizations_si.dart +++ b/lib/l10n/app_localizations_si.dart @@ -8541,6 +8541,18 @@ class AppLocalizationsSi extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 06734d8f5..d5759251d 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -8606,6 +8606,18 @@ class AppLocalizationsSk extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 41eb5d9fe..1adf03e23 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -8602,6 +8602,18 @@ class AppLocalizationsSl extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_sq.dart b/lib/l10n/app_localizations_sq.dart index db10863ee..cad71796e 100644 --- a/lib/l10n/app_localizations_sq.dart +++ b/lib/l10n/app_localizations_sq.dart @@ -8630,6 +8630,18 @@ class AppLocalizationsSq extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_sr.dart b/lib/l10n/app_localizations_sr.dart index 1dfb48b14..2fe9c26c1 100644 --- a/lib/l10n/app_localizations_sr.dart +++ b/lib/l10n/app_localizations_sr.dart @@ -8760,6 +8760,18 @@ class AppLocalizationsSr extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 0f4f1822e..04d13f3b9 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -8561,6 +8561,18 @@ class AppLocalizationsSv extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_sw.dart b/lib/l10n/app_localizations_sw.dart index fc6c4ebea..63181f12a 100644 --- a/lib/l10n/app_localizations_sw.dart +++ b/lib/l10n/app_localizations_sw.dart @@ -8619,6 +8619,18 @@ class AppLocalizationsSw extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_ta.dart b/lib/l10n/app_localizations_ta.dart index 35c0de9d8..49500d1cc 100644 --- a/lib/l10n/app_localizations_ta.dart +++ b/lib/l10n/app_localizations_ta.dart @@ -8619,6 +8619,18 @@ class AppLocalizationsTa extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_te.dart b/lib/l10n/app_localizations_te.dart index 367213cc6..b52a46b63 100644 --- a/lib/l10n/app_localizations_te.dart +++ b/lib/l10n/app_localizations_te.dart @@ -8612,6 +8612,18 @@ class AppLocalizationsTe extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_th.dart b/lib/l10n/app_localizations_th.dart index 6fdd54d95..ca9b067b5 100644 --- a/lib/l10n/app_localizations_th.dart +++ b/lib/l10n/app_localizations_th.dart @@ -8491,6 +8491,18 @@ class AppLocalizationsTh extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_tl.dart b/lib/l10n/app_localizations_tl.dart index af7bdd8e5..53916acdc 100644 --- a/lib/l10n/app_localizations_tl.dart +++ b/lib/l10n/app_localizations_tl.dart @@ -8644,6 +8644,18 @@ class AppLocalizationsTl extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 297a7a5eb..63d7ab331 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -8562,6 +8562,18 @@ class AppLocalizationsTr extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Çekirdek indirilemedi. Bağlantınızı kontrol edip tekrar deneyin.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'İndirilen Oyunlar'; diff --git a/lib/l10n/app_localizations_ug.dart b/lib/l10n/app_localizations_ug.dart index c4f515b26..5865acbb1 100644 --- a/lib/l10n/app_localizations_ug.dart +++ b/lib/l10n/app_localizations_ug.dart @@ -8578,6 +8578,18 @@ class AppLocalizationsUg extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 8cf6a3923..bcc0b7f18 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -8621,6 +8621,18 @@ class AppLocalizationsUk extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_vi.dart b/lib/l10n/app_localizations_vi.dart index f8d635e8b..4fb4ad9c0 100644 --- a/lib/l10n/app_localizations_vi.dart +++ b/lib/l10n/app_localizations_vi.dart @@ -8549,6 +8549,18 @@ class AppLocalizationsVi extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_yue.dart b/lib/l10n/app_localizations_yue.dart index 8e16bf0fd..adb3387b0 100644 --- a/lib/l10n/app_localizations_yue.dart +++ b/lib/l10n/app_localizations_yue.dart @@ -8250,6 +8250,18 @@ class AppLocalizationsYue extends AppLocalizations { String get emulatorCoreDownloadFailed => 'Could not download the core. Check your connection and try again.'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => 'Downloaded Games'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index c7b650c16..560ea918f 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -8237,6 +8237,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get emulatorCoreDownloadFailed => '内核下载失败,请检查网络连接后重试。'; + @override + String emulatorCoreResetSettings(String system) { + return 'Reset $system settings to defaults'; + } + + @override + String get emulatorCoreSettingsReset => 'Settings reset to defaults.'; + + @override + String get emulatorCoreResetSettingsFailed => + 'Could not reset settings. Check your connection and try again.'; + @override String get downloadedGames => '已下载游戏'; diff --git a/lib/ui/screens/playback/native_game_player_screen.dart b/lib/ui/screens/playback/native_game_player_screen.dart index 94950310d..8f2f382ff 100644 --- a/lib/ui/screens/playback/native_game_player_screen.dart +++ b/lib/ui/screens/playback/native_game_player_screen.dart @@ -830,17 +830,18 @@ class _NativeGamePlayerScreenState extends State /// /// The most common trigger today is a hardware-rendered core: the host /// answers RETRO_ENVIRONMENT_SET_HW_RENDER with false, and cores with no - /// software renderer (Nintendo 64's mupen64plus_next above all) fail their - /// content load outright. See buglog bug-032. + /// software renderer (e.g. Nintendo 64's mupen64plus_next) fail their + /// content load outright. String _startFailureMessage(Object error) { if (error is PlatformException) { switch (error.code) { case 'core_missing': return 'The core for this system is not included in this build.'; case 'load_failed': - return 'This game cannot be played with the native core. ' + return 'This game cannot be played with the native core.\n' 'Open the game\'s details screen and switch it to ' - '"EmulatorJS (WebView)", then try again.'; + '"EmulatorJS (WebView)".\nYou may also try resetting this core\'s settings in ' + 'Settings > Playback > Emulator Cores and try again.'; } } return 'Could not start this game. ($error)'; diff --git a/lib/ui/screens/settings/emulator_cores_screen.dart b/lib/ui/screens/settings/emulator_cores_screen.dart index 1af336eb4..b547d7bd6 100644 --- a/lib/ui/screens/settings/emulator_cores_screen.dart +++ b/lib/ui/screens/settings/emulator_cores_screen.dart @@ -3,12 +3,14 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:jellyfin_preference/jellyfin_preference.dart'; import 'package:moonfin_design/moonfin_design.dart'; +import 'package:server_core/server_core.dart'; import '../../../data/services/core_download_service.dart'; import '../../../l10n/app_localizations.dart'; import '../../../util/game_cores.dart'; import '../../../util/platform_detection.dart'; import '../../widgets/adaptive/adaptive_list_section.dart'; +import '../../widgets/focus/focusable_button.dart'; import '../../widgets/focus/request_initial_focus.dart'; import '../../widgets/settings/clean_settings_typography.dart'; import '../../widgets/settings/preference_tiles.dart'; @@ -27,6 +29,7 @@ class EmulatorCoresScreen extends StatefulWidget { class _EmulatorCoresScreenState extends State { late final CoreDownloadService _service = CoreDownloadService(GetIt.instance()); + final MediaServerClient _client = GetIt.instance(); final Set _installed = {}; final Map _downloading = {}; @@ -92,6 +95,25 @@ class _EmulatorCoresScreenState extends State { } } + Future _resetSettings(GameCore core) async { + final l10n = AppLocalizations.of(context); + final games = _client.gamesApi; + if (games == null) return; + try { + await resetCoreSettings(games, core.coreId); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.emulatorCoreSettingsReset)), + ); + } catch (e) { + debugPrint('[EmulatorCoresScreen] Reset settings failed: $e'); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.emulatorCoreResetSettingsFailed)), + ); + } + } + @override Widget build(BuildContext context) => RequestInitialFocus( targetNode: PlatformDetection.isTV ? _firstTileFocusNode : null, @@ -138,43 +160,59 @@ class _EmulatorCoresScreenState extends State { subtitle = '~${core.approxSizeMb.toStringAsFixed(0)} MB'; } - return TvFocusHighlight( - builder: (_, focused) => SwitchListTile.adaptive( - focusNode: isFirst ? _firstTileFocusNode : null, - secondary: downloading - ? SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - value: progress > 0 ? progress : null, - strokeWidth: 2, + return Row( + children: [ + Expanded( + child: TvFocusHighlight( + builder: (_, focused) => SwitchListTile.adaptive( + focusNode: isFirst ? _firstTileFocusNode : null, + secondary: downloading + ? SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + value: progress > 0 ? progress : null, + strokeWidth: 2, + ), + ) + : Icon( + Icons.videogame_asset, + color: focused + ? AppColors.black.withValues(alpha: 0.54) + : null, + ), + title: Text( + core.system, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: focused + ? AppColors.black.withValues(alpha: 0.87) + : AppColorScheme.onSurface, ), - ) - : Icon( - Icons.videogame_asset, - color: focused ? AppColors.black.withValues(alpha: 0.54) : null, ), - title: Text( - core.system, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: focused - ? AppColors.black.withValues(alpha: 0.87) - : AppColorScheme.onSurface, + subtitle: Text( + subtitle, + style: TextStyle( + color: focused ? AppColors.black.withValues(alpha: 0.54) : null, + ), + ), + value: installed || downloading, + onChanged: (available && !downloading) + ? (v) => _toggle(core, v) + : null, + ), ), ), - subtitle: Text( - subtitle, - style: TextStyle( - color: focused ? AppColors.black.withValues(alpha: 0.54) : null, + Tooltip( + message: l10n.emulatorCoreResetSettings(core.system), + child: FocusableButton( + semanticLabel: l10n.emulatorCoreResetSettings(core.system), + onPressed: () => _resetSettings(core), + child: const Icon(Icons.settings_backup_restore), ), ), - value: installed || downloading, - onChanged: (available && !downloading) - ? (v) => _toggle(core, v) - : null, - ), + ], ); } } diff --git a/lib/util/game_cores.dart b/lib/util/game_cores.dart index c818d5f35..e56e4a84f 100644 --- a/lib/util/game_cores.dart +++ b/lib/util/game_cores.dart @@ -399,6 +399,17 @@ Future?> loadGameStateWithMigration( return legacy; } +/// The saved-settings key for [coreId], shared by every place that reads or +/// writes a core's persisted emulator options. +String coreSettingsKey(String coreId) => 'moonfin-native-$coreId'; + +/// Clears [coreId]'s saved emulator settings so the next load falls back to +/// the core's own defaults. A single newline byte, not an empty list: the +/// settings parser already treats it as "no saved options", and it avoids +/// sending a zero-byte PUT body, which some HTTP stacks and servers mishandle. +Future resetCoreSettings(GamesApi games, String coreId) => + games.putSave(coreSettingsKey(coreId), const [10], kind: 'settings'); + /// The libretro core id for an EmulatorJS core name, or null if there's no /// mapping for it. String? libretroCoreId(String core) => _libretroCores[core]; diff --git a/native/libretro_host/libretro_host.c b/native/libretro_host/libretro_host.c index 11180fddf..e16fb1fc2 100644 --- a/native/libretro_host/libretro_host.c +++ b/native/libretro_host/libretro_host.c @@ -18,6 +18,18 @@ #include #endif +#ifdef __ANDROID__ +#include +#endif + +#include +#include +#ifdef _WIN32 +#include +#else +#include +#endif + // --------------------------------------------------------------------------- // Platform primitives: dynamic library, threading, time. // --------------------------------------------------------------------------- @@ -495,6 +507,226 @@ static int parse_variable(struct lh_host *h, const char *key, return 0; } +// --------------------------------------------------------------------------- +// Virtual file system. +// +// Without GET_VFS_INTERFACE, some cores (Stella) never fall back to treating +// game.path as a real, stat-able file - they just reject the ROM outright, +// even though it genuinely exists on disk at that path. This is a thin, +// portable shim over stdio/dirent so those cores work like any other. +// --------------------------------------------------------------------------- + +struct retro_vfs_file_handle { + FILE *fp; +}; + +struct retro_vfs_dir_handle { +#ifdef _WIN32 + HANDLE find; + WIN32_FIND_DATAA data; + bool pending; +#else + DIR *dir; + struct dirent *entry; +#endif +}; + +static const char *vfs_get_path(struct retro_vfs_file_handle *stream) { + (void)stream; + return NULL; +} + +static struct retro_vfs_file_handle *vfs_open(const char *path, unsigned mode, + unsigned hints) { + (void)hints; + const char *fmode = "rb"; + if (mode & RETRO_VFS_FILE_ACCESS_WRITE) { + fmode = (mode & RETRO_VFS_FILE_ACCESS_UPDATE_EXISTING) ? "r+b" : "w+b"; + } + FILE *fp = fopen(path, fmode); + if (!fp && mode == RETRO_VFS_FILE_ACCESS_READ_WRITE) { + // "r+b" requires the file to already exist; a core opening for + // read/write update on a not-yet-created file needs it made first. + fp = fopen(path, "w+b"); + } + if (!fp) return NULL; + struct retro_vfs_file_handle *handle = calloc(1, sizeof(*handle)); + if (!handle) { + fclose(fp); + return NULL; + } + handle->fp = fp; + return handle; +} + +static int vfs_close(struct retro_vfs_file_handle *stream) { + if (!stream) return -1; + int rc = fclose(stream->fp); + free(stream); + return rc == 0 ? 0 : -1; +} + +static int64_t vfs_tell(struct retro_vfs_file_handle *stream) { + if (!stream) return -1; + long pos = ftell(stream->fp); + return pos < 0 ? -1 : (int64_t)pos; +} + +static int64_t vfs_size(struct retro_vfs_file_handle *stream) { + if (!stream) return -1; + long cur = ftell(stream->fp); + if (cur < 0 || fseek(stream->fp, 0, SEEK_END) != 0) return -1; + long end = ftell(stream->fp); + fseek(stream->fp, cur, SEEK_SET); + return end < 0 ? -1 : (int64_t)end; +} + +static int64_t vfs_seek(struct retro_vfs_file_handle *stream, int64_t offset, + int seek_position) { + if (!stream) return -1; + int whence = SEEK_SET; + if (seek_position == RETRO_VFS_SEEK_POSITION_CURRENT) whence = SEEK_CUR; + else if (seek_position == RETRO_VFS_SEEK_POSITION_END) whence = SEEK_END; + if (fseek(stream->fp, (long)offset, whence) != 0) return -1; + return vfs_tell(stream); +} + +static int64_t vfs_read(struct retro_vfs_file_handle *stream, void *s, + uint64_t len) { + if (!stream) return -1; + return (int64_t)fread(s, 1, (size_t)len, stream->fp); +} + +static int64_t vfs_write(struct retro_vfs_file_handle *stream, const void *s, + uint64_t len) { + if (!stream) return -1; + return (int64_t)fwrite(s, 1, (size_t)len, stream->fp); +} + +static int vfs_flush(struct retro_vfs_file_handle *stream) { + if (!stream) return -1; + return fflush(stream->fp) == 0 ? 0 : -1; +} + +static int vfs_remove(const char *path) { return remove(path) == 0 ? 0 : -1; } + +static int vfs_rename(const char *old_path, const char *new_path) { + return rename(old_path, new_path) == 0 ? 0 : -1; +} + +static int64_t vfs_truncate(struct retro_vfs_file_handle *stream, + int64_t length) { + (void)stream; + (void)length; + return -1; // Unused by any core this host ships. +} + +static int vfs_stat(const char *path, int32_t *size) { + struct stat st; + if (stat(path, &st) != 0) return 0; + int flags = RETRO_VFS_STAT_IS_VALID; +#ifdef _WIN32 + if (st.st_mode & _S_IFDIR) flags |= RETRO_VFS_STAT_IS_DIRECTORY; +#else + if (S_ISDIR(st.st_mode)) flags |= RETRO_VFS_STAT_IS_DIRECTORY; +#endif + if (size) *size = (int32_t)st.st_size; + return flags; +} + +static int vfs_mkdir(const char *dir) { +#ifdef _WIN32 + if (_mkdir(dir) == 0) return 0; +#else + if (mkdir(dir, 0755) == 0) return 0; +#endif + return errno == EEXIST ? -2 : -1; +} + +static struct retro_vfs_dir_handle *vfs_opendir(const char *dir, + bool include_hidden) { + (void)include_hidden; + struct retro_vfs_dir_handle *handle = calloc(1, sizeof(*handle)); + if (!handle) return NULL; +#ifdef _WIN32 + char pattern[1024]; + snprintf(pattern, sizeof(pattern), "%s\\*", dir); + handle->find = FindFirstFileA(pattern, &handle->data); + if (handle->find == INVALID_HANDLE_VALUE) { + free(handle); + return NULL; + } + handle->pending = true; +#else + handle->dir = opendir(dir); + if (!handle->dir) { + free(handle); + return NULL; + } +#endif + return handle; +} + +static bool vfs_readdir(struct retro_vfs_dir_handle *dirstream) { + if (!dirstream) return false; +#ifdef _WIN32 + if (dirstream->pending) { + dirstream->pending = false; + return true; + } + return FindNextFileA(dirstream->find, &dirstream->data) != 0; +#else + dirstream->entry = readdir(dirstream->dir); + return dirstream->entry != NULL; +#endif +} + +static const char *vfs_dirent_get_name(struct retro_vfs_dir_handle *dirstream) { + if (!dirstream) return NULL; +#ifdef _WIN32 + return dirstream->data.cFileName; +#else + return dirstream->entry ? dirstream->entry->d_name : NULL; +#endif +} + +static bool vfs_dirent_is_dir(struct retro_vfs_dir_handle *dirstream) { + if (!dirstream) return false; +#ifdef _WIN32 + return (dirstream->data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0; +#else + if (!dirstream->entry) return false; +#ifdef DT_DIR + if (dirstream->entry->d_type == DT_DIR) return true; + if (dirstream->entry->d_type != DT_UNKNOWN) return false; +#endif + // d_type is DT_UNKNOWN on some filesystems (notably FAT/exFAT and some + // Android storage); fall back to stat rather than assume "not a directory". + return false; +#endif +} + +static int vfs_closedir(struct retro_vfs_dir_handle *dirstream) { + if (!dirstream) return -1; +#ifdef _WIN32 + FindClose(dirstream->find); +#else + closedir(dirstream->dir); +#endif + free(dirstream); + return 0; +} + +static const struct retro_vfs_interface g_vfs_interface = { + vfs_get_path, vfs_open, vfs_close, + vfs_size, vfs_tell, vfs_seek, + vfs_read, vfs_write, vfs_flush, + vfs_remove, vfs_rename, vfs_truncate, + vfs_stat, vfs_mkdir, vfs_opendir, + vfs_readdir, vfs_dirent_get_name, vfs_dirent_is_dir, + vfs_closedir, NULL, +}; + // --------------------------------------------------------------------------- // Environment callback (mirrors the tvOS GameSession switch). // --------------------------------------------------------------------------- @@ -558,11 +790,16 @@ static void notify_geometry(struct lh_host *h, unsigned width, unsigned height, // a real function. static void RETRO_CALLCONV log_printf_cb(enum retro_log_level level, const char *fmt, ...) { - (void)level; if (!fmt) return; va_list args; va_start(args, fmt); +#ifdef __ANDROID__ + (void)level; + __android_log_vprint(ANDROID_LOG_INFO, "moonfin_libretro", fmt, args); +#else + (void)level; vfprintf(stderr, fmt, args); +#endif va_end(args); } @@ -727,6 +964,15 @@ static bool RETRO_CALLCONV environment_cb(unsigned cmd, void *data) { if (!data) return false; ((struct retro_log_callback *)data)->log = log_printf_cb; return true; + case RETRO_ENVIRONMENT_GET_VFS_INTERFACE: { + if (!data) return false; + struct retro_vfs_interface_info *info = + (struct retro_vfs_interface_info *)data; + if (info->required_interface_version > 3) return false; + info->required_interface_version = 3; + info->iface = (struct retro_vfs_interface *)&g_vfs_interface; + return true; + } case RETRO_ENVIRONMENT_GET_PREFERRED_HW_RENDER: return false; default: