diff --git a/CMakeLists.txt b/CMakeLists.txt index 637b236..4cb9d8b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -306,6 +306,47 @@ if(BUILD_TESTING) COMMAND $ "${CMAKE_CURRENT_BINARY_DIR}") + add_executable(recomp-ui-launcher-mod-gate-test + tests/launcher_mod_gate_test.c src/common/crc32.c src/common/sha1.c + src/common/launcher_mod_visibility.c + src/common/sha256.c src/common/ips_patch.c src/consoles/psx/memcard_format.c) + target_include_directories(recomp-ui-launcher-mod-gate-test PRIVATE src src/common src/consoles/psx) + target_link_libraries(recomp-ui-launcher-mod-gate-test PRIVATE ${RUI_SDL_TARGET}) + # Keep assertions active in Release, matching the intended policy fixture. + if(MSVC) + target_compile_options(recomp-ui-launcher-mod-gate-test PRIVATE /UNDEBUG) + else() + target_compile_options(recomp-ui-launcher-mod-gate-test PRIVATE -UNDEBUG) + endif() + add_test(NAME recomp-ui-launcher-mod-gate COMMAND recomp-ui-launcher-mod-gate-test) + + add_executable(recomp-ui-launcher-mod-button-test + tests/launcher_mod_button_test.cpp + src/third_party/imgui/imgui.cpp src/third_party/imgui/imgui_draw.cpp + src/third_party/imgui/imgui_tables.cpp src/third_party/imgui/imgui_widgets.cpp) + target_include_directories(recomp-ui-launcher-mod-button-test PRIVATE + src/common src/third_party/imgui) + if(MSVC) + target_compile_options(recomp-ui-launcher-mod-button-test PRIVATE /UNDEBUG) + else() + target_compile_options(recomp-ui-launcher-mod-button-test PRIVATE -UNDEBUG) + endif() + add_test(NAME recomp-ui-launcher-mod-button COMMAND recomp-ui-launcher-mod-button-test) + + # Cover folders: per-disc preference, relocation, Unicode paths, invalid + # and empty folders, ordering and aspect fit. Writes only under the test dir. + add_executable(recomp-ui-launcher-artwork-folder-test tests/launcher_artwork_folder_test.cpp) + target_include_directories(recomp-ui-launcher-artwork-folder-test PRIVATE src/common) + target_compile_features(recomp-ui-launcher-artwork-folder-test PRIVATE cxx_std_17) + if(MSVC) + target_compile_options(recomp-ui-launcher-artwork-folder-test PRIVATE /UNDEBUG) + else() + target_compile_options(recomp-ui-launcher-artwork-folder-test PRIVATE -UNDEBUG) + endif() + add_test(NAME recomp-ui-launcher-artwork-folder + COMMAND recomp-ui-launcher-artwork-folder-test + "${CMAKE_CURRENT_BINARY_DIR}/artwork-folder-test-$") + # Renderer / VSync as enumerable lists rather than cycle buttons. Same # include-the-TU shape as the discs test above. add_executable(recomp-ui-launcher-video-pick-test diff --git a/src/common/backends/imgui/launcher_imgui.cpp b/src/common/backends/imgui/launcher_imgui.cpp index 632160f..3ce038e 100644 --- a/src/common/backends/imgui/launcher_imgui.cpp +++ b/src/common/backends/imgui/launcher_imgui.cpp @@ -16,6 +16,8 @@ #include "launcher_input.h" #include "launcher_files.h" #include "launcher_directory_url.h" +#include "launcher_artwork.h" +#include "launcher_quick_mods.h" #include "launcher_debug.h" #include "launcher_binds.h" #include "launcher_udp_port.h" @@ -23,6 +25,7 @@ #include "launcher_system.h" #include "launcher_i18n.h" #include "launcher_mod_visibility.h" +#include "launcher_mod_feature_actions.h" #include "launcher_mod_preparation.h" #include "recomp_moderation.h" // local ignore/block list (online only) #include "consoles/n64/n64_binds.h" // RUI_N64_FIELD_* for the pad-capture path @@ -31,6 +34,7 @@ #include "imgui.h" #include "launcher_nav.h" +#include "launcher_mod_button.h" #if defined(LNG_SDL3) #include "imgui_impl_sdl3.h" #define LNG_ImplSDL_InitForOpenGL ImGui_ImplSDL3_InitForOpenGL @@ -358,6 +362,18 @@ bool download_icon_button(const char* id, float side, bool enabled, // g_th moved to external linkage above the anonymous namespace (see note). LauncherTexture g_boxart, g_pad, g_pad_analog, g_pad_digital, g_brand, g_memcard; +struct ArtworkGallery { + std::string serial, error; + launcher_artwork::Catalog catalog; + std::vector textures; + size_t index=0; + bool enlarged=false; + void clear() { + for(auto& t:textures)launcher_texture_free(&t); + textures.clear();catalog={};serial.clear();error.clear();index=0;enlarged=false; + } +} g_artwork; + // Optional platform wordmark (SystemProfile.wordmark_image) — rendered in the // header instead of the platform text when the asset is present. Absent => text. LauncherTexture g_wordmark; @@ -580,7 +596,9 @@ static void ui_pick(LauncherModel* m, PickRequest req) { sizeof(g_pick_buf)); break; case PickMode::Folder: - r = launcher_try_pick_folder(title, g_pick_buf, sizeof(g_pick_buf)); + r = launcher_try_pick_folder_in( + title, req.start_path.empty() ? nullptr : req.start_path.c_str(), + g_pick_buf, sizeof(g_pick_buf)); break; } if (r == 1) { @@ -1968,6 +1986,26 @@ static const SettingsChoice kScaleChoices[LNG_WINDOW_SCALE_MAX] = { static const SettingsChoice kFullscreenChoices[] = { {0, "Off"}, {1, "Borderless"}, {2, "Exclusive"} }; +static void fullscreen_combo(LauncherModel* m,const char* id) { + if(ImGui::BeginCombo(id,ui_text(launcher_model_fullscreen_label(m)))) { + for(const auto& choice:kFullscreenChoices) + if(ImGui::Selectable(ui_text(choice.label),m->s.fullscreen==choice.value)) + launcher_model_set_fullscreen(m,choice.value); + ImGui::EndCombo(); + } +} +static void internal_resolution_combo(LauncherModel* m,const char* id) { + if(ImGui::BeginCombo(id,ui_text(launcher_model_internal_resolution_label(m)))) { + const int n=launcher_model_internal_resolution_count(m); + const int current=launcher_model_internal_resolution_index(m); + for(int i=0;is.fullscreen)); + row_label_right("Fullscreen",th,px(SETTINGS_CTRL_W)); + ImGui::SetNextItemWidth(px(SETTINGS_CTRL_W)); + fullscreen_combo(m,"##fullscreen"); } void row_run_ahead(LauncherModel* m, const LauncherTheme& th) { @@ -2088,6 +2126,7 @@ const LauncherTexture& verdict_texture(int verdict) { // the disc verdict against it and records the choice in Settings.disc_index, // which the host persists like any other setting. void draw_disc_selector(LauncherModel* m, const LauncherTheme& th, float availw) { + if(m->disc_selection_locked)return; const int count = launcher_model_disc_count(m); if (count <= 1) return; const int sel = launcher_model_disc_selected(m); @@ -2202,6 +2241,97 @@ void draw_verdict_block(LauncherModel* m, const LauncherTheme& th, float availw) } } +// Assets are selected by the detected disc serial, never by a saved global region. +// All backends resolve the same portable artwork root next to the entry launcher. +// GameInfo.artwork_root when the host names one (a regional package keeps its +// covers beside the shared entry), else /artwork. +static std::filesystem::path artwork_home(const LauncherModel* m) { + if (m->artwork_root && m->artwork_root[0]) + return std::filesystem::u8path(m->artwork_root); + return launcher_artwork::root(std::filesystem::u8path(asset(""))); +} + +void draw_artwork_gallery(LauncherModel* m,float height,float width) { + const std::string serial=m->verify.serial; + if(serial!=g_artwork.serial) { + g_artwork.clear();g_artwork.serial=serial; + g_artwork.catalog=launcher_artwork::scan(artwork_home(m),serial); + size_t pixels=0; + for(const auto& path:g_artwork.catalog.images) { + auto t=launcher_texture_load(path.u8string().c_str()); + if(t.id) { + const size_t count=size_t(t.w)*t.h; + if(pixels+count>32*1024*1024)launcher_texture_free(&t); + else {pixels+=count;g_artwork.textures.push_back(t);} + } // malformed/unsupported files are optional; bound total VRAM too + } + } + auto image=[&](const LauncherTexture& t,float maxw,float maxh,const char* id) { + const auto size=launcher_artwork::fit(float(t.w),float(t.h),maxw,maxh); + ImGui::SetCursorPosX(ImGui::GetCursorPosX()+(maxw-size.w)*0.5f); + const auto p=ImGui::GetCursorScreenPos(); + ImGui::GetWindowDrawList()->AddImage(tid(t),p,ImVec2(p.x+size.w,p.y+size.h)); + return ImGui::InvisibleButton(id,ImVec2(size.w,size.h)); + }; + const bool custom=!g_artwork.textures.empty(); + const bool fallback=!custom && g_boxart.id; + if(custom || fallback) { + const auto& t=custom?g_artwork.textures[g_artwork.index]:g_boxart; + const auto origin=ImGui::GetCursorScreenPos(); + const float gutter=g_artwork.textures.size()>1?ImGui::GetFrameHeight()+px(4):0; + ImGui::SetCursorScreenPos(ImVec2(origin.x+gutter,origin.y)); + if(image(t,std::max(1.0f,width-2*gutter),height,"##enlarge_cover")) { + g_artwork.enlarged=true;ImGui::OpenPopup("Cover gallery"); + } + if(ImGui::IsItemHovered())ImGui::SetTooltip("%s",ui_text("Click to enlarge")); + const auto after=ImGui::GetCursorScreenPos(); + if(g_artwork.textures.size()>1) { + ImGui::SetCursorScreenPos(ImVec2(origin.x,origin.y+height*0.5f)); + if(ImGui::ArrowButton("##cover_previous",ImGuiDir_Left)) + g_artwork.index=launcher_artwork::step(g_artwork.index,g_artwork.textures.size(),-1); + ImGui::SetCursorScreenPos(ImVec2(origin.x+width-ImGui::GetFrameHeight(),origin.y+height*0.5f)); + if(ImGui::ArrowButton("##cover_next",ImGuiDir_Right)) + g_artwork.index=launcher_artwork::step(g_artwork.index,g_artwork.textures.size(),1); + } + ImGui::SetCursorScreenPos(ImVec2(origin.x,after.y)); + if(g_artwork.textures.size()>1)ImGui::TextDisabled("%u / %u",unsigned(g_artwork.index+1),unsigned(g_artwork.textures.size())); + ImGui::SetNextWindowSize(ImVec2(ImGui::GetIO().DisplaySize.x*0.8f,ImGui::GetIO().DisplaySize.y*0.85f),ImGuiCond_Appearing); + if(ImGui::BeginPopupModal("Cover gallery",&g_artwork.enlarged,ImGuiWindowFlags_NoSavedSettings)) { + const auto available=ImGui::GetContentRegionAvail(); + image(custom?g_artwork.textures[g_artwork.index]:g_boxart,available.x,std::max(1.0f,available.y-ImGui::GetFrameHeightWithSpacing()*2),"##large_cover"); + if(ImGui::Button(ui_text("Close"))){g_artwork.enlarged=false;ImGui::CloseCurrentPopup();} + ImGui::EndPopup(); + } + } else { + const LauncherTexture empty{};hero_boxart_centered(empty,height/px(1.0f),width); + ImGui::TextDisabled("%s",ui_text("No readable cover images in this folder.")); + } + const bool verified=m->profile && m->profile->verify.mode==1 && + launcher_artwork::supported(serial) && launcher_model_can_open_mods(m); + ImGui::BeginDisabled(!verified); + if (ImGui::Button(ui_text("Browse Covers"))) { + const auto home = artwork_home(m); + const auto directory = home / serial; + std::error_code ec; + std::filesystem::create_directories(directory, ec); + g_artwork.error.clear(); + PickRequest req; + req.mode = PickMode::Folder; + req.title = ui_text("Browse Covers"); + if (!ec) req.start_path = directory.u8string(); + req.on_pick = [home, serial](const char* selected) { + if (!selected || !selected[0]) return; + if (launcher_artwork::select_folder(home, serial, std::filesystem::u8path(selected))) + g_artwork.clear(); // the next frame reloads this disc's saved folder + else + g_artwork.error = ui_text("The folder is invalid or its selection could not be saved."); + }; + ui_pick(m, std::move(req)); + } + ImGui::EndDisabled(); + if(!g_artwork.error.empty())ImGui::TextWrapped("%s",g_artwork.error.c_str()); +} + void draw_game_panel(LauncherModel* m, const LauncherTheme& th, bool fill_h = false) { if (!begin_panel("game", 0, fill_h)) { end_panel(); return; } // No "GAME" eyebrow: the box art itself tells the user this is the game. @@ -2219,6 +2349,8 @@ void draw_game_panel(LauncherModel* m, const LauncherTheme& th, bool fill_h = fa // Reserve space for everything under the art: verified line + 2 meta rows // + Change ROM, plus the SAVES block when this game has battery SRAM. float reserve = px(198.0f); + if(disc_verdict)reserve+=px(54.0f); + reserve+=ImGui::GetFrameHeightWithSpacing(); if (disc_verdict) reserve += px(120.0f); // taller: icon+headline + tracks row // Disc Selection label + combo + spacing, for a multi-image title. if (launcher_model_disc_count(m) > 1) reserve += px(62.0f); @@ -2242,7 +2374,8 @@ void draw_game_panel(LauncherModel* m, const LauncherTheme& th, bool fill_h = fa // Artwork yields to controls. Below a useful thumbnail size, omit it // (and its spacer) instead of forcing the metadata below the fold. if (art_h >= px(96.0f)) { - hero_boxart_centered(g_boxart, art_h, availw); + if (disc_verdict) draw_artwork_gallery(m, art_h, availw); + else hero_boxart_centered(g_boxart, art_h, availw); ImGui::Dummy(ImVec2(0, px(10))); } } @@ -2300,6 +2433,7 @@ void draw_game_panel(LauncherModel* m, const LauncherTheme& th, bool fill_h = fa // machine. Naming the disc number is what keeps those two apart. A // single-image title has nothing to number, so it reads "Browse For Disc" // ("Browse For ROM" on cartridge consoles, from the profile's rom_noun). + if(!m->disc_selection_locked) { const int disc_no = launcher_model_disc_count(m) > 1 ? launcher_model_disc_number( m, launcher_model_disc_selected(m)) @@ -2364,6 +2498,8 @@ void draw_game_panel(LauncherModel* m, const LauncherTheme& th, bool fill_h = fa } } + } else ImGui::TextDisabled("%s", ui_text("This disc was chosen before the game started. To change it, quit and start again.")); + // A required system file the host could not find (GameInfo.bios_name): // say so where the player is looking and point at Settings, which owns the // picker (the SYSTEM card). PLAY stays disabled until a pick verifies @@ -2737,6 +2873,9 @@ static int dash_card_columns(float availw, float gap, float cardw, int count) { return cols; } +static void draw_quick_mods(LauncherModel* m,const LauncherTheme& th); +static void draw_quick_settings(LauncherModel* m,const LauncherTheme& th); + void panel_save_draw(LauncherModel* m, const LauncherTheme* th) { const SystemProfile* prof = (const SystemProfile*)m->profile; const SaveKind kind = prof ? prof->save.kind : SAVE_NONE; @@ -2747,22 +2886,30 @@ void panel_save_draw(LauncherModel* m, const LauncherTheme* th) { const int slots = (prof->save.slots > 0 && prof->save.slots <= 2) ? prof->save.slots : 2; const float gap = px(th->spacing_sm); const float avail = ImGui::GetContentRegionAvail().x; - const float cw = dash_card_width(avail, gap, slots); - const int cols = dash_card_columns(avail, gap, cw, slots); + const int cards=slots+(m->dashboard_quick_controls?2:0); + const float cw = dash_card_width(avail, gap, cards); + const int cols = dash_card_columns(avail, gap, cw, cards); // Multitap fill-height: slot cards take the reserved band under the // controller stack. 2P / narrow leave g_save_fill_h false and hug. const float fill_h = ImGui::GetContentRegionAvail().y; const bool do_fill = g_save_fill_h && fill_h > px(180.0f); g_save_fill_h = do_fill; // draw_memcard_slot reads this for the bottom slack - for (int slot = 0; slot < slots; ++slot) { + for (int slot = 0; slot < cards; ++slot) { if (slot % cols) ImGui::SameLine(0, gap); else if (slot) ImGui::Dummy(ImVec2(0, gap)); char cid[16]; snprintf(cid, sizeof(cid), "mcc%d", slot); char pid[16]; snprintf(pid, sizeof(pid), "mcp%d", slot); - begin_container(cid, ImVec2(cw, do_fill ? fill_h : 0.0f), - do_fill ? ImGuiChildFlags_None : ImGuiChildFlags_AutoResizeY); - if (begin_panel(pid, cw, do_fill, do_fill)) - draw_memcard_slot(m, *th, slot); + const bool quick=slot>=slots; + const float height=quick?px(250):do_fill?fill_h:0.0f; + begin_container(cid, ImVec2(cw, height), + quick || do_fill ? ImGuiChildFlags_None : ImGuiChildFlags_AutoResizeY); + // The container is already sized in physical pixels. A zero + // inner width fills it without applying DPI scaling twice. + if (begin_panel(pid, 0, quick || do_fill, quick?false:do_fill)) { + if(slothas_bios && m->bios_verify_for_rom_cb) + launcher_model_refresh_bios_status(m); +} + static bool mod_feature_less(const ModFeatureListItem& lhs, const ModFeatureListItem& rhs) { const int group = std::strcmp(lhs.feature.group, rhs.feature.group); @@ -12182,16 +12329,7 @@ static void draw_mod_features(LauncherModel* m, const LauncherTheme& th) { bool enabled = feature.enabled != 0; if (ImGui::Checkbox("##enabled", &enabled)) { - if (!mods->feature_enable( - mods->ctx, feature.package_id, feature.id, - enabled ? 1 : 0)) { - mod_note_error(m); - } else if (enabled) { - prompt_missing_mod_resources( - m, feature.package_id, feature.id); - } - if (m->has_bios && m->bios_verify_for_rom_cb) - launcher_model_refresh_bios_status(m); + change_mod_feature(m, feature, enabled); } if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s %s\n%s%s%s", @@ -12254,6 +12392,13 @@ static void draw_mod_features(LauncherModel* m, const LauncherTheme& th) { mods->feature_get(mods->ctx, m->mod_selected, &feature) && launcher_mod_feature_listed(mods, &feature)) { ImGui::TextColored(col(th.accent2), "%s", feature.name); + if (m->dashboard_quick_controls && mods->feature_set_quick) { + bool quick = feature.quick != 0; + if (ImGui::Checkbox(ui_text("Show in Quick Mods"), &quick) && + !mods->feature_set_quick(mods->ctx, feature.package_id, + feature.id, quick ? 1 : 0)) + mod_note_error(m); + } if (const char* channel_tag = mod_channel_tag(feature.channel)) { ImGui::SameLine(); ImGui::TextColored(col(mod_channel_color(feature.channel, th)), @@ -12480,6 +12625,71 @@ static void draw_rom_patch(LauncherModel* m, const LauncherTheme& th) { ImGui::EndChild(); } +static void draw_quick_mods(LauncherModel* m,const LauncherTheme& th) { + eyebrow("Quick Mods"); + const auto* mods=m?m->mods:nullptr; + if(!mods || !mods->feature_count || !mods->feature_get || !mods->feature_enable)return; + std::vector features; + for(int i=0,n=mods->feature_count(mods->ctx);ifeature_get(mods->ctx,i,&item.feature) && launcher_quick_listed(mods,&item.feature))features.push_back(item); + } + std::stable_sort(features.begin(),features.end(),mod_feature_less); + ImGui::BeginDisabled(!launcher_model_can_open_mods(m)); + if(ImGui::BeginChild("##quick_mod_list",ImVec2(0,0))) { + for(const auto& item:features) { + const auto& feature=item.feature; + ImGui::PushID(feature.package_id);ImGui::PushID(feature.id); + bool enabled=feature.enabled!=0; + bool changed=ImGui::Checkbox("##enabled",&enabled); + bool hovered=ImGui::IsItemHovered(); + ImGui::SameLine();ImGui::PushTextWrapPos(0); + ImGui::TextUnformatted(feature.name);ImGui::PopTextWrapPos(); + if(ImGui::IsItemClicked()){enabled=!enabled;changed=true;} + hovered=hovered || ImGui::IsItemHovered(); + if(changed)change_mod_feature(m,feature,enabled); + if(hovered && (feature.has_error || m->mod_status[0])) + ImGui::SetTooltip("%s",feature.has_error?feature.status:m->mod_status); + ImGui::PopID();ImGui::PopID(); + } + } + ImGui::EndChild();ImGui::EndDisabled(); + (void)th; +} + +static void draw_quick_settings(LauncherModel* m,const LauncherTheme& th) { + eyebrow("Quick Settings"); + if(m->has_vsync) { + bool on=m->s.vsync!=RECOMP_LAUNCHER_VSYNC_OFF; + if(ImGui::Checkbox(ui_text("VSync"),&on)) + launcher_model_set_vsync(m,on?RECOMP_LAUNCHER_VSYNC_ON:RECOMP_LAUNCHER_VSYNC_OFF); + } + if(launcher_model_internal_resolution_offered(m)) { + ImGui::TextUnformatted(ui_text("Internal resolution")); + ImGui::SetNextItemWidth(-1); + internal_resolution_combo(m,"##quick_resolution"); + } + ImGui::TextUnformatted(ui_text("Screen Mode")); + ImGui::SetNextItemWidth(-1); + fullscreen_combo(m,"##quick_screen_mode"); + bool fullscreen=m->s.fullscreen!=0; + if(ImGui::Checkbox(ui_text("Fullscreen"),&fullscreen))launcher_model_toggle_fullscreen(m); + if(ImGui::IsItemHovered())ImGui::SetTooltip("%s",ui_text("Uses the same Off / Borderless / Exclusive setting. Changes apply when the game starts.")); + if(m->has_screen_kind) { + ImGui::TextUnformatted(ui_text("Screen Model")); + ImGui::SetNextItemWidth(-1); + if(ImGui::BeginCombo("##quick_screen_model",ui_text(launcher_model_screen_kind_label(m)))) { + for(int i=0;is.screen_kind==i; + if(ImGui::Selectable(ui_text(launcher_model_screen_kind_name(m,i)),selected))launcher_model_set_screen_kind(m,i); + if(selected)ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + } + (void)th; +} + void draw_mods(LauncherModel* m, const LauncherTheme& th) { const auto* mods = m ? m->mods : nullptr; if (!m || (!mods && !m->rom_patch_supported)) return; @@ -14178,7 +14388,8 @@ void draw_ui(LauncherModel* m, const LauncherTheme& th, int logical_w, int logic launcher_model_set_view(m, LNG_VIEW_SETTINGS); if (m->mods) { ImGui::SameLine(0, gap); - if (ImGui::Button(ui_text("Mods"), ImVec2(w, px(34)))) + if (rui_mods_button(launcher_model_can_open_mods(m), + ui_text("Mods"), ImVec2(w, px(34)))) launcher_model_set_view(m, LNG_VIEW_MODS); } if (m->has_assist_tools) { @@ -15074,6 +15285,7 @@ extern "C" LngAction launcher_backend_run(LauncherPlatform* p, launcher_boot_timing_mark("rui:action_requested"); launcher_input_shutdown(); launcher_boot_timing_mark("rui:input_closed"); + g_artwork.clear(); launcher_texture_free(&g_boxart); launcher_texture_free(&g_pad); launcher_texture_free(&g_pad_analog); diff --git a/src/common/backends/imgui/launcher_mod_button.h b/src/common/backends/imgui/launcher_mod_button.h new file mode 100644 index 0000000..b1f276a --- /dev/null +++ b/src/common/backends/imgui/launcher_mod_button.h @@ -0,0 +1,19 @@ +#pragma once +#include "imgui.h" + +/* Only the dashboard entry is gated. The existing Mods page is unchanged. */ +static inline bool rui_mods_button_tooltip(bool enabled) { + if (!enabled && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { + ImGui::SetTooltip("%s", "Please select a valid game disc first."); + return true; + } + return false; +} + +static inline bool rui_mods_button(bool enabled, const char* label, ImVec2 size) { + ImGui::BeginDisabled(!enabled); + const bool pressed = ImGui::Button(label, size); + ImGui::EndDisabled(); + rui_mods_button_tooltip(enabled); + return enabled && pressed; +} diff --git a/src/common/launcher_artwork.h b/src/common/launcher_artwork.h new file mode 100644 index 0000000..8f508b6 --- /dev/null +++ b/src/common/launcher_artwork.h @@ -0,0 +1,96 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +// Optional read-only cover images, one folder per disc serial. Only an explicit +// folder selection writes anything: that disc's cover-folder.txt preference. +namespace launcher_artwork { +namespace fs = std::filesystem; +struct Catalog { std::vector images; }; +inline std::string lower(std::string s) { + for (char& c:s) c=static_cast(std::tolower(static_cast(c))); + return s; +} +inline bool supported(const std::string& serial) { + // Any console's disc serial that is a safe single folder name + // (SCES-01330, T-1234G, MK-81000): no separators, no dot names. + if(serial.empty() || serial.size()>64 || serial[0]=='.')return false; + for(char c:serial) { + const bool ok=(c>='A' && c<='Z') || (c>='a' && c<='z') || (c>='0' && c<='9') || + c=='-' || c=='_' || c=='.'; + if(!ok)return false; + } + return true; +} +inline fs::path root(fs::path executable_directory) { + executable_directory=executable_directory.lexically_normal(); + // SDL_GetBasePath ends in a separator; remove its empty filename first. + if(executable_directory.filename().empty()) executable_directory=executable_directory.parent_path(); + return executable_directory/"artwork"; +} +inline fs::path folder(const fs::path& home,const std::string& serial) { + if(!supported(serial))return {}; + const auto base=fs::absolute(home/serial).lexically_normal(); + std::ifstream in(base/"cover-folder.txt",std::ios::binary); + std::string saved(65537,'\0');in.read(saved.data(),saved.size());saved.resize(size_t(in.gcount())); + if(saved.empty() || saved.size()>65536 || saved.find('\0')!=std::string::npos)return base; + auto selected=fs::u8path(saved); + return (selected.is_absolute()?selected:base/selected).lexically_normal(); +} +inline bool select_folder(const fs::path& home,const std::string& serial,const fs::path& path) { + if(!supported(serial))return false; + try { + if(!fs::is_directory(path))return false; + const auto base=fs::absolute(home/serial).lexically_normal(); + if(fs::is_symlink(base) || fs::is_symlink(base/"cover-folder.txt"))return false; + const auto chosen=fs::absolute(path).lexically_normal(); + fs::create_directories(base); + // Paths within the portable installation follow it when moved. + const auto inside=chosen.lexically_relative(fs::absolute(home).parent_path()); + const auto saved=(!inside.empty() && *inside.begin()!=fs::path("..")) + ?chosen.lexically_relative(base):chosen; + std::ofstream out(base/"cover-folder.txt",std::ios::binary|std::ios::trunc); + out<path(); + if(!it->is_symlink(ec) && it->is_regular_file(ec)) { + auto ext=lower(p.extension().u8string()); + if((ext==".png" || ext==".jpg" || ext==".jpeg" || ext==".tga") && + it->file_size(ec)<=32*1024*1024 && !ec)c.images.push_back(p); + } + it.increment(ec); + } + std::sort(c.images.begin(),c.images.end(),[](const auto& a,const auto& b){ + return rank(a)!=rank(b)?rank(a)= 0) return r; } if (have_zn) { - r = linux_pick_folder_zenity(title, out, out_cap); + r = linux_pick_folder_zenity(title, start_dir, out, out_cap); if (r >= 0) return r; } if (!want_kd && have_kd) { - r = linux_pick_folder_kdialog(title, out, out_cap); + r = linux_pick_folder_kdialog(title, start_dir, out, out_cap); if (r >= 0) return r; } return -1; @@ -702,18 +713,25 @@ bool launcher_pick_rom(char* out_path, size_t out_cap) { } int launcher_try_pick_folder(const char* title, char* out_path, size_t out_cap) { + return launcher_try_pick_folder_in(title, NULL, out_path, out_cap); +} + +int launcher_try_pick_folder_in(const char* title, const char* start_dir, + char* out_path, size_t out_cap) { if (!out_path || out_cap == 0) return -1; out_path[0] = '\0'; #if defined(__ANDROID__) (void)title; + (void)start_dir; return -1; #elif defined(__linux__) - return linux_pick_folder(title, out_path, out_cap); + return linux_pick_folder(title, start_dir, out_path, out_cap); #else { const char* sel = - tinyfd_selectFolderDialog(title ? title : "Select folder", ""); + tinyfd_selectFolderDialog(title ? title : "Select folder", + start_dir ? start_dir : ""); if (!sel || !sel[0]) return 0; snprintf(out_path, out_cap, "%s", sel); return 1; diff --git a/src/common/launcher_files.h b/src/common/launcher_files.h index 5addaf3..2cbb1bb 100644 --- a/src/common/launcher_files.h +++ b/src/common/launcher_files.h @@ -87,6 +87,9 @@ int launcher_try_pick_file(const char* title, const char* const* patterns, // Tri-state folder pick. Same contract as launcher_try_pick_file. int launcher_try_pick_folder(const char* title, char* out_path, size_t out_cap); +/* Same, opening in start_dir (UTF-8; NULL or "" = the picker default). */ +int launcher_try_pick_folder_in(const char* title, const char* start_dir, + char* out_path, size_t out_cap); // Tri-state save-destination pick. Same contract as launcher_try_pick_file. int launcher_try_pick_save_file(const char* title, const char* const* patterns, diff --git a/src/common/launcher_gl.c b/src/common/launcher_gl.c index 1af3ccf..bd42ba3 100644 --- a/src/common/launcher_gl.c +++ b/src/common/launcher_gl.c @@ -1,6 +1,7 @@ // launcher_gl.c — image->GL-texture loader (stb_image + GL 1.1). #include "launcher_gl.h" +#include #include "launcher_sdlcompat.h" // GL header for the active SDL version @@ -13,6 +14,8 @@ // glsl_shader.c does — sharing one impl caused LNK2005, and relying on the // host's format config left TGA box art unloadable). Static = self-contained // impl with our own STBI_ONLY_* set, identical behaviour standalone or in-process. +#define STBI_WINDOWS_UTF8 +#define STBI_MAX_DIMENSIONS 8192 #define STB_IMAGE_STATIC #define STB_IMAGE_IMPLEMENTATION #define STBI_ONLY_TGA @@ -39,6 +42,10 @@ void launcher_image_free(unsigned char* pixels) { static LauncherTexture load_impl(const char* path, int colorkey_tol) { LauncherTexture t = { 0, 0, 0 }; int w = 0, h = 0, comp = 0; + // Bound optional user images before allocating/uploading them. + if(!stbi_info(path,&w,&h,&comp) || w<=0 || h<=0 || (uint64_t)w*h>16*1024*1024)return t; + GLint limit=0;glGetIntegerv(GL_MAX_TEXTURE_SIZE,&limit); + if(w>limit || h>limit)return t; unsigned char* pixels = stbi_load(path, &w, &h, &comp, 4); // force RGBA if (!pixels) return t; diff --git a/src/common/launcher_mod_feature_actions.h b/src/common/launcher_mod_feature_actions.h new file mode 100644 index 0000000..026583b --- /dev/null +++ b/src/common/launcher_mod_feature_actions.h @@ -0,0 +1,14 @@ +#pragma once +#include "launcher_model.h" +#include "launcher_mod_visibility.h" + +/* One provider operation for the full Mods page and dashboard shortcuts. + * The provider owns dependencies, validation and persisted feature state. */ +static inline int launcher_mod_enable_feature(LauncherModel* model, + const RecompLauncherCModFeature* feature,int enabled) { + const RecompLauncherCModProvider* mods=model?model->mods:NULL; + if(!feature || !mods || !mods->feature_enable || + !launcher_model_can_open_mods(model) || + !launcher_mod_feature_listed(mods,feature))return 0; + return mods->feature_enable(mods->ctx,feature->package_id,feature->id,enabled); +} diff --git a/src/common/launcher_model.c b/src/common/launcher_model.c index fa4f5a4..213bfb2 100644 --- a/src/common/launcher_model.c +++ b/src/common/launcher_model.c @@ -941,10 +941,13 @@ void launcher_model_init(LauncherModel* m, { char cached_rom[1024]; const char* seeded_rom = initial_rom; - if (lm_load_cached_rom_path(game, initial_rom, cached_rom, + if ((!game || !game->disc_selection_locked) && lm_load_cached_rom_path(game, initial_rom, cached_rom, sizeof(cached_rom))) seeded_rom = cached_rom; launcher_model_set_rom(m, seeded_rom); + m->disc_selection_locked=game && game->disc_selection_locked; + m->artwork_root=game?game->artwork_root:NULL; + m->dashboard_quick_controls=game && game->dashboard_quick_controls; } // Password/mantra save: read the current one-line password file so the @@ -1575,6 +1578,7 @@ int launcher_model_rom_report(const LauncherModel* m, char* out, size_t cap) { } void launcher_model_set_rom(LauncherModel* m, const char* path) { + if(m->disc_selection_locked && (!path || strcmp(path,m->rom_full)!=0))return; if (lm_is_sbi_path(path)) { char mounted[sizeof(m->rom_full)] = {0}; m->setup_error[0] = '\0'; @@ -2429,6 +2433,16 @@ void launcher_model_cycle_screen_kind(LauncherModel* m) { int n = 4; (void)screen_kind_vocab(m, &n); m->s.screen_kind = (clampi(m->s.screen_kind, 0, n - 1) + 1) % n; } +int launcher_model_screen_kind_count(const LauncherModel* m) { + int n=0;(void)screen_kind_vocab(m,&n);return n; +} +const char* launcher_model_screen_kind_name(const LauncherModel* m,int index) { + int n=0;const char* const* names=screen_kind_vocab(m,&n); + return names[clampi(index,0,n-1)]; +} +void launcher_model_set_screen_kind(LauncherModel* m,int index) { + if(m && m->has_screen_kind)m->s.screen_kind=clampi(index,0,launcher_model_screen_kind_count(m)-1); +} const char* launcher_model_screen_kind_label(const LauncherModel* m) { int n = 4; @@ -3268,6 +3282,13 @@ bool launcher_model_can_finish_setup(const LauncherModel* m) { return true; } +bool launcher_model_can_open_mods(const LauncherModel* m) { + if (!m || m->profile_handoff || m->setup_preparing) return false; + if (m->profile && m->profile->verify.mode == 1) + return m->rom_present && m->rom_full[0] && m->verify.verdict == 1; + return true; /* Preserve non-disc hosts. */ +} + bool launcher_model_can_launch(const LauncherModel* m) { if (!m) return false; if (!m->rom_present || strcmp(m->rom_size, "--") == 0) return false; diff --git a/src/common/launcher_model.h b/src/common/launcher_model.h index 3fbe6dc..d70dd68 100644 --- a/src/common/launcher_model.h +++ b/src/common/launcher_model.h @@ -560,6 +560,11 @@ typedef struct { // See VerifyResult above. Untouched (all-zero) for verify.mode==0 systems // (SNES) — panels branch on m->profile->verify.mode, never on this alone. VerifyResult verify; + // From GameInfo (RECOMP_LAUNCHER_HAS_ARTWORK_GALLERY): the host chose the + // disc already; cover folder root (borrowed, NULL = default); Quick cards. + bool disc_selection_locked; + const char* artwork_root; + bool dashboard_quick_controls; // ---- editable settings (working copy of the C ABI struct) ---- RecompLauncherCSettings s; @@ -987,6 +992,9 @@ void launcher_model_toggle_perspective_texturing(LauncherModel* m); // correction rounds back to the native pixel and has no visible effect. bool launcher_model_geometry_correction_inert(const LauncherModel* m); void launcher_model_cycle_screen_kind(LauncherModel* m); // Raw/CRT/Composite/Trinitron +int launcher_model_screen_kind_count(const LauncherModel* m); +const char* launcher_model_screen_kind_name(const LauncherModel* m,int index); +void launcher_model_set_screen_kind(LauncherModel* m,int index); const char* launcher_model_screen_kind_label(const LauncherModel* m); void launcher_model_toggle_scanlines(LauncherModel* m); void launcher_model_set_scanline_strength_pct(LauncherModel* m, int pct); // clamps 1..100 @@ -1216,6 +1224,8 @@ void launcher_model_setup_start_bios_regen(LauncherModel* m); bool launcher_model_can_finish_setup(const LauncherModel* m); // True when BIOS (if required) and ROM/disc are ready to launch (incl. fingerprint). bool launcher_model_can_launch(const LauncherModel* m); +// Dashboard Mods entry requires a fully verified disc and no regional handoff. +bool launcher_model_can_open_mods(const LauncherModel* m); // True when the mounted disc is OK for online (TOC/cue policy + content). // Non-disc games (verify.mode!=1) always return true when netplay is supported. bool launcher_model_netplay_disc_ok(const LauncherModel* m); diff --git a/src/common/launcher_quick_mods.h b/src/common/launcher_quick_mods.h new file mode 100644 index 0000000..b719ad5 --- /dev/null +++ b/src/common/launcher_quick_mods.h @@ -0,0 +1,11 @@ +#pragma once +#include "launcher_mod_feature_actions.h" + +/* The dashboard's Quick Mods card lists the features the provider marks quick + * (the manifest's quick flag or the player's own choice) that the Mods page + * also lists. Switching one uses launcher_mod_enable_feature, the same path as + * the Mods page. */ +static inline int launcher_quick_listed(const RecompLauncherCModProvider* p, + const RecompLauncherCModFeature* f) { + return p && f && f->quick && launcher_mod_feature_listed(p, f); +} diff --git a/src/recomp_launcher.h b/src/recomp_launcher.h index a4c96f9..cf7f0d3 100644 --- a/src/recomp_launcher.h +++ b/src/recomp_launcher.h @@ -1031,6 +1031,11 @@ typedef struct RecompLauncherCModFeature { * never reach a shipped build at all -- when one appears here, this is a * local developer build. Appended for ABI stability. */ int channel; + /* Listed in the dashboard's Quick Mods card (GameInfo. + * dashboard_quick_controls): the player's saved choice, else the + * manifest's quick flag. See feature_set_quick. Appended for ABI + * stability; zero keeps a feature off the card. */ + int quick; } RecompLauncherCModFeature; typedef struct RecompLauncherCModOption { @@ -1208,7 +1213,13 @@ typedef struct RecompLauncherCModProvider { * Appended for statically paired source compatibility, not binary sizing. */ int (*try_commit)(void* ctx, const char* image_path); unsigned long long (*preparation_revision)(void* ctx); + /* Add a feature to (quick=1) or remove it from (0) the dashboard's Quick + * Mods card. Presentation only; activation is unchanged. NULL hides the + * "Show in Quick Mods" switch. Appended for ABI stability. */ + int (*feature_set_quick)(void* ctx, const char* package_id, + const char* feature_id, int quick); } RecompLauncherCModProvider; +#define RECOMP_LAUNCHER_HAS_QUICK_MODS 1 #define RECOMP_LAUNCHER_HAS_WORKER_MOD_COMMIT 1 #define RECOMP_LAUNCHER_HAS_PREPARED_MOD_COMMIT 1 @@ -2422,7 +2433,21 @@ typedef struct RecompLauncherCGameInfo { /* 0/1: historical integer multiplier. 10: tenths (10..50 = 1.0..5.0). * Also exposes hold/toggle choice on the host-shortcut panel. */ int assist_fast_forward_divisor; + + /* ---- Preselected media, covers and dashboard quick controls ----------- + * disc_selection_locked: the host already chose and verified the disc + * (e.g. a regional package's entry); the launcher offers no other disc + * and ignores the remembered one. artwork_root: UTF-8 folder holding one + * / cover folder per disc; NULL = /artwork. + * Borrowed for the launcher's lifetime, never persisted. + * dashboard_quick_controls: show Quick Mods and Quick Settings cards next + * to the memory cards. All zero keeps the existing launcher. Appended for + * ABI stability. */ + int disc_selection_locked; + const char* artwork_root; + int dashboard_quick_controls; } RecompLauncherCGameInfo; +#define RECOMP_LAUNCHER_HAS_ARTWORK_GALLERY 1 /* RecompLauncherCGameInfo carries texture_filter_labels / _values. */ #define RECOMP_LAUNCHER_HAS_TEXTURE_FILTER_CATALOG 1 diff --git a/tests/launcher_artwork_folder_test.cpp b/tests/launcher_artwork_folder_test.cpp new file mode 100644 index 0000000..8fc6ac9 --- /dev/null +++ b/tests/launcher_artwork_folder_test.cpp @@ -0,0 +1,50 @@ +#include "launcher_artwork.h" +#include +#include +using namespace launcher_artwork; +int main(int argc,char** argv) { + assert(argc==2);const auto qa=fs::u8path(argv[1]); + fs::remove_all(qa); // a previous run's tree; the test owns this directory + fs::create_directories(qa);const auto home=qa/"portable"/"artwork"; + const std::vector regions={"SCES-01330","SCES-01331","SLPS-01144"}; + for(const auto& serial:regions) { + const auto standard=home/serial;fs::create_directories(standard); + assert(folder(home,serial)==fs::absolute(standard).lexically_normal()); + std::ofstream(standard/"front.png")<<"fixture"; + std::ofstream(standard/"back.JPG")<<"fixture"; + std::ofstream(standard/"disc.tga")<<"fixture"; + std::ofstream(standard/"manual.pdf")<<"%PDF-fixture"; + auto c=scan(home,serial);assert(c.images.size()==3); + assert(rank(c.images[0])==0 && rank(c.images[1])==1 && rank(c.images[2])==2); + const auto custom=qa/fs::u8path(u8"Other covers \u00c4 \u65e5\u672c folder")/serial; + fs::create_directories(custom); + for(int i=0;i<40;++i)std::ofstream(custom/(std::to_string(i)+".png"))<<"fixture"; + assert(select_folder(home,serial,custom)); + assert(folder(home,serial)==fs::absolute(custom).lexically_normal()); + assert(scan(home,serial).images.size()==40); // no old 32-image truncation + assert(!select_folder(home,serial,custom/"missing")); + assert(scan(home,serial).images.size()==40); // invalid choice preserves saved path + assert(fs::file_size(standard/"front.png")==7); + assert(fs::file_size(standard/"manual.pdf")==12); // existing documents untouched + } + for(const auto& serial:regions)assert(scan(home,serial).images.size()==40); + const auto empty=qa/"empty";fs::create_directories(empty); + assert(select_folder(home,regions[0],empty));assert(scan(home,regions[0]).images.empty()); + assert(scan(home,regions[1]).images.size()==40 && scan(home,regions[2]).images.size()==40); + fs::remove(empty);assert(scan(home,regions[0]).images.empty()); + assert(!select_folder(home,"../bad",qa));assert(scan(home,"../bad").images.empty()); + assert(supported("T-1234G") && supported("MK-81000") && !supported("") && + !supported("..") && !supported("a/b") && !supported(std::string(65,'A'))); + // Internal folders are relative, so the preference survives moving the portable tree. + assert(select_folder(home,regions[0],home/regions[1])); + const auto moved=qa/"moved portable";fs::rename(home.parent_path(),moved); + assert(scan(moved/"artwork",regions[0]).images.size()==3); + // External choices remain absolute, with independent state per region. + assert(scan(moved/"artwork",regions[1]).images.size()==40); + std::ofstream(moved/"artwork"/regions[2]/"cover-folder.txt",std::ios::binary)< +#include +#include + +static unsigned draw_case(bool enabled, bool hovered) { + ImGui::NewFrame(); + ImGui::Begin("button-fixture"); + ImGuiContext& g = *GImGui; + ImDrawList* draw = ImGui::GetWindowDrawList(); + int start = draw->VtxBuffer.Size; + assert(!rui_mods_button(enabled, "Mods", ImVec2(110, 34))); + assert(((g.LastItemData.ItemFlags & ImGuiItemFlags_Disabled) != 0) == !enabled); + unsigned alpha = 0; + for (int i = start; i < draw->VtxBuffer.Size; ++i) { + unsigned a = (draw->VtxBuffer[i].col >> IM_COL32_A_SHIFT) & 255; + if (a > alpha) alpha = a; + } + assert(alpha > 0); // The disabled button remains visible. + assert(!(g.CurrentItemFlags & ImGuiItemFlags_Disabled)); // Other controls unaffected. + assert(!rui_mods_button_tooltip(enabled)); // No hover -> no message. + if (hovered) { + g.LastItemData.StatusFlags |= ImGuiItemStatusFlags_HoveredRect | + ImGuiItemStatusFlags_HoveredWindow; + if (!enabled) assert(!ImGui::IsItemHovered()); // Ordinary hover rejects disabled widgets. + ImGui::LogToBuffer(); + assert(rui_mods_button_tooltip(enabled) == !enabled); + if (!enabled) + assert(std::strstr(g.LogBuffer.c_str(), "Please select a valid game disc first.")); + else + assert(!std::strstr(g.LogBuffer.c_str(), "Please select a valid game disc first.")); + ImGui::LogFinish(); + } + ImGui::End(); + ImGui::Render(); + return alpha; +} + +int main() { + IMGUI_CHECKVERSION(); ImGui::CreateContext(); + ImGuiIO& io = ImGui::GetIO(); io.IniFilename = nullptr; + io.DisplaySize = ImVec2(1280, 720); io.DeltaTime = 1.0f / 60.0f; + unsigned char* pixels; int w, h; io.Fonts->GetTexDataAsRGBA32(&pixels, &w, &h); + draw_case(false, false); // First-frame window layout. + const unsigned disabled = draw_case(false, true); + const unsigned enabled = draw_case(true, true); + assert(disabled < enabled); // Real disabled styling, not just a click guard. + ImGui::DestroyContext(); + std::puts("PASS real ImGui Mods button: visible/disabled styling, exact disabled-hover tooltip, enabled state, balanced scope; no desktop/input automation"); +} diff --git a/tests/launcher_mod_gate_test.c b/tests/launcher_mod_gate_test.c new file mode 100644 index 0000000..8f58ca0 --- /dev/null +++ b/tests/launcher_mod_gate_test.c @@ -0,0 +1,71 @@ +/* Model policy only; no window, input simulation or gameplay state. */ +#include "launcher_model.c" +#include "launcher_mod_feature_actions.h" +#include "launcher_quick_mods.h" +#include +#include +void launcher_binds_set_zapper(int a,int b) { (void)a;(void)b; } +static int feature_enabled,enable_calls,deny_change; +static int enable_feature(void* ctx,const char* package,const char* id,int enabled) { + (void)ctx;assert(!strcmp(package,"fixture.package")); + assert(!strcmp(id,"fixture-feature"));++enable_calls; + if(deny_change)return 0;feature_enabled=enabled;return 1; +} +int main(void) { + LauncherModel m = {0}; + SystemProfile profile = {0}; + profile.verify.mode = 1; m.profile = &profile; + assert(!launcher_model_can_open_mods(NULL)); + assert(!launcher_model_can_open_mods(&m)); + m.rom_present = true; strcpy(m.rom_full,"verified-disc.cue"); + for(int v=0;v<4;v++) { m.verify.verdict=v;assert(launcher_model_can_open_mods(&m)==(v==1)); } + m.verify.verdict=1;assert(launcher_model_can_open_mods(&m)); + m.profile_handoff=true;assert(!launcher_model_can_open_mods(&m)); + m.profile_handoff=false;m.setup_preparing=true;assert(!launcher_model_can_open_mods(&m)); + m.setup_preparing=false;m.rom_present=false;assert(!launcher_model_can_open_mods(&m)); + profile.verify.mode=0;assert(launcher_model_can_open_mods(&m)); + m.disc_selection_locked=true;strcpy(m.rom_full,"verified-disc.cue");m.rom_present=true; + launcher_model_set_rom(&m,"foreign-disc.cue");assert(!strcmp(m.rom_full,"verified-disc.cue")); + launcher_model_set_rom(&m,NULL);assert(m.rom_present && !strcmp(m.rom_full,"verified-disc.cue")); + assert(!m.profile_handoff); + RecompLauncherCModProvider provider={0};provider.feature_enable=enable_feature; + m.mods=&provider;profile.verify.mode=1;m.verify.verdict=1; + RecompLauncherCModFeature feature={0}; + strcpy(feature.package_id,"fixture.package");strcpy(feature.id,"fixture-feature"); + assert(launcher_mod_enable_feature(&m,&feature,1) && feature_enabled && enable_calls==1); + assert(launcher_mod_enable_feature(&m,&feature,0) && !feature_enabled); + deny_change=1;assert(!launcher_mod_enable_feature(&m,&feature,1) && !feature_enabled);deny_change=0; + m.verify.verdict=0;int calls=enable_calls; + assert(!launcher_mod_enable_feature(&m,&feature,1) && enable_calls==calls); + m.verify.verdict=1;provider.hide_hidden_features=1;feature.hidden=1; + assert(!launcher_mod_enable_feature(&m,&feature,1) && enable_calls==calls); + static const char* labels[]={"Native","1080p","4K"};static const int values[]={1,1080,2160}; + m.has_supersampling=true;m.internal_resolution_labels=labels;m.internal_resolution_values=values;m.num_internal_resolutions=3; + launcher_model_set_internal_resolution(&m,2);assert(m.s.internal_resolution==2160 && launcher_model_internal_resolution_index(&m)==2); + m.has_vsync=true;launcher_model_set_vsync(&m,RECOMP_LAUNCHER_VSYNC_ON);assert(m.s.vsync==RECOMP_LAUNCHER_VSYNC_ON); + launcher_model_set_vsync(&m,RECOMP_LAUNCHER_VSYNC_OFF);assert(m.s.vsync==RECOMP_LAUNCHER_VSYNC_OFF); + launcher_model_set_fullscreen(&m,2);assert(!strcmp(launcher_model_fullscreen_label(&m),"Exclusive")); + launcher_model_toggle_fullscreen(&m);assert(!m.s.fullscreen); + launcher_model_set_fullscreen(&m,1);assert(!strcmp(launcher_model_fullscreen_label(&m),"Borderless")); + m.has_screen_kind=true;assert(launcher_model_screen_kind_count(&m)==4); + for(int i=0;i<4;++i){launcher_model_set_screen_kind(&m,i);assert(!strcmp(launcher_model_screen_kind_label(&m),launcher_model_screen_kind_name(&m,i)));} + static const char* custom_screens[]={"Panel A","Panel B","Panel C","Panel D","Panel E"}; + profile.screen_kind_names=custom_screens;profile.screen_kind_count=5; + assert(launcher_model_screen_kind_count(&m)==5);launcher_model_set_screen_kind(&m,4);assert(!strcmp(launcher_model_screen_kind_label(&m),"Panel E")); + launcher_model_cycle_screen_kind(&m);assert(m.s.screen_kind==0); + profile.screen_kind_names=NULL;profile.screen_kind_count=0; + feature.hidden=0;provider.hide_hidden_features=0; + /* Quick Mods lists exactly the provider's quick features that the Mods + * page lists; no feature name or id is special. */ + feature.quick=0;assert(!launcher_quick_listed(&provider,&feature)); + feature.quick=1;assert(launcher_quick_listed(&provider,&feature)); + strcpy(feature.id,"widescreen");strcpy(feature.name,"Seamless Loading"); + feature.quick=0;assert(!launcher_quick_listed(&provider,&feature)); + feature.quick=1;feature.hidden=1;provider.hide_hidden_features=1; + assert(!launcher_quick_listed(&provider,&feature)); + assert(!launcher_quick_listed(NULL,&feature) && !launcher_quick_listed(&provider,NULL)); + puts("PASS Quick controls: one provider/state, rejected/hidden/invalid-disc changes blocked, existing setting vocabularies and model values shared"); + puts("PASS locked disc rejects foreign paths/removal without verification or handoff"); + puts("PASS Mods button policy: empty/bad/warn/handoff/preparing locked; verified enabled; non-disc unchanged"); + return 0; +} diff --git a/tests/launcher_model_cache_test.c b/tests/launcher_model_cache_test.c index 8d584dc..0ec39ac 100644 --- a/tests/launcher_model_cache_test.c +++ b/tests/launcher_model_cache_test.c @@ -98,6 +98,14 @@ int main(int argc, char** argv) { "valid initial_rom should win")) return 9; + game.disc_selection_locked=1; + game.artwork_root="portable-artwork-root"; + game.dashboard_quick_controls=1; + launcher_model_init(&model,&settings,&game,stale); + if(!expect_path(model.rom_full,stale,"preselected path cannot be replaced by cached media") || !model.disc_selection_locked || model.artwork_root!=game.artwork_root || !model.dashboard_quick_controls)return 13; + launcher_model_set_rom(&model,rom); + if(!expect_path(model.rom_full,stale,"locked media cannot be changed"))return 14; + /* A host that owns its bindings must receive Zapper edits through the * settings object. A legacy host still uses its native keybind writer. */ model.settings_bindings = true;