From 3ed2f52b904877b44a17d67d920b50c9a5e84f4e Mon Sep 17 00:00:00 2001 From: Arnaldo Lima <197129546+arnaldolima1@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:55:06 -0300 Subject: [PATCH 01/29] Start new render targets as zeros, not undefined A game that draws only part of a buffer and samples around it reads the pixels it never drew. On a PSP those hold what VRAM held, normally zeros; here a new target started undefined, and MoltenVK showed it as magenta. A bloom chain drawn side by side in one 512-wide buffer, each step at its own address, spread that magenta over the whole picture. New targets are now cleared to zero once, when they are first used. _NO_TARGET_CLEAR restores the old behaviour. Also: _TRACE_GE_LIST_WORDS sets how many words each traced display-list run prints (20000 before, and by default). Verified on macOS arm64 (Apple M4, MoltenVK) with God of War: Ghost of Sparta (UCES01401) under the interpreter: the title menu, magenta before, draws with its own colours. Not verified on Windows, Linux or Android, nor with any other game. Co-Authored-By: Claude Opus 5.5 --- host/gpu/ge_state.cpp | 9 +++++++-- host/gpu/vulkan_renderer.cpp | 22 +++++++++++++++++++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/host/gpu/ge_state.cpp b/host/gpu/ge_state.cpp index 48af218..42097dd 100644 --- a/host/gpu/ge_state.cpp +++ b/host/gpu/ge_state.cpp @@ -1359,7 +1359,8 @@ std::uint32_t GeState::execute(const GuestMemory &memory, std::uint32_t pc, std: // _TRACE_GE_LIST=N[:M]: every command word of display list runs // N to N+M-1 (default one), counted from the first, to read what a - // screen is built from when it draws nothing sensible. + // screen is built from when it draws nothing sensible. Each run prints + // its first 20000 words; _TRACE_GE_LIST_WORDS changes that. static const auto list_trace = []() -> std::pair { const char *text = portablekit::env("TRACE_GE_LIST"); if (text == nullptr) return {~0ull, 0ull}; @@ -1368,6 +1369,10 @@ std::uint32_t GeState::execute(const GuestMemory &memory, std::uint32_t pc, std: const std::uint64_t count = end != nullptr && *end == ':' ? std::strtoull(end + 1, nullptr, 0) : 1ull; return {first, count}; }(); + static const std::uint64_t list_trace_words = [] { + const char *text = portablekit::env("TRACE_GE_LIST_WORDS"); + return text != nullptr ? std::strtoull(text, nullptr, 0) : 20000ull; + }(); static std::uint64_t runs = 0; const std::uint64_t run = runs++; const bool dump = run >= list_trace.first && run - list_trace.first < list_trace.second; @@ -1377,7 +1382,7 @@ std::uint32_t GeState::execute(const GuestMemory &memory, std::uint32_t pc, std: if (stall != 0u && pc == stall) return pc; if (!memory.contains(pc, 4u)) return pc; const std::uint32_t word = memory.load32(pc); - if (dump && steps < 20000u) + if (dump && steps < list_trace_words) std::cerr << "[ge-list] " << psprecomp::hex32(pc) << " " << psprecomp::hex32(word) << "\n"; const std::uint32_t command = word >> 24u; const std::uint32_t data = word & 0x00FFFFFFu; diff --git a/host/gpu/vulkan_renderer.cpp b/host/gpu/vulkan_renderer.cpp index 9347fc5..b7df31d 100644 --- a/host/gpu/vulkan_renderer.cpp +++ b/host/gpu/vulkan_renderer.cpp @@ -3812,9 +3812,25 @@ void VulkanRenderer::Impl::begin_pass(std::uint32_t address, std::uint32_t overw if (target == nullptr) return; if (!target->initialized) { // Attachments are loaded, not cleared, so a new target starts undefined: - // move it into the layouts the render pass expects once. - transition(command_buffer, target->color, VK_IMAGE_LAYOUT_UNDEFINED, - VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + // move it into the layouts the render pass expects once. Its colour + // starts as zeros, as the PSP's VRAM does: a game that draws only part + // of a buffer and samples around it (a bloom chain drawn side by side + // in one 512-wide buffer, each step at its own address) reads those + // pixels, and undefined contents show through (magenta on MoltenVK). + // _NO_TARGET_CLEAR leaves them undefined, as before. + static const bool no_target_clear = portablekit::env("NO_TARGET_CLEAR") != nullptr; + if (no_target_clear) { + transition(command_buffer, target->color, VK_IMAGE_LAYOUT_UNDEFINED, + VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + } else { + transition(command_buffer, target->color, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL); + const VkClearColorValue zero{{0.0f, 0.0f, 0.0f, 0.0f}}; + const VkImageSubresourceRange range{VK_IMAGE_ASPECT_COLOR_BIT, 0u, 1u, 0u, 1u}; + vkCmdClearColorImage(command_buffer, target->color, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &zero, 1u, + &range); + transition(command_buffer, target->color, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + } transition(command_buffer, target->depth, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, depth_aspect()); target->initialized = true; From 2886672432a443853bc98fec58bc31db86f96050 Mon Sep 17 00:00:00 2001 From: Arnaldo Lima <197129546+arnaldolima1@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:55:06 -0300 Subject: [PATCH 02/29] Present a repeated flip once a vblank; let a profile cap flips at one a vblank sceDisplaySetFrameBuf presented every call. The display reads the framebuffer at the vblank, so setting the same buffer again within one vblank period with nothing drawn since is no longer presented again (_PRESENT_EVERY_FLIP restores the old behaviour). GameProfile::flips_wait_for_vblank, off by default: a game whose frame loop waits on nothing but the GE (which takes no emulated time here) flipped as fast as the host could draw, ten times a vblank, until the busy clock charged 100 ms at once and the pacer held the game for it: a hitch every few frames, emulation at 77% and audio underruns. With it on, a second flip within one vblank period waits for the next vblank (_FLIP_UNLIMITED turns it off). Verified on macOS arm64 (Apple M4, MoltenVK) with God of War: Ghost of Sparta (UCES01401), recompiled: title menu at 60 fps and 100% speed, no frame over 20 ms, no audio underruns. With frame_vblanks left at 2 the presents stall (0 fps) while the game flips at 60; the profile sets 1. That interaction is not fixed here. Not verified on other platforms or with other games. Co-Authored-By: Claude Opus 5.5 --- host/hle/hle_media.cpp | 44 ++++++++++++++++++++++++++++++++++++++---- host/profile.hpp | 7 +++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/host/hle/hle_media.cpp b/host/hle/hle_media.cpp index d983fe6..47b18c8 100644 --- a/host/hle/hle_media.cpp +++ b/host/hle/hle_media.cpp @@ -555,13 +555,49 @@ void register_display_ctrl(HleRegistrar &hle) { kernel().finish(ctx, 0u); }); // The guest flipping the framebuffer is the end of a frame. + // + // The display reads the framebuffer at the vblank, so setting the buffer + // already set, within the same vblank period and with nothing drawn since, + // shows nothing new: it is not presented again. A game seen setting it + // ten times a frame paid for ten presents, about 10 ms each on MoltenVK, + // which the busy clock then made up with a 100 ms hold. _PRESENT_EVERY_FLIP presents every call, as + // before. hle.add("sceDisplay", "sceDisplaySetFrameBuf", [](Runtime &rt, AllegrexContext &ctx) { - media().display.framebuffer = arg(ctx, 0); - media().display.buffer_width = arg(ctx, 1); - media().display.pixel_format = arg(ctx, 2); + DisplayState &display = media().display; + const bool same_buffer = display.framebuffer == arg(ctx, 0) && display.buffer_width == arg(ctx, 1) && + display.pixel_format == arg(ctx, 2); + display.framebuffer = arg(ctx, 0); + display.buffer_width = arg(ctx, 1); + display.pixel_format = arg(ctx, 2); trace_pacing("sceDisplaySetFrameBuf"); stall_watchdog::note_frame(); - present_frame(rt); + static const bool every_flip = portablekit::env("PRESENT_EVERY_FLIP") != nullptr; + static std::uint64_t presented_vblank = ~0ull; + static std::uint64_t presented_draws = ~0ull; + std::uint64_t draws = 0u; +#if defined(PORTABLEKIT_HAS_RENDERER) + if (media().renderer) draws = media().renderer->draws_submitted(); +#endif + if (every_flip || !same_buffer || presented_vblank != kernel().vblank_count() || presented_draws != draws) { + present_frame(rt); + presented_vblank = kernel().vblank_count(); +#if defined(PORTABLEKIT_HAS_RENDERER) + presented_draws = media().renderer ? media().renderer->draws_submitted() : 0u; +#endif + } + // At most one flip a vblank for a game that asks for it + // (GameProfile::flips_wait_for_vblank). + static const bool limit = portablekit::game().flips_wait_for_vblank && !portablekit::env_set("FLIP_UNLIMITED"); + static std::uint64_t flipped_vblank = ~0ull; + const std::uint64_t vblank = kernel().vblank_count(); + const bool again = flipped_vblank == vblank; + flipped_vblank = again ? vblank + 1u : vblank; + if (limit && again) { + WaitState wait{}; + wait.type = WaitType::VBlank; + kernel().block(ctx, wait, 0u); + return; + } kernel().finish(ctx, 0u); }); diff --git a/host/profile.hpp b/host/profile.hpp index 09322ed..053ba64 100644 --- a/host/profile.hpp +++ b/host/profile.hpp @@ -263,6 +263,13 @@ struct GameProfile { // (Purun). Frame interpolation presents between frames of this length, // and the Frame rate setting offers only rates above the game's own. std::uint32_t frame_vblanks = 2; + // A game that paces its frame loop on nothing but the GE's work (which + // takes no emulated time here) flips as fast as the host can draw, many + // times a vblank, and then waits all at once when the busy clock catches + // up. True makes a second flip within one vblank period wait for the next + // vblank, so the game flips at most once a vblank, as it can on a PSP. + // _FLIP_UNLIMITED turns it off. + bool flips_wait_for_vblank = false; // --- Loading ------------------------------------------------------------- // Fast loading (kernel/fast_loading.hpp) lets emulated time run ahead From 21bbec9cd31f43b73c09a08412d71a9411e49c8f Mon Sep 17 00:00:00 2001 From: Arnaldo Lima <197129546+arnaldolima1@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:43:24 -0300 Subject: [PATCH 03/29] Add a launcher, interface translations and a switch for multiplayer A profile's LauncherTheme puts a launcher before the game: a side bar with the game's logo and the menu's pages, a home page with a banner and quick settings, and a Play button. The menu draws its pages inside it, so every setting lives in one place before and during play. Its art is the player's: images in the data folder's launcher/ (or an app's Resources/launcher/), else PIC1.PNG and ICON0.PNG read from their disc image. --play skips it once, --launcher shows it once, and the player can turn it off. It backs the saves up when Play is pressed, keeping the newest ten. The theme's colours become the whole interface's: the widget palette is set at start instead of fixed, and text on an accent fill has a colour of its own. The interface speaks English and Brazilian Portuguese. Widgets look up what they draw in a table (ui/i18n.hpp); sentences built around names use patterns. The language is a setting (ui.language), the system's by default. GameProfile::adhoc_play false drops the Network page and the multiplayer rows for a game without multiplayer. The window is titled with the project's name rather than the executable's. Tested on macOS (Apple Silicon, MoltenVK) with God of War: Ghost of Sparta: launcher pages, Play, back, the in-game menu in both languages, checked from window captures. Not tested: Linux, Windows, Android, a gamepad by hand, touch. Co-Authored-By: Claude Opus 5.5 --- cmake/PortableKit.cmake | 2 + docs/PROFILE_GUIDE.md | 6 + host/app_paths.cpp | 5 + host/app_paths.hpp | 4 + host/gpu/vulkan_renderer.cpp | 17 +- host/gpu/vulkan_renderer.hpp | 5 + host/main.cpp | 24 +- host/profile.hpp | 51 +++ host/settings/settings.cpp | 8 + host/settings/settings.hpp | 3 + host/ui/file_browser.cpp | 2 +- host/ui/i18n.cpp | 539 +++++++++++++++++++++++++++++ host/ui/i18n.hpp | 27 ++ host/ui/launcher.cpp | 634 +++++++++++++++++++++++++++++++++++ host/ui/launcher.hpp | 34 ++ host/ui/layer.cpp | 4 + host/ui/menu.cpp | 167 +++++++-- host/ui/text_input.cpp | 8 +- host/ui/widgets.cpp | 103 +++--- host/ui/widgets.hpp | 50 ++- 20 files changed, 1601 insertions(+), 92 deletions(-) create mode 100644 host/ui/i18n.cpp create mode 100644 host/ui/i18n.hpp create mode 100644 host/ui/launcher.cpp create mode 100644 host/ui/launcher.hpp diff --git a/cmake/PortableKit.cmake b/cmake/PortableKit.cmake index 90147fb..4c5560b 100644 --- a/cmake/PortableKit.cmake +++ b/cmake/PortableKit.cmake @@ -101,7 +101,9 @@ set(PORTABLEKIT_RENDERER_SOURCES host/ui/font_menu.cpp host/input/motion.cpp host/input/steam_deck_imu.cpp + host/ui/i18n.cpp host/ui/input_script.cpp + host/ui/launcher.cpp host/ui/layer.cpp host/ui/menu.cpp host/ui/save_screen.cpp diff --git a/docs/PROFILE_GUIDE.md b/docs/PROFILE_GUIDE.md index fe7dcab..fce42a7 100644 --- a/docs/PROFILE_GUIDE.md +++ b/docs/PROFILE_GUIDE.md @@ -71,6 +71,12 @@ Two more are for a port that goes further than the PSP did, and both are null un `interpolation_thresholds` is for frame interpolation, which blends the game's frames and must not blend across a camera cut. The angles and fractions it uses to tell a cut from motion hold for any game; its distances are in the game's own world units, and the defaults were measured on Monster Hunter Portable 3rd. A game whose world is on a very different scale gives its own; one that does not, gets those defaults. +`adhoc_play` is false for a game with no multiplayer: the menu then has no Network page and no row about multiplayer. It is true by default, so a profile that says nothing keeps them. + +`launcher`, a `LauncherTheme`, puts a launcher before the game: a side bar with the game's logo and the menu's pages, a home page with a banner and quick settings, and a Play button. Its colours become the whole interface's, the in-game menu and the setup included. Its art is never the repository's: images the player puts in the data folder's `launcher/` (or an app's `Resources/launcher/`), else `PSP_GAME/PIC1.PNG` and `PSP_GAME/ICON0.PNG` read from their disc image. Null: the game starts at once, as before. The player can turn it off, `--play` skips it once and `--launcher` shows it once. Sparta's `host/sparta_profile.cpp` is the example. + +The interface speaks English and Brazilian Portuguese (`host/ui/i18n.hpp`): the widgets look up what they draw in a table, and the player picks the language under System. A profile's own text, such as a launcher's credits, is translated when the table has it and shown as written when it does not. + A driver writes into the game's memory, so check the code it relies on before it writes anything — Yakumo compares every instruction and constant it depends on at start-up, from `patch_loaded_image`, and stays out, saying why, if one differs. ## Generated code stays out of the repository diff --git a/host/app_paths.cpp b/host/app_paths.cpp index 8c8d1ce..c99ba8d 100644 --- a/host/app_paths.cpp +++ b/host/app_paths.cpp @@ -90,6 +90,11 @@ std::filesystem::path bundled_font_directory() { return shipped_directory("Resources", "fonts"); } +std::filesystem::path bundled_launcher_directory() { + if (!bundled_resource_directory().empty()) return bundled_resource_directory() / "launcher"; + return shipped_directory("Resources", "launcher"); +} + std::vector bundled_fonts() { std::vector fonts; const std::filesystem::path directory = bundled_font_directory(); diff --git a/host/app_paths.hpp b/host/app_paths.hpp index 8f224d7..6bd2a1e 100644 --- a/host/app_paths.hpp +++ b/host/app_paths.hpp @@ -23,6 +23,10 @@ namespace portablekit { // Contents/Resources/fonts in a macOS app bundle. [[nodiscard]] std::filesystem::path bundled_font_directory(); +// Where the images the launcher shows may ship (LauncherTheme): launcher/ next +// to the executable, or Contents/Resources/launcher in a macOS app bundle. +[[nodiscard]] std::filesystem::path bundled_launcher_directory(); + // Font files a release ships in fonts/ next to the executable, as fallbacks // after the system's own fonts. [[nodiscard]] std::vector bundled_fonts(); diff --git a/host/gpu/vulkan_renderer.cpp b/host/gpu/vulkan_renderer.cpp index b7df31d..361eed6 100644 --- a/host/gpu/vulkan_renderer.cpp +++ b/host/gpu/vulkan_renderer.cpp @@ -57,7 +57,8 @@ namespace portablekit::gpu { std::string default_window_title() { - std::string title = portablekit::game().app_name; + // What the player calls the port, not the executable's name. + std::string title = portablekit::game().project_name; if (*kBuildLabel != '\0') title += std::string(" (") + kBuildLabel + ")"; return title; } @@ -1598,6 +1599,7 @@ struct VulkanRenderer::Impl { void submit_frame(); Texture white_texture{}; + std::vector ui_images; // create_ui_image() std::map textures; // HD texture pack (texture_pack.hpp). `pack` is null while the setting // is off or no pack is installed; turning the setting on or off takes @@ -5605,6 +5607,17 @@ void VulkanRenderer::begin_ui_frame() { if (impl_ && impl_->ui_ready) ImGui_ImplVulkan_NewFrame(); } +std::uint64_t VulkanRenderer::create_ui_image(std::uint32_t width, std::uint32_t height, + const std::uint32_t *pixels) { + if (!impl_ || !impl_->ui_ready || width == 0u || height == 0u || pixels == nullptr) return 0u; + Impl &impl = *impl_; + Impl::Texture texture = impl.create_texture(width, height, pixels); + if (texture.view == VK_NULL_HANDLE) return 0u; + const VkDescriptorSet set = ImGui_ImplVulkan_AddTexture(texture.view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + impl.ui_images.push_back(texture); + return reinterpret_cast(set); +} + void VulkanRenderer::set_ui_draw_data(ImDrawData *draw_data) { if (impl_) impl_->ui_draw_data = draw_data; } @@ -7998,6 +8011,8 @@ void VulkanRenderer::shutdown() { Impl &impl = *impl_; vkDeviceWaitIdle(impl.device); // The interface may still be up when the game quits from its menu. + for (Impl::Texture &image : impl.ui_images) impl.destroy_texture(image); + impl.ui_images.clear(); if (impl.ui_ready && ImGui::GetCurrentContext() != nullptr) ImGui_ImplVulkan_Shutdown(); impl.ui_ready = false; impl.destroy_swapchain_views(); diff --git a/host/gpu/vulkan_renderer.hpp b/host/gpu/vulkan_renderer.hpp index d9e1803..7a5a131 100644 --- a/host/gpu/vulkan_renderer.hpp +++ b/host/gpu/vulkan_renderer.hpp @@ -263,6 +263,11 @@ class VulkanRenderer { void begin_ui_frame(); // Draw data from ImGui::Render, drawn over the next presented image. void set_ui_draw_data(ImDrawData *draw_data); + // An RGBA image (8 bits a channel, rows top first) the interface can draw + // with ImGui::Image, kept until the renderer is destroyed. Zero when the + // interface is not up. Call between interface frames. + [[nodiscard]] std::uint64_t create_ui_image(std::uint32_t width, std::uint32_t height, + const std::uint32_t *pixels); // Presents a frame outside the game's own flips: the last game frame when // there is one and `show_game` is set, a plain background otherwise, with // the interface over it. Used while the game is paused or not started. diff --git a/host/main.cpp b/host/main.cpp index ebeb54d..5a15ef9 100644 --- a/host/main.cpp +++ b/host/main.cpp @@ -14,6 +14,9 @@ #include "install/user_data.hpp" #include "gpu/ge_state.hpp" #include "kernel/kernel.hpp" +#if defined(PORTABLEKIT_HAS_RENDERER) +#include "ui/launcher.hpp" +#endif #include "psprecomp/common.hpp" #include "psprecomp/elf32.hpp" @@ -96,10 +99,12 @@ std::uint64_t configured_max_dispatches() { std::string usage() { const std::string app = portablekit::game().app_name; - return "usage: " + app + " [game_dir]\n" + return "usage: " + app + " [--play | --launcher] [game_dir]\n" " " + app + " --install [image.iso [--in-place]]\n" " " + app + " --adhoc-server [port]\n" " game_dir play from a directory holding EBOOT.ELF, disc.iso and ms0/\n" + " --play start the game at once, without the launcher\n" + " --launcher show the launcher even if it was turned off\n" " --install run the setup again on screen, then play\n" " --install image set up from image.iso without the setup screens, then exit\n" " --in-place use the image where it is instead of copying it\n" @@ -113,6 +118,8 @@ struct Options { bool install = false; std::optional install_image; bool in_place = false; + bool play = false; // skip the launcher + bool launcher = false; // show the launcher even when turned off }; class UsageError final : public std::runtime_error { @@ -127,6 +134,8 @@ Options parse_options(int argc, char **argv) { if (arg == "--help" || arg == "-h") throw UsageError(""); if (arg == "--install") options.install = true; else if (arg == "--in-place") options.in_place = true; + else if (arg == "--play") options.play = true; + else if (arg == "--launcher") options.launcher = true; else if (!arg.empty() && arg[0] == '-') throw UsageError("unknown option " + arg); else if (options.install && !options.install_image) options.install_image = portablekit::install::path_from_utf8(arg); else if (!options.install && !options.game_dir) options.game_dir = std::filesystem::path(arg); @@ -134,6 +143,7 @@ Options parse_options(int argc, char **argv) { } if (options.in_place && !options.install_image) throw UsageError("--in-place needs --install image.iso"); if (options.install && options.game_dir) throw UsageError("--install does not take a game_dir"); + if (options.play && options.launcher) throw UsageError("--play and --launcher exclude each other"); return options; } @@ -419,6 +429,18 @@ int main(int argc, char **argv) { std::cout << "No recompiled code is linked; running under the interpreter.\n"; } +#if defined(PORTABLEKIT_HAS_RENDERER) + // The launcher, once the window is up and before any of the game runs. + if (!options.play) { + switch (portablekit::ui::run_launcher(paths.disc_image, options.launcher)) { + case portablekit::ui::LauncherChoice::Play: break; + case portablekit::ui::LauncherChoice::Quit: portablekit::adhoc_shutdown(); return 0; + case portablekit::ui::LauncherChoice::SetUp: + portablekit::adhoc_shutdown(); + return portablekit::install::restart_for_setup(argv[0]); + } + } +#endif runtime.run(elf.runtime_entry(load_base), configured_max_dispatches()); std::cout << "Runtime stopped: " << runtime.stop_reason() << "\n"; // Quit from the menu, a closed window or the game ending: the network diff --git a/host/profile.hpp b/host/profile.hpp index 053ba64..514f119 100644 --- a/host/profile.hpp +++ b/host/profile.hpp @@ -92,6 +92,49 @@ struct GlyphCacheLayout { std::uint32_t row_gap; }; +// A launcher before the game starts (ui/launcher.hpp): a side bar with the +// game's logo and the pages of the menu, a home page with a banner and quick +// settings, and a Play button. Its colours are also the interface's: the menu +// and the setup take them too. +// +// Art is the player's: images in the launcher folder (/launcher/, or +// Resources/launcher/ in an app) when they are there, else read from their +// disc image. The port carries none. +struct LauncherTheme { + // The title, in the side bar when there is no logo: "GOD OF WAR" over + // "GHOST OF SPARTA". + const char *title = nullptr; + const char *subtitle = nullptr; + // TrueType fonts for the title and the headings; empty: a serif face of + // the system's, else the interface's. + std::span title_font = {}; + std::span heading_font = {}; + // Colours, 0xRRGGBB. + std::uint32_t background = 0x161414u; // the pages + std::uint32_t sidebar = 0x0F0E0Eu; // the side bar and the bottom bar + std::uint32_t panel = 0x1C1919u; // the in-game menu's panel + std::uint32_t accent = 0xA3161Au; // the Play button, the selected page + std::uint32_t accent_bright = 0xD8343Au; // headings, highlights + std::uint32_t text = 0xE8E4DFu; + std::uint32_t text_dim = 0x9C9590u; + // The banner on the home page: a file in the launcher folder, else an + // image on the disc. `backdrop_focus_y` is the part of its height kept in + // view when it is cropped to the banner's shape: 0 the top, 1 the bottom. + const char *backdrop_file = "background.jpg"; + const char *backdrop_on_disc = "PSP_GAME/PIC1.PNG"; + float backdrop_focus_y = 0.5f; + // The logo at the top of the side bar, likewise. `logo_on_black`: the + // image is the logo on black, which is made transparent and cropped away. + const char *logo_file = "logo.jpg"; + const char *logo_on_disc = "PSP_GAME/ICON0.PNG"; + bool logo_on_black = false; + // Where the port lives, shown at the foot of the home page; null: not shown. + const char *project_url = nullptr; + const char *issues_url = nullptr; + // A line of credits for the home page; null: none. + const char *credits = nullptr; +}; + // A driver for the game's own camera. Every member may be null; the framework // reads camera/camera_driver.hpp's defaults for those. struct CameraDriver { @@ -212,6 +255,9 @@ struct GameProfile { // agree on it, so it is the original release's code even when the port's // disc id is a different one. const char *adhoc_product_code; + // Whether the game plays with other players at all. False: the menu has + // no Network page and nothing about multiplayer. + bool adhoc_play = true; // --- Controls ---------------------------------------------------------- // What the triggers may press instead of L and R, for the way this game is @@ -292,6 +338,11 @@ struct GameProfile { // Where the game caches the glyphs it has drawn. Null: a font changed while // the game runs applies to text the game has not drawn yet. const GlyphCacheLayout *glyph_cache = nullptr; + + // --- Launcher ---------------------------------------------------------- + // Shown before the game starts, unless the player chose to skip it or + // started with --play. Null: the game starts at once, as it always has. + const LauncherTheme *launcher = nullptr; }; // Defined by the port, exactly once. Everything under host/ reads the game diff --git a/host/settings/settings.cpp b/host/settings/settings.cpp index fb1e30e..6e52669 100644 --- a/host/settings/settings.cpp +++ b/host/settings/settings.cpp @@ -419,6 +419,14 @@ const std::vector &fields() { }}, BOOL_FIELD("ui.menu_hint_seen", menu_hint_seen), BOOL_FIELD("saves.backup_timestamp", backup_timestamp), + BOOL_FIELD("launcher.skip", launcher_skip), + BOOL_FIELD("launcher.backup", launcher_backup), + {"ui.language", nullptr, + [](Settings &s, const std::string &t) { + s.language = t; + return true; + }, + [](const Settings &s) { return s.language; }, nullptr}, {"ui.last_folder", nullptr, [](Settings &s, const std::string &t) { s.last_folder = t; diff --git a/host/settings/settings.hpp b/host/settings/settings.hpp index 17058ff..aaba100 100644 --- a/host/settings/settings.hpp +++ b/host/settings/settings.hpp @@ -132,6 +132,9 @@ struct Settings { bool menu_pause_multiplayer{}; // ...also during ad hoc play, where a paused game stops answering its peers bool menu_hint_seen{}; // the "Esc / L3+R3 opens the menu" hint was shown std::string last_folder; // where the setup's file browser was last used + bool launcher_skip{}; // start the game at once instead of showing the launcher + bool launcher_backup{true}; // back the saves up each time the launcher starts the game + std::string language; // the interface's language: "en", "pt"; empty: the system's // Saves bool backup_timestamp{true}; // a backup made from the menu goes to a new folder named by its time diff --git a/host/ui/file_browser.cpp b/host/ui/file_browser.cpp index a032d5a..e873538 100644 --- a/host/ui/file_browser.cpp +++ b/host/ui/file_browser.cpp @@ -55,7 +55,7 @@ bool chip(const char *id, const std::string &text, bool active = false) { std::round((focused ? 2.0f : 1.5f) * scale)); const ImVec2 text_size = ImGui::CalcTextSize(text.c_str()); draw->AddText({min.x + (size.x - text_size.x) * 0.5f, min.y + (size.y - text_size.y) * 0.5f}, - active ? colors::kPanel : colors::kText, text.c_str()); + active ? colors::kOnAccent : colors::kText, text.c_str()); ImGui::SameLine(0.0f, std::round(8.0f * scale)); return pressed; } diff --git a/host/ui/i18n.cpp b/host/ui/i18n.cpp new file mode 100644 index 0000000..c79a470 --- /dev/null +++ b/host/ui/i18n.cpp @@ -0,0 +1,539 @@ +// The interface's translations (ui/i18n.hpp). Each entry is the English text +// as the code writes it, then its translation. A pattern for trf() keeps its +// {} where the code puts a name or a number. + +#include "ui/i18n.hpp" + +#include "settings/settings.hpp" + +#include + +#include +#include + +namespace portablekit::ui { +namespace { + +struct Entry { + const char *english; + const char *portuguese; +}; + +// Brazilian Portuguese. +constexpr Entry kPortuguese[] = { + // The menu's frame. + {"Settings", "Configurações"}, + {"Paused", "Pausado"}, + {"Running", "Em execução"}, + {"Video", "Vídeo"}, + {"Audio", "Áudio"}, + {"Controls", "Controles"}, + {"Network", "Rede"}, + {"System", "Sistema"}, + {"Close", "Fechar"}, + {"Resume", "Continuar"}, + {"Select", "Selecionar"}, + {"Back", "Voltar"}, + {"Section", "Seção"}, + {"Change", "Alterar"}, + {"Cancel", "Cancelar"}, + {"Quit", "Sair"}, + {"Done", "Concluído"}, + {"Later", "Depois"}, + {"On", "Ligado"}, + {"Off", "Desligado"}, + {"Yes", "Sim"}, + {"No", "Não"}, + {"None", "Nenhum"}, + {"Default", "Padrão"}, + {"Type", "Digitar"}, + {"Cursor", "Cursor"}, + {"Symbols", "Símbolos"}, + {"Shift", "Shift"}, + {"Space", "Espaço"}, + {"Delete", "Apagar"}, + {"Settings and pause", "Configurações e pausa"}, + + // Video. + {"Picture", "Imagem"}, + {"Resolution", "Resolução"}, + {"Auto", "Auto"}, + {"Auto draws the game at the window's own size and follows it (at most 1632 lines). ×1 to ×6 draw 272 lines " + "per step, 480×272 times the step unless the aspect ratio is Fill. Higher is sharper and needs more from the GPU.", + "Auto desenha o jogo no tamanho da própria janela e acompanha as mudanças (até 1632 linhas). ×1 a ×6 desenham " + "272 linhas por passo, 480×272 vezes o passo, a menos que a proporção seja Preencher. Mais alto fica mais " + "nítido e exige mais da GPU."}, + {"Display", "Tela"}, + {"Fullscreen", "Tela cheia"}, + {"Window", "Janela"}, + {"Fill the screen, or play in a window you can resize.", + "Ocupar a tela inteira, ou jogar numa janela que você pode redimensionar."}, + {"Window size", "Tamanho da janela"}, + {"The window's size, in multiples of the PSP's screen.", "O tamanho da janela, em múltiplos da tela do PSP."}, + {"Aspect ratio", "Proporção"}, + {"Original", "Original"}, + {"Stretch", "Esticar"}, + {"Fill", "Preencher"}, + {"Original keeps the PSP's shape with black bars at the sides or top. Stretch fills the window by stretching " + "the picture.", + "Original mantém o formato do PSP com barras pretas nas laterais ou em cima. Esticar preenche a janela " + "esticando a imagem."}, + {"Original keeps the PSP's shape with black bars at the sides or top. Stretch fills the window by stretching " + "the picture. Fill widens (or narrows) the game's view to the window's shape, keeping its height, and keeps " + "the interface in the PSP's proportions.", + "Original mantém o formato do PSP com barras pretas nas laterais ou em cima. Esticar preenche a janela " + "esticando a imagem. Preencher alarga (ou estreita) a visão do jogo até o formato da janela, mantendo a " + "altura, e deixa a interface nas proporções do PSP."}, + {"Scaling filter", "Filtro de escala"}, + {"Sharp", "Nítido"}, + {"Smooth", "Suave"}, + {"How the finished picture is scaled to the window: smooth (bilinear) or sharp (nearest pixel).", + "Como a imagem final é ajustada à janela: suave (bilinear) ou nítida (pixel mais próximo)."}, + {"Texture filter", "Filtro de texturas"}, + {"How the game's textures are sampled: smooth (bilinear) or sharp (nearest texel).", + "Como as texturas do jogo são amostradas: suave (bilinear) ou nítida (texel mais próximo)."}, + {"Texture pack", "Pacote de texturas"}, + {"Draws an HD texture pack in PPSSPP's format from textures/{} in the data folder instead of the game's textures.", + "Desenha um pacote de texturas HD no formato do PPSSPP, de textures/{} na pasta de dados, no lugar das " + "texturas do jogo."}, + {"Not installed", "Não instalado"}, + {"No pack in {}", "Nenhum pacote em {}"}, + {"Import texture pack…", "Importar pacote de texturas…"}, + {"Open the textures folder", "Abrir a pasta de texturas"}, + {"Show the textures folder in the data folder, where imported packs go.", + "Mostra a pasta textures, dentro da pasta de dados, para onde vão os pacotes importados."}, + {"Timing", "Tempo"}, + {"Vsync", "Vsync"}, + {"Off (mailbox)", "Desligado (mailbox)"}, + {"Off (immediate)", "Desligado (imediato)"}, + {"On waits for the display's refresh and never tears. Off shows each frame at once; the game's speed is the " + "same either way.", + "Ligado espera a atualização da tela e nunca rasga a imagem. Desligado mostra cada quadro na hora; a " + "velocidade do jogo é a mesma nos dois casos."}, + {"Frame rate", "Taxa de quadros"}, + {"Match display", "Igual à tela"}, + {"With Vsync on, no faster than the display refreshes.", "Com o Vsync ligado, não passa da taxa da tela."}, + {"Lowered to keep the game at full speed; it tries the chosen rate again later.", + "Reduzida para manter o jogo na velocidade normal; a taxa escolhida é tentada de novo depois."}, + {"Lower when behind", "Reduzir quando atrasar"}, + {"On lowers the frame rate by itself when presenting that often would slow the game. Off keeps the chosen " + "rate, and the game may then run below full speed.", + "Ligado reduz a taxa de quadros sozinho quando mostrar tantos quadros deixaria o jogo lento. Desligado " + "mantém a taxa escolhida, e o jogo pode rodar abaixo da velocidade normal."}, + {"Game speed", "Velocidade do jogo"}, + {"Game speed is Unlimited", "A velocidade do jogo está Ilimitada"}, + {"Unlimited", "Ilimitada"}, + {"Normal", "Normal"}, + {"Normal holds the game to real time. Unlimited lets it run as fast as frames can be drawn, which also speeds " + "up the game.", + "Normal mantém o jogo em tempo real. Ilimitada deixa rodar tão rápido quanto os quadros puderem ser " + "desenhados, o que também acelera o jogo."}, + {"Fast loading", "Carregamento rápido"}, + {"Lets the game run ahead of real time while it loads and is silent, so loads take as long as the computer " + "needs. Never during play, sound, movies or ad hoc play.", + "Deixa o jogo correr à frente do tempo real enquanto carrega em silêncio, então os carregamentos levam só o " + "tempo que o computador precisa. Nunca durante o jogo, som, vídeos ou partidas ad hoc."}, + {"Performance", "Desempenho"}, + {"Overlay", "Sobreposição"}, + {"Overlay and log", "Sobreposição e log"}, + {"Log only", "Só log"}, + {"Frame times and speed in the top-left corner, and a [perf] line per second on the console. F3 shows or hides " + "the overlay.", + "Tempos de quadro e velocidade no canto superior esquerdo, e uma linha [perf] por segundo no console. F3 " + "mostra ou esconde a sobreposição."}, + {"Restore video defaults", "Restaurar padrões de vídeo"}, + {"Every setting on this page back to how {} ships.", "Todas as opções desta página voltam ao padrão do {}."}, + + // Fonts. + {"Text", "Texto"}, + {"Font", "Fonte"}, + {"Weight", "Peso"}, + {"Regular", "Normal"}, + {"Bold", "Negrito"}, + {"Heavy", "Extra negrito"}, + {"Default (chosen font unreadable)", "Padrão (fonte escolhida ilegível)"}, + {"Thickens the strokes of the game's text. The game squeezes Latin letters to half width, which thins them; " + "Bold makes up for it.", + "Engrossa os traços do texto do jogo. O jogo comprime as letras latinas à metade da largura, o que as " + "afina; Negrito compensa isso."}, + {"Open the fonts folder", "Abrir a pasta de fontes"}, + {"The game's text changes as soon as you choose. Characters a font lacks come from the default; fonts in the " + "fonts folder are listed first.", + "O texto do jogo muda assim que você escolhe. Os caracteres que faltam numa fonte vêm da padrão; as fontes " + "da pasta fonts aparecem primeiro."}, + {"Japanese", "Japonês"}, + {"Your font, Japanese", "Sua fonte, japonês"}, + {"Your font", "Sua fonte"}, + {"Latin", "Latino"}, + {"Looking for installed fonts…", "Procurando fontes instaladas…"}, + + // Audio. + {"Output", "Saída"}, + {"Volume", "Volume"}, + {"Loudness of everything the game plays.", "O volume de tudo que o jogo toca."}, + {"Mute", "Mudo"}, + {"Silence the game without losing the volume setting.", "Silencia o jogo sem perder a configuração de volume."}, + {"Device", "Dispositivo"}, + {"No audio device", "Nenhum dispositivo de áudio"}, + {"44100 Hz stereo, paused while this menu is open", "44100 Hz estéreo, pausado enquanto este menu está aberto"}, + {"44100 Hz stereo", "44100 Hz estéreo"}, + {"Restore audio defaults", "Restaurar padrões de áudio"}, + {"Full volume, not muted.", "Volume máximo, sem mudo."}, + + // Controls. + {"Gamepad", "Controle"}, + {"Connected", "Conectado"}, + {"No gamepad; the keyboard and mouse drive the game", "Nenhum controle; o teclado e o mouse comandam o jogo"}, + {"Confirm button", "Botão de confirmar"}, + {"Bottom (Western)", "Inferior (ocidental)"}, + {"Right (Japanese)", "Direito (japonês)"}, + {"Which face button confirms, in the game and in this menu. The game's prompts show × to confirm and ○ to go " + "back.", + "Qual botão frontal confirma, no jogo e neste menu. Os avisos do jogo mostram × para confirmar e ○ para " + "voltar."}, + {"Which face button confirms, in the game and in this menu. The game's prompts show ○ to confirm and × to go " + "back.", + "Qual botão frontal confirma, no jogo e neste menu. Os avisos do jogo mostram ○ para confirmar e × para " + "voltar."}, + {"Stick dead zone", "Zona morta do analógico"}, + {"How far the left stick moves before the game sees it. Raise it if the character drifts.", + "Quanto o analógico esquerdo se move antes de o jogo perceber. Aumente se o personagem andar sozinho."}, + {"Trigger point", "Ponto dos gatilhos"}, + {"How far LT/RT (L2/R2) travel before they press anything.", + "Quanto LT/RT (L2/R2) precisam descer antes de apertar algo."}, + {"Trigger profile", "Perfil dos gatilhos"}, + {"Standard (L / R)", "Padrão (L / R)"}, + {"What LT/RT (L2/R2) press. Standard: L and R, like the shoulders.", + "O que LT/RT (L2/R2) apertam. Padrão: L e R, como os botões de ombro."}, + {"Right stick", "Analógico direito"}, + {"Camera", "Câmera"}, + {"D-pad", "Direcional"}, + {"Analog camera", "Câmera analógica"}, + {"Camera speed", "Velocidade da câmera"}, + {"How fast the camera turns at full deflection, in degrees a second.", + "A velocidade com que a câmera gira com o analógico no máximo, em graus por segundo."}, + {"Invert camera horizontally", "Inverter câmera na horizontal"}, + {"Turn the camera the other way left and right.", "Gira a câmera no sentido contrário para os lados."}, + {"Invert camera vertically", "Inverter câmera na vertical"}, + {"Turn the camera the other way up and down.", "Gira a câmera no sentido contrário para cima e para baixo."}, + {"Right stick is not the camera", "O analógico direito não é a câmera"}, + {"Analog camera is off", "A câmera analógica está desligada"}, + {"Right stick is not the D-pad", "O analógico direito não é o direcional"}, + {"Right stick D-pad point", "Ponto do direcional no analógico direito"}, + {"How far the right stick moves before it presses the D-pad.", + "Quanto o analógico direito se move antes de apertar o direcional."}, + {"When the game asks for a name", "Quando o jogo pedir um nome"}, + {"On-screen keyboard", "Teclado na tela"}, + {"Use the name below", "Usar o nome abaixo"}, + {"On-screen keyboard: type the name with the gamepad or the keyboard while the game waits. Otherwise the name " + "below is given at once.", + "Teclado na tela: digite o nome com o controle ou o teclado enquanto o jogo espera. Senão, o nome abaixo é " + "usado na hora."}, + {"Keyboard and mouse", "Teclado e mouse"}, + {"While the game runs, the window takes the pointer: moving the mouse turns the camera and its buttons press " + "what they are bound to. Esc opens this menu and gives the pointer back.", + "Enquanto o jogo roda, a janela captura o ponteiro: mover o mouse gira a câmera e os botões apertam o que " + "estiver associado a eles. Esc abre este menu e devolve o ponteiro."}, + {"Mouse", "Mouse"}, + {"Mouse is off", "O mouse está desligado"}, + {"Mouse sensitivity", "Sensibilidade do mouse"}, + {"Invert mouse horizontally", "Inverter mouse na horizontal"}, + {"Turn the camera the other way when the mouse moves sideways.", + "Gira a câmera no sentido contrário quando o mouse vai para os lados."}, + {"Invert mouse vertically", "Inverter mouse na vertical"}, + {"Push the mouse forward to look down instead of up.", "Empurrar o mouse para a frente olha para baixo, não para cima."}, + {"Press a key or a mouse button", "Aperte uma tecla ou um botão do mouse"}, + {"Press a key or a mouse button to add it, or one it has already to remove it; Esc cancels. A key taken from " + "another control leaves that one.", + "Aperte uma tecla ou um botão do mouse para adicionar, ou um que já esteja associado para remover; Esc " + "cancela. Uma tecla tirada de outro comando deixa aquele comando."}, + {"This menu", "Este menu"}, + {"Performance overlay", "Sobreposição de desempenho"}, + {"Restore control defaults", "Restaurar padrões dos controles"}, + {"Every gamepad, keyboard, mouse, touch and name setting back to how {} ships.", + "Todas as opções de controle, teclado, mouse, toque e nome voltam ao padrão do {}."}, + {"Touch screen", "Tela de toque"}, + {"On-screen controls", "Controles na tela"}, + {"On-screen controls are off", "Os controles na tela estão desligados"}, + {"Controls opacity", "Opacidade dos controles"}, + {"How strongly the on-screen controls are drawn.", "Quão visíveis os controles na tela ficam."}, + {"Controls size", "Tamanho dos controles"}, + {"The size of the on-screen controls.", "O tamanho dos controles na tela."}, + {"Touch camera speed", "Velocidade da câmera ao tocar"}, + {"Degrees the camera turns for a drag across the height of the screen.", + "Quantos graus a câmera gira ao arrastar o dedo pela altura da tela."}, + {"Use the classic keyboard layout", "Usar o layout clássico de teclado"}, + {"Tilt controls", "Controles de inclinação"}, + {"Up", "Cima"}, + {"Down", "Baixo"}, + {"Left", "Esquerda"}, + {"Right", "Direita"}, + {"Cross", "Xis"}, + {"Circle", "Círculo"}, + {"Square", "Quadrado"}, + {"Triangle", "Triângulo"}, + {"Start", "Start"}, + {"Select (PSP)", "Select (PSP)"}, + + // System. + {"Game", "Jogo"}, + {"Back to the game.", "Volta para o jogo."}, + {"Pause the game when the menu opens", "Pausar o jogo ao abrir o menu"}, + {"On: the game stops while this menu is open. Off: it keeps running and playing sound behind the menu, which " + "takes all input. Applies the next time the menu opens.", + "Ligado: o jogo para enquanto este menu está aberto. Desligado: ele continua rodando e tocando som atrás do " + "menu, que recebe todos os comandos. Vale na próxima vez que o menu abrir."}, + {"Show the launcher at start", "Mostrar o launcher ao iniciar"}, + {"On: the launcher, with its Play button and settings, comes up before the game. Off: the game starts at once. " + "Applies the next time the game starts.", + "Ligado: o launcher, com o botão Jogar e as configurações, aparece antes do jogo. Desligado: o jogo inicia " + "direto. Vale na próxima vez que o jogo iniciar."}, + {"Language", "Idioma"}, + {"The language of this menu and the launcher. The game's own text is not changed.", + "O idioma deste menu e do launcher. O texto do próprio jogo não muda."}, + {"Open the data folder", "Abrir a pasta de dados"}, + {"Show {}'s data folder in the file manager.", "Mostra a pasta de dados do {} no gerenciador de arquivos."}, + {"Set up game data again…", "Configurar os dados do jogo de novo…"}, + {"Choose the disc image again, for example after moving it. The game closes first.", + "Escolha a imagem do disco de novo, por exemplo depois de movê-la. O jogo fecha antes."}, + {"Quit game", "Sair do jogo"}, + {"Close {}. Progress since your last save is lost.", "Fecha o {}. O progresso desde o último save é perdido."}, + {"Saves", "Saves"}, + {"About", "Sobre"}, + {"Build", "Build"}, + {"Data folder", "Pasta de dados"}, + {"Saves folder", "Pasta de saves"}, + {"Graphics", "Gráficos"}, + {"Interface", "Interface"}, + {"Quit the game?", "Sair do jogo?"}, + {"Set up game data again?", "Configurar os dados do jogo de novo?"}, + {"Progress since your last save is lost.", "O progresso desde o último save é perdido."}, + {"{} closes the game and opens the setup, where you choose the disc image again. Progress since your last save " + "is lost.", + "O {} fecha o jogo e abre a configuração, onde você escolhe a imagem do disco de novo. O progresso desde o " + "último save é perdido."}, + {"Close and set up", "Fechar e configurar"}, + {"Set by {}", "Definido por {}"}, + {"Save the log…", "Salvar o log…"}, + {"Log", "Log"}, + + // Saves. + {"Import save…", "Importar save…"}, + {"Export save…", "Exportar save…"}, + {"Back up saves…", "Fazer backup dos saves…"}, + {"Open the saves folder", "Abrir a pasta de saves"}, + {"Show the folder the game saves to (PSP/SAVEDATA) in the file manager.", + "Mostra no gerenciador de arquivos a pasta onde o jogo salva (PSP/SAVEDATA)."}, + {"Copy a save from a PSP memory stick, PPSSPP or another installation: choose its folder or the PSP/SAVEDATA " + "folder that holds it. A save it replaces is kept, not deleted.", + "Copia um save de um memory stick de PSP, do PPSSPP ou de outra instalação: escolha a pasta dele ou a pasta " + "PSP/SAVEDATA que o contém. Um save substituído é guardado, não apagado."}, + {"Copy your game data and downloaded quests to a folder you choose, to take them to a PSP or another machine.", + "Copia seus dados de jogo para uma pasta que você escolher, para levar a um PSP ou a outro computador."}, + {"Copy all of this game's saves, the install data included, to the backups folder or a folder you choose.", + "Copia todos os saves deste jogo, incluindo os dados de instalação, para a pasta de backups ou uma pasta " + "que você escolher."}, + {"No folders here.", "Nenhuma pasta aqui."}, + {"Import from this folder", "Importar desta pasta"}, + {"Export to this folder", "Exportar para esta pasta"}, + {"Back up to this folder", "Fazer backup nesta pasta"}, + {"Import: open a save folder, or choose a folder that holds them, such as a memory stick's PSP/SAVEDATA.", + "Importar: abra uma pasta de save, ou escolha uma pasta que contenha saves, como a PSP/SAVEDATA de um " + "memory stick."}, + {"Export: choose the folder to copy your saves to.", "Exportar: escolha a pasta para onde copiar seus saves."}, + {"Back up: choose the folder the backup goes to.", "Backup: escolha a pasta onde o backup vai ficar."}, + {"Import saves", "Importar saves"}, + {"Save to import", "Save a importar"}, + {"Current save", "Save atual"}, + {"Replace and import", "Substituir e importar"}, + {"Import this save", "Importar este save"}, + {"Import these saves", "Importar estes saves"}, + {"Choose another folder", "Escolher outra pasta"}, + {"Back to the folders.", "Volta para as pastas."}, + {"Import nothing.", "Não importa nada."}, + {"Imported", "Importado"}, + {"Imported; the old save was kept", "Importado; o save antigo foi guardado"}, + {"The game reads its saves at the title screen. Restart now to load the imported save; progress since your " + "last save is lost. If you keep playing instead, do not save before you restart: saving would replace the " + "imported save with the game you are playing.", + "O jogo lê os saves na tela de título. Reinicie agora para carregar o save importado; o progresso desde o " + "último save é perdido. Se continuar jogando, não salve antes de reiniciar: salvar substituiria o save " + "importado pelo jogo atual."}, + {"Restart now", "Reiniciar agora"}, + {"Closes the game and starts it again at the title screen.", "Fecha o jogo e o inicia de novo na tela de título."}, + {"Back to the menu.", "Volta para o menu."}, + {"Export saves", "Exportar saves"}, + {"Exported", "Exportado"}, + {"Encrypted for a PSP", "Criptografado para PSP"}, + {"To", "Para"}, + {"The folder is laid out like a memory stick: copy its PSP folder to the root of a PSP's memory stick, or " + "import it from this menu on another machine.", + "A pasta segue a estrutura de um memory stick: copie a pasta PSP dela para a raiz do memory stick de um " + "PSP, ou importe por este menu em outro computador."}, + {"Not exported", "Não exportado"}, + {"Back up saves", "Backup dos saves"}, + {"There is no save to back up yet.", "Ainda não há save para fazer backup."}, + {"Add a timestamp to the backup name", "Pôr data e hora no nome do backup"}, + {"On: every backup is a new folder named by its date and time, such as 2026-09-19_19-05-12, holding the save " + "folders. Off: the save folders go straight into the folder you choose, and an earlier backup there is " + "replaced after asking.", + "Ligado: cada backup é uma pasta nova com a data e a hora, como 2026-09-19_19-05-12, com as pastas de save " + "dentro. Desligado: as pastas de save vão direto para a pasta escolhida, e um backup anterior ali é " + "substituído depois de perguntar."}, + {"Back up to the backups folder", "Backup na pasta de backups"}, + {"Back up to another folder…", "Backup em outra pasta…"}, + {"Choose where the backup goes.", "Escolha onde o backup vai ficar."}, + {"Open the backups folder", "Abrir a pasta de backups"}, + {"Replace the earlier backup?", "Substituir o backup anterior?"}, + {"Keep the earlier backup.", "Mantém o backup anterior."}, + {"Replace the backup", "Substituir o backup"}, + {"The earlier backup there is replaced.", "O backup anterior ali é substituído."}, + {"Backed up", "Backup feito"}, + {"Not backed up", "Backup não feito"}, + {"Open the folder", "Abrir a pasta"}, + {"Show the backup in the file manager.", "Mostra o backup no gerenciador de arquivos."}, + + // Texture packs. + {"Import texture pack", "Importar pacote de texturas"}, + {"Pack to import", "Pacote a importar"}, + {"Folder", "Pasta"}, + {"Textures", "Texturas"}, + {"Images", "Imagens"}, + {"Missing images", "Imagens faltando"}, + {"textures.ini names images that are not in the folder; those textures keep the game's own look.", + "O textures.ini cita imagens que não estão na pasta; essas texturas ficam com a aparência original do jogo."}, + {"In use now", "Em uso agora"}, + {"Installed pack", "Pacote instalado"}, + {"Pack folder", "Pasta do pacote"}, + {"Used from", "Usado de"}, + {"This is the installed pack already.", "Este já é o pacote instalado."}, + {"Copy and replace", "Copiar e substituir"}, + {"This pack is the one in use already.", "Este pacote já é o que está em uso."}, + {"Use it where it is", "Usar onde está"}, + {"Putting the pack in place", "Colocando o pacote no lugar"}, + {"Copying texture pack", "Copiando o pacote de texturas"}, + {"The pack in use now stays as it is until the copy is complete. The menu stays open until then.", + "O pacote em uso continua como está até a cópia terminar. O menu fica aberto até lá."}, + {"Cancelling…", "Cancelando…"}, + {"Stops the copy and removes what it has copied so far. Nothing else changes.", + "Para a cópia e remove o que já foi copiado. Nada mais muda."}, + {"Installed", "Instalado"}, + {"Installed in", "Instalado em"}, + {"Replaced pack", "Pacote substituído"}, + {"The pack is on and applies at once: textures already on screen change within a frame or two.", + "O pacote está ligado e vale na hora: as texturas na tela mudam em um ou dois quadros."}, + {"Not imported", "Não importado"}, + {"Cancelled", "Cancelado"}, + {"Nothing changed: the pack in use before is still in place.", + "Nada mudou: o pacote que estava em uso continua no lugar."}, + {"Show the folder the pack is in.", "Mostra a pasta onde o pacote está."}, + {"Pack used from", "Pacote usado de"}, + {"Stop using the pack folder", "Parar de usar a pasta do pacote"}, + {"Go back to the pack installed in the data folder, if any. The pack's own folder is left alone.", + "Volta para o pacote instalado na pasta de dados, se houver. A pasta do pacote não é mexida."}, + {"Does not load", "Não carrega"}, + {"Import: open the pack's folder, the one that holds textures.ini, or choose a folder that holds it, such as " + "PPSSPP's PSP/TEXTURES. You can also drop the folder on the window.", + "Importar: abra a pasta do pacote, a que contém o textures.ini, ou escolha uma pasta que a contenha, como a " + "PSP/TEXTURES do PPSSPP. Você também pode arrastar a pasta para a janela."}, + + // The file browser. + {"Home", "Início"}, + {"Desktop", "Área de trabalho"}, + {"Documents", "Documentos"}, + {"Downloads", "Downloads"}, + {"Parent folder", "Pasta acima"}, + {"Showing all files", "Mostrando todos os arquivos"}, + {"System dialog…", "Janela do sistema…"}, + {"Disc images (*.iso)", "Imagens de disco (*.iso)"}, + {"All files", "Todos os arquivos"}, + + // The launcher. + {"PLAY", "JOGAR"}, + {"QUICK SETTINGS", "CONFIGURAÇÕES RÁPIDAS"}, + {"Back up saves before playing", "Backup dos saves ao jogar"}, + {"Copies your saves to the backups folder in the data folder each time you press Play; the newest ten are kept.", + "Copia seus saves para a pasta backups, na pasta de dados, sempre que você clica em Jogar; os dez mais " + "recentes são mantidos."}, + {"Show this launcher at start", "Mostrar este launcher ao iniciar"}, + {"No: the game starts at once. Start with --launcher to see this screen again.", + "Não: o jogo inicia direto. Inicie com --launcher para ver esta tela de novo."}, + {"In the game: Esc, or L3+R3 on a gamepad, opens the port's menu.", + "No jogo: Esc, ou L3+R3 no controle, abre o menu do port."}, + {"Project page", "Página do projeto"}, + {"Report a problem", "Reportar um problema"}, + {"Your disc image could not be read.", "Não foi possível ler a imagem do disco."}, + {"System & saves", "Sistema e saves"}, + {"An unofficial port, played from your own copy of the game.", + "Um port não oficial, jogado a partir da sua própria cópia do jogo."}, +}; + +using Table = std::unordered_map; + +const Table &portuguese() { + static const Table table = [] { + Table t; + for (const Entry &e : kPortuguese) t.emplace(e.english, e.portuguese); + return t; + }(); + return table; +} + +Language system_language() { + static const Language system = [] { + int count = 0; + SDL_Locale **locales = SDL_GetPreferredLocales(&count); + const bool pt = locales != nullptr && count > 0 && locales[0]->language != nullptr && + std::string_view(locales[0]->language) == "pt"; + SDL_free(locales); + return pt ? Language::Portuguese : Language::English; + }(); + return system; +} + +} // namespace + +Language language() { + const std::string &chosen = settings::current().language; + if (chosen == "pt") return Language::Portuguese; + if (chosen == "en") return Language::English; + return system_language(); +} + +void next_language() { + settings::current().language = language() == Language::Portuguese ? "en" : "pt"; + settings::save(); +} + +const char *language_name() { return language() == Language::Portuguese ? "Português" : "English"; } + +const char *tr(const char *english) { + if (english == nullptr || language() == Language::English) return english; + const Table &table = portuguese(); + const auto found = table.find(english); + return found == table.end() ? english : found->second; +} + +std::string tr(const std::string &english) { + if (english.empty() || language() == Language::English) return english; + const Table &table = portuguese(); + const auto found = table.find(english); + return found == table.end() ? english : std::string(found->second); +} + +std::string trf(const char *pattern, std::initializer_list args) { + const std::string translated = tr(pattern); + std::string out; + auto arg = args.begin(); + for (std::size_t i = 0; i < translated.size(); ++i) { + if (translated.compare(i, 2, "{}") == 0 && arg != args.end()) { + out += *arg++; + ++i; + } else { + out += translated[i]; + } + } + return out; +} + +} // namespace portablekit::ui diff --git a/host/ui/i18n.hpp b/host/ui/i18n.hpp new file mode 100644 index 0000000..f4ee14b --- /dev/null +++ b/host/ui/i18n.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +// The interface's languages. Text is written in English everywhere and looked +// up here when it is drawn: the widgets translate what they are given, so a +// screen only has to build its own sentences from patterns (trf) where it +// puts names or numbers into them. Text with no translation stays English. +namespace portablekit::ui { + +enum class Language { English, Portuguese }; + +// The player's choice (Settings::language), else the system's language. +[[nodiscard]] Language language(); +// Cycles to the next language and saves the choice. +void next_language(); +// The current language's own name: "English", "Português". +[[nodiscard]] const char *language_name(); + +// `english` in the current language. +[[nodiscard]] const char *tr(const char *english); +[[nodiscard]] std::string tr(const std::string &english); +// `pattern` in the current language, each {} replaced by the next argument. +[[nodiscard]] std::string trf(const char *pattern, std::initializer_list args); + +} // namespace portablekit::ui diff --git a/host/ui/launcher.cpp b/host/ui/launcher.cpp new file mode 100644 index 0000000..cf8c963 --- /dev/null +++ b/host/ui/launcher.cpp @@ -0,0 +1,634 @@ +// The launcher (ui/launcher.hpp). It draws its own frame (the side bar, the +// home page, the bottom bar with Play) and has the in-game menu draw its pages +// inside it, so every setting lives in one place whether the game runs or not. +// +// The art is the player's: images they put in the launcher folder, else +// PSP_GAME/PIC1.PNG and PSP_GAME/ICON0.PNG read from their disc image. +// Nothing of the game is carried by the port or the framework. + +#include "../profile.hpp" +#include "ui/launcher.hpp" + +#include "ui/i18n.hpp" +#include "ui/layer.hpp" +#include "ui/text_input.hpp" +#include "ui/widgets.hpp" + +#include "app_paths.hpp" +#include "gpu/vulkan_renderer.hpp" +#include "install/installer.hpp" +#include "install/user_data.hpp" +#include "kernel/iso_image.hpp" +#include "save_data/save_transfer.hpp" +#include "settings/settings.hpp" +#include "portablekit_version.hpp" + +#include "imgui.h" +#include "imgui_internal.h" + +// texture_pack.cpp keeps its decoder to itself; the launcher has its own, for +// the images it shows. +#define STBI_ONLY_PNG +#define STBI_ONLY_JPEG +#define STBI_NO_STDIO +#define STBI_NO_LINEAR +#define STBI_NO_HDR +#define STB_IMAGE_STATIC +#define STB_IMAGE_IMPLEMENTATION +#include "stb_image.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace portablekit::ui { +namespace { + +namespace fs = std::filesystem; + +// Backups the launcher keeps in /backups/ before it deletes the oldest. +constexpr std::size_t kKeptBackups = 10u; +// Images larger than this on a side are not shown. +constexpr int kMaxImageSide = 8192; + +// --------------------------------------------------------------------------- +// Pages. The side bar has the home page, then the menu's pages this game has. + +struct Page { + const char *name; + int tab; // the menu's page (launcher_page), or -1 for the home page +}; + +const std::vector &pages() { + static const std::vector list = [] { + std::vector p{{"Home", -1}, {"Video", 0}, {"Audio", 1}, {"Controls", 2}}; + if (portablekit::game().adhoc_play) p.push_back({"Network", 3}); + p.push_back({"System & saves", 4}); + return p; + }(); + return list; +} + +// --------------------------------------------------------------------------- +// Images. + +struct Image { + std::uint64_t id{}; // for ImGui; zero: none + float width{}; + float height{}; +}; + +struct Pixels { + int width{}; + int height{}; + std::vector rgba; +}; + +std::optional decode(const std::vector &bytes, const std::string &name) { + int width = 0, height = 0, channels = 0; + stbi_uc *data = stbi_load_from_memory(bytes.data(), static_cast(bytes.size()), &width, &height, &channels, 4); + if (data == nullptr || width <= 0 || height <= 0 || width > kMaxImageSide || height > kMaxImageSide) { + std::cout << "[launcher] cannot use " << name << ": " + << (data == nullptr ? stbi_failure_reason() : "too large") << "\n"; + if (data != nullptr) stbi_image_free(data); + return std::nullopt; + } + Pixels pixels{width, height, + std::vector(data, data + static_cast(width) * height * 4u)}; + stbi_image_free(data); + return pixels; +} + +std::optional> read_file(const fs::path &path) { + std::error_code ec; + if (!fs::is_regular_file(path, ec)) return std::nullopt; + std::ifstream in(path, std::ios::binary); + std::vector bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + if (bytes.empty()) return std::nullopt; + return bytes; +} + +std::optional> read_from_disc(IsoImage &iso, const char *path) { + const std::optional entry = iso.find(path); + if (!entry || entry->directory || entry->size == 0u || entry->size > 32u * 1024u * 1024u) return std::nullopt; + std::vector bytes(entry->size); + if (iso.read(static_cast(entry->lba) * IsoImage::kSectorSize, bytes) != bytes.size()) + return std::nullopt; + return bytes; +} + +// The folders a launcher image may be in, first match wins: the data +// folder's launcher/, then the one an app ships. +std::vector launcher_folders() { + std::vector folders; + try { + folders.push_back(install::user_data_directory() / "launcher"); + } catch (const std::exception &) { + } + if (fs::path shipped = bundled_launcher_directory(); !shipped.empty()) folders.push_back(std::move(shipped)); + return folders; +} + +// A logo drawn on black: black becomes transparent (each pixel taken as the +// logo's colour laid over black at some opacity) and the black around it is +// cropped away. +void lift_from_black(Pixels &p) { + int left = p.width, top = p.height, right = -1, bottom = -1; + for (int y = 0; y < p.height; ++y) + for (int x = 0; x < p.width; ++x) { + std::uint8_t *px = &p.rgba[(static_cast(y) * p.width + x) * 4u]; + const int alpha = std::max({px[0], px[1], px[2]}); + if (alpha > 0) + for (int c = 0; c < 3; ++c) px[c] = static_cast(std::min(255, px[c] * 255 / alpha)); + px[3] = static_cast(alpha); + if (alpha > 24) { + left = std::min(left, x); + right = std::max(right, x); + top = std::min(top, y); + bottom = std::max(bottom, y); + } + } + if (right < left || bottom < top) return; + const int pad = std::max(2, (right - left) / 100); + left = std::max(0, left - pad); + top = std::max(0, top - pad); + right = std::min(p.width - 1, right + pad); + bottom = std::min(p.height - 1, bottom + pad); + const int width = right - left + 1, height = bottom - top + 1; + std::vector cropped(static_cast(width) * height * 4u); + for (int y = 0; y < height; ++y) + std::copy_n(&p.rgba[(static_cast(top + y) * p.width + left) * 4u], + static_cast(width) * 4u, &cropped[static_cast(y) * width * 4u]); + p = Pixels{width, height, std::move(cropped)}; +} + +Image upload(const Pixels &p) { + Image image; + image.id = Layer::get().renderer().create_ui_image(static_cast(p.width), + static_cast(p.height), + reinterpret_cast(p.rgba.data())); + if (image.id == 0u) return {}; + image.width = static_cast(p.width); + image.height = static_cast(p.height); + return image; +} + +// --------------------------------------------------------------------------- +// The look. + +ImU32 rgb(std::uint32_t color, int alpha = 255) { + return IM_COL32((color >> 16) & 0xFFu, (color >> 8) & 0xFFu, color & 0xFFu, alpha); +} + +// A bold serif face of the system's for headings and the Play button. +const char *const kSerifFonts[] = { + "/System/Library/Fonts/Supplemental/Georgia Bold.ttf", + "/System/Library/Fonts/Supplemental/Times New Roman Bold.ttf", + "C:/Windows/Fonts/georgiab.ttf", + "C:/Windows/Fonts/timesbd.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSerif-Bold.ttf", + "/usr/share/fonts/dejavu/DejaVuSerif-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf", + "/system/fonts/NotoSerif-Bold.ttf", +}; + +ImFont *load_font(std::span data) { + ImFontAtlas *atlas = ImGui::GetIO().Fonts; + if (!data.empty()) { + ImFontConfig config; + config.FontDataOwnedByAtlas = false; + return atlas->AddFontFromMemoryTTF(const_cast(data.data()), static_cast(data.size()), + 0.0f, &config); + } + for (const char *candidate : kSerifFonts) { + std::error_code ec; + if (fs::is_regular_file(install::path_from_utf8(candidate), ec)) return atlas->AddFontFromFileTTF(candidate); + } + return nullptr; +} + +// Draws `image` over `min`-`max`, cropped to that shape; `focus_y` is the +// part of its height kept in view. +void draw_cover(ImDrawList *draw, const Image &image, ImVec2 min, ImVec2 max, float focus_y) { + const float image_aspect = image.width / image.height; + const float area_aspect = (max.x - min.x) / std::max(max.y - min.y, 1.0f); + ImVec2 uv0{0.0f, 0.0f}, uv1{1.0f, 1.0f}; + if (area_aspect > image_aspect) { + const float shown = image_aspect / area_aspect; + uv0.y = (1.0f - shown) * std::clamp(focus_y, 0.0f, 1.0f); + uv1.y = uv0.y + shown; + } else { + const float shown = area_aspect / image_aspect; + uv0.x = (1.0f - shown) * 0.5f; + uv1.x = uv0.x + shown; + } + draw->AddImage(static_cast(image.id), min, max, uv0, uv1); +} + +class Launcher { +public: + Launcher(const LauncherTheme &theme, const fs::path &disc_image); + // One frame; false once the player chose. + bool frame(); + [[nodiscard]] LauncherChoice choice() const noexcept { return choice_; } + +private: + void sidebar(float width, float height); + void home(ImVec2 min, ImVec2 max); + void settings_page(ImVec2 min, ImVec2 max, bool back); + void bottom_bar(ImVec2 min, ImVec2 max); + void heading(const char *text, ImVec2 at, float width, float size); + void link(const char *label, const char *url); + + const LauncherTheme &theme_; + Image backdrop_; + Image logo_; + ImFont *serif_{}; + bool disc_unreadable_{}; + bool first_frame_{true}; + int page_{0}; + bool focus_nav_{}; // focus the selected page's entry in the side bar + LauncherChoice choice_{LauncherChoice::Play}; + bool done_{}; +}; + +Launcher::Launcher(const LauncherTheme &theme, const fs::path &disc_image) : theme_(theme) { + const std::vector folders = launcher_folders(); + std::optional iso; + if (!disc_image.empty()) { + try { + iso.emplace(disc_image); + } catch (const std::exception &e) { + std::cout << "[launcher] " << e.what() << "\n"; + disc_unreadable_ = true; + } + } + // A file in a launcher folder, else the image on the disc. + const auto load = [&](const char *file, const char *on_disc, bool on_black) -> Image { + std::optional pixels; + for (const fs::path &folder : folders) { + if (file == nullptr || pixels) break; + const fs::path path = folder / file; + if (const auto bytes = read_file(path)) pixels = decode(*bytes, install::path_to_utf8(path)); + } + if (!pixels && on_disc != nullptr && iso) { + if (const auto bytes = read_from_disc(*iso, on_disc)) pixels = decode(*bytes, on_disc); + on_black = false; + } + if (!pixels) return {}; + if (on_black) lift_from_black(*pixels); + return upload(*pixels); + }; + backdrop_ = load(theme.backdrop_file, theme.backdrop_on_disc, false); + logo_ = load(theme.logo_file, theme.logo_on_disc, theme.logo_on_black); + serif_ = load_font(!theme.heading_font.empty() ? theme.heading_font : theme.title_font); +} + +void Launcher::heading(const char *text, ImVec2 at, float width, float size) { + ImDrawList *draw = ImGui::GetWindowDrawList(); + ImGui::PushFont(serif_, size); + draw->AddText(at, rgb(theme_.accent_bright), text); + const float height = ImGui::CalcTextSize(text).y; + ImGui::PopFont(); + const float y = std::round(at.y + height + size * 0.25f); + draw->AddLine({at.x, y}, {at.x + width, y}, rgb(theme_.text, 40), std::max(1.0f, std::round(size * 0.06f))); +} + +void Launcher::link(const char *label, const char *url) { + if (url == nullptr) return; + const ImVec2 size = ImGui::CalcTextSize(label, nullptr, true); + const ImVec2 min = ImGui::GetCursorScreenPos(); + if (ImGui::InvisibleButton(label, size, ImGuiButtonFlags_EnableNav) && !SDL_OpenURL(url)) + std::cout << "[launcher] cannot open " << url << ": " << SDL_GetError() << "\n"; + const bool lit = ImGui::IsItemFocused() || ImGui::IsItemHovered(); + ImDrawList *draw = ImGui::GetWindowDrawList(); + draw->AddText(min, rgb(lit ? theme_.accent_bright : theme_.text_dim), label, ImGui::FindRenderedTextEnd(label)); + if (lit) draw->AddLine({min.x, min.y + size.y}, {min.x + size.x, min.y + size.y}, rgb(theme_.accent_bright)); +} + +void Launcher::sidebar(float width, float height) { + const float font = Layer::get().font_size(); + ImDrawList *draw = ImGui::GetWindowDrawList(); + draw->AddRectFilled({0.0f, 0.0f}, {width, height}, rgb(theme_.sidebar)); + draw->AddLine({width - 1.0f, 0.0f}, {width - 1.0f, height}, rgb(theme_.text, 14)); + const float pad = std::round(font * 0.8f); + float y = pad; + + // The logo, or the title. + if (logo_.id != 0u) { + float w = width - pad * 2.0f; + float h = w * logo_.height / logo_.width; + if (h > font * 6.0f) { + h = font * 6.0f; + w = h * logo_.width / logo_.height; + } + const float x = std::round((width - w) * 0.5f); + draw->AddImage(static_cast(logo_.id), {x, y}, {x + w, y + h}); + y += std::round(h + font * 0.8f); + } else if (theme_.title != nullptr) { + ImGui::PushFont(serif_, font * 1.25f); + ImGui::PushTextWrapPos(width - pad); + ImGui::SetCursorScreenPos({pad, y}); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(rgb(theme_.text)), "%s", theme_.title); + if (theme_.subtitle != nullptr) + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(rgb(theme_.text)), "%s", theme_.subtitle); + ImGui::PopTextWrapPos(); + ImGui::PopFont(); + y = std::round(ImGui::GetCursorScreenPos().y + font * 0.8f); + } + + // The pages. + const float item = std::round(font * 2.1f); + for (int i = 0; i < static_cast(pages().size()); ++i) { + const ImVec2 min{0.0f, y}; + const ImVec2 max{width - 1.0f, y + item}; + ImGui::SetCursorScreenPos(min); + ImGui::PushID(i); + if (ImGui::InvisibleButton("page", {max.x - min.x, item}, ImGuiButtonFlags_EnableNav)) page_ = i; + if (focus_nav_ && i == page_) { + ImGui::FocusItem(); + focus_nav_ = false; + } + const bool focused = ImGui::IsItemFocused(); + const bool hovered = ImGui::IsItemHovered(); + ImGui::PopID(); + const bool selected = i == page_; + if (selected) draw->AddRectFilled(min, max, rgb(theme_.text, 14)); + else if (focused || hovered) draw->AddRectFilled(min, max, rgb(theme_.text, 8)); + if (focused) + draw->AddRectFilled(min, {min.x + std::max(2.0f, std::round(font * 0.15f)), max.y}, + rgb(theme_.accent_bright)); + draw->AddText({pad, std::round(y + (item - font) * 0.5f)}, + rgb(selected ? theme_.accent_bright : focused || hovered ? theme_.text : theme_.text_dim), + tr(pages()[static_cast(i)].name)); + y += item; + } + + // How to open the menu in the game, at the foot. + ImGui::PushFont(nullptr, font * 0.78f); + const float wrap = width - pad * 2.0f; + const char *menu_hint = tr("In the game: Esc, or L3+R3 on a gamepad, opens the port's menu."); + const ImVec2 hint = ImGui::CalcTextSize(menu_hint, nullptr, false, wrap); + draw->AddText(nullptr, 0.0f, {pad, std::round(height - pad - hint.y)}, rgb(theme_.text_dim), menu_hint, nullptr, + wrap); + ImGui::PopFont(); +} + +void Launcher::home(ImVec2 min, ImVec2 max) { + const float font = Layer::get().font_size(); + ImDrawList *draw = ImGui::GetWindowDrawList(); + const float width = max.x - min.x; + const float pad = std::round(font * 1.4f); + + // The banner, fading into the page. + const float banner = std::round(std::min((max.y - min.y) * 0.44f, width * 0.38f)); + if (backdrop_.id != 0u) { + draw_cover(draw, backdrop_, min, {max.x, min.y + banner}, theme_.backdrop_focus_y); + const float fade = std::round(banner * 0.4f); + draw->AddRectFilledMultiColor({min.x, min.y + banner - fade}, {max.x, min.y + banner + 1.0f}, + rgb(theme_.background, 0), rgb(theme_.background, 0), + rgb(theme_.background, 255), rgb(theme_.background, 255)); + } + float y = std::round(min.y + banner - font * 0.6f); + + // The disc and the version. + ImGui::PushFont(serif_, font * 0.95f); + const std::string id = std::string(portablekit::game().disc_id_display) + " · " + + portablekit::game().project_name + " " + kBuildVersion; + draw->AddText({min.x + pad, y}, rgb(theme_.accent_bright), id.c_str()); + ImGui::PopFont(); + if (disc_unreadable_) { + ImGui::PushFont(nullptr, font * 0.8f); + draw->AddText({min.x + pad, std::round(y + font * 1.2f)}, rgb(theme_.text_dim), + tr("Your disc image could not be read.")); + ImGui::PopFont(); + } + y += std::round(font * 2.4f); + + // The quick settings, in two columns where there is room: the game's + // picture, then the launcher's own. + const float area = std::round(std::min(width - pad * 2.0f, font * 46.0f)); + heading(tr("QUICK SETTINGS"), {min.x + pad, y}, area, font * 1.05f); + y += std::round(font * 1.9f); + const float foot = std::round(font * 1.8f); + const bool two = area >= font * 34.0f; + const float gap = std::round(font * 1.2f); + const float column = two ? std::round((area - gap) * 0.5f) : area; + const float rows_height = std::max(font * 3.0f, max.y - y - foot); + ImGui::SetCursorScreenPos({min.x + pad, y}); + ImGui::BeginChild("##quick", {column, rows_height}, ImGuiChildFlags_NavFlattened); + launcher_quick_settings(); + if (two) { + touch_scroll(); + ImGui::EndChild(); + ImGui::SetCursorScreenPos({min.x + pad + column + gap, y}); + ImGui::BeginChild("##quick_launcher", {column, rows_height}, ImGuiChildFlags_NavFlattened); + } + settings::Settings &s = settings::current(); + if (choice_row("Back up saves before playing", s.launcher_backup ? "Yes" : "No", + {false, {}, "Copies your saves to the backups folder in the data folder each time you press " + "Play; the newest ten are kept."})) { + s.launcher_backup = !s.launcher_backup; + settings::save(); + } + if (choice_row("Show this launcher at start", s.launcher_skip ? "No" : "Yes", + {false, {}, "No: the game starts at once. Start with --launcher to see this screen again."})) { + s.launcher_skip = !s.launcher_skip; + settings::save(); + } + if (choice_row("Language", language_name(), + {false, {}, "The language of this menu and the launcher. The game's own text is not changed."})) + next_language(); + touch_scroll(); + ImGui::EndChild(); + + // Links and credits. + ImGui::PushFont(nullptr, font * 0.8f); + const float line_y = std::round(max.y - foot + font * 0.5f); + ImGui::SetCursorScreenPos({min.x + pad, line_y}); + link(tr("Project page"), theme_.project_url); + if (theme_.project_url != nullptr) ImGui::SameLine(0.0f, font * 1.2f); + link(tr("Report a problem"), theme_.issues_url); + if (theme_.credits != nullptr) { + const char *credits = tr(theme_.credits); + const ImVec2 size = ImGui::CalcTextSize(credits); + draw->AddText({std::round(max.x - pad - size.x), line_y}, rgb(theme_.text_dim, 200), credits); + } + ImGui::PopFont(); +} + +void Launcher::settings_page(ImVec2 min, ImVec2 max, bool back) { + const float font = Layer::get().font_size(); + const float pad = std::round(font * 1.4f); + const float width = max.x - min.x - pad * 2.0f; + const Page &page = pages()[static_cast(page_)]; + heading(tr(page.name), {min.x + pad, min.y + pad}, width, font * 1.4f); + const float top = std::round(min.y + pad + font * 2.6f); + ImGui::SetCursorScreenPos({min.x + pad, top}); + ImGui::BeginChild("##page", {width, max.y - top - font * 0.6f}, ImGuiChildFlags_NavFlattened); + const LauncherPage result = launcher_page(page.tab, back); + touch_scroll(); + ImGui::EndChild(); + if (result.set_up) { + choice_ = LauncherChoice::SetUp; + done_ = true; + } else if (back && !result.back_used) { + page_ = 0; + focus_nav_ = true; + } +} + +void Launcher::bottom_bar(ImVec2 min, ImVec2 max) { + const float font = Layer::get().font_size(); + ImDrawList *draw = ImGui::GetWindowDrawList(); + draw->AddRectFilled(min, max, rgb(theme_.sidebar)); + draw->AddLine(min, {max.x, min.y}, rgb(theme_.text, 14)); + const float pad = std::round(font * 1.4f); + const float gap = std::round(font * 0.45f); + + // Play. + const float button_width = std::round(font * 9.5f); + const ImVec2 bmin{max.x - gap - button_width, min.y + gap}; + const ImVec2 bmax{max.x - gap, max.y - gap}; + ImGui::SetCursorScreenPos(bmin); + if (ImGui::InvisibleButton("##play", {bmax.x - bmin.x, bmax.y - bmin.y}, ImGuiButtonFlags_EnableNav)) { + choice_ = LauncherChoice::Play; + done_ = true; + } + if (first_frame_) { + ImGui::FocusItem(); + first_frame_ = false; + } + const bool focused = ImGui::IsItemFocused(); + const bool lit = focused || ImGui::IsItemHovered(); + draw->AddRectFilled(bmin, bmax, rgb(lit ? theme_.accent_bright : theme_.accent)); + if (focused) draw->AddRect(bmin, bmax, rgb(theme_.text, 200), 0.0f, 0, std::max(1.0f, std::round(font * 0.08f))); + ImGui::PushFont(serif_, font * 1.3f); + const char *play = tr("PLAY"); + const ImVec2 text = ImGui::CalcTextSize(play); + draw->AddText({std::round(bmin.x + (bmax.x - bmin.x - text.x) * 0.5f), + std::round(bmin.y + (bmax.y - bmin.y - text.y) * 0.5f)}, + rgb(theme_.text), play); + ImGui::PopFont(); + + // What the focused row does. + const std::string &description = Layer::get().description(); + if (!description.empty()) { + ImGui::PushFont(nullptr, font * 0.8f); + const float wrap = bmin.x - min.x - pad * 2.0f; + const ImVec2 size = ImGui::CalcTextSize(description.c_str(), nullptr, false, wrap); + draw->PushClipRect(min, {bmin.x - pad, max.y}, true); + draw->AddText(nullptr, 0.0f, {min.x + pad, std::round(min.y + std::max(gap, (max.y - min.y - size.y) * 0.5f))}, + rgb(theme_.text_dim), description.c_str(), nullptr, wrap); + draw->PopClipRect(); + ImGui::PopFont(); + } +} + +bool Launcher::frame() { + Layer &layer = Layer::get(); + // A text field's keyboard takes the whole screen until it closes. + if (text_input_open()) { + text_input_frame(); + return true; + } + const ImGuiKey cancel = layer.confirm_south() ? ImGuiKey_GamepadFaceRight : ImGuiKey_GamepadFaceDown; + const bool back = layer.take_back() || ImGui::IsKeyPressed(cancel, false); + (void)layer.take_menu_toggle(); + if (ImGui::IsKeyPressed(ImGuiKey_GamepadStart, false)) { + choice_ = LauncherChoice::Play; + return false; + } + // The shoulder buttons go through the pages. + const int count = static_cast(pages().size()); + if (ImGui::IsKeyPressed(ImGuiKey_GamepadL1, false)) page_ = (page_ + count - 1) % count; + if (ImGui::IsKeyPressed(ImGuiKey_GamepadR1, false)) page_ = (page_ + 1) % count; + + const ImVec2 size = ImGui::GetIO().DisplaySize; + const float font = layer.font_size(); + ImGui::SetNextWindowPos({0.0f, 0.0f}); + ImGui::SetNextWindowSize(size); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {0.0f, 0.0f}); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::Begin("##launcher", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoBringToFrontOnFocus | + ImGuiWindowFlags_NoScrollWithMouse); + ImGui::PopStyleVar(3); + ImGui::GetWindowDrawList()->AddRectFilled({0.0f, 0.0f}, size, rgb(theme_.background)); + + const float side = std::round(std::clamp(size.x * 0.2f, font * 10.0f, font * 14.0f)); + const float bar = std::round(font * 3.3f); + sidebar(side, size.y); + const ImVec2 main_min{side, 0.0f}; + const ImVec2 main_max{size.x, size.y - bar}; + if (pages()[static_cast(page_)].tab < 0) home(main_min, main_max); + else settings_page(main_min, main_max, back); + bottom_bar({side, size.y - bar}, size); + + ImGui::End(); + return !done_; +} + +// Copies the saves to /backups/