Files
sphaira/sphaira/source/ui/menus/game_meta_menu.cpp
ITotalJustice 44e1584461 fs: add support for mounting nca, save and gamecard fs. file picker inherits from browser. fix bugs (see below).
- fixed fs real path length not actually being 0x301, but instead 255. fixes #204
- file picker inherits from file browser now so there's a lot less duplicated code.
- file browser now saves the last highlighted file.
- fix bug in file browser where the new file path could be empty (ie not containing a /).
- added support for viewing qlaunch romfs.
- moved fs mount options to the top of the list (may revert).
2025-08-09 11:34:35 +01:00

376 lines
14 KiB
C++

#include "ui/menus/game_meta_menu.hpp"
#include "ui/menus/game_nca_menu.hpp"
#include "ui/nvg_util.hpp"
#include "ui/sidebar.hpp"
#include "ui/option_box.hpp"
#include "yati/nx/ns.hpp"
#include "yati/nx/nca.hpp"
#include "yati/nx/ncm.hpp"
#include "yati/nx/es.hpp"
#include "title_info.hpp"
#include "app.hpp"
#include "defines.hpp"
#include "log.hpp"
#include "i18n.hpp"
#include "image.hpp"
#include <cstring>
#include <algorithm>
namespace sphaira::ui::menu::game::meta {
namespace {
#define SYSVER_MAJOR(x) (((x) >> 26) & 0x003F)
#define SYSVER_MINOR(x) (((x) >> 20) & 0x003F)
#define SYSVER_MICRO(x) (((x) >> 16) & 0x003F)
#define SYSVER_RELSTEP(x) (((x) >> 00) & 0xFFFF)
constexpr const char* TICKET_STR[] = {
[TicketType_None] = "None",
[TicketType_Common] = "Common",
[TicketType_Personalised] = "Personalised",
[TicketType_Missing] = "Missing",
};
constexpr u64 MINI_NACP_OFFSET = offsetof(NacpStruct, display_version);
Result GetMiniNacpFromContentId(NcmContentStorage* cs, const NcmContentMetaKey& key, const NcmContentId& id, MiniNacp& out) {
u64 program_id;
fs::FsPath path;
R_TRY(ncm::GetFsPathFromContentId(cs, key, id, &program_id, &path));
return nca::ParseControl(path, program_id, &out, sizeof(out), nullptr, MINI_NACP_OFFSET);
}
} // namespace
Menu::Menu(Entry& entry) : MenuBase{entry.GetName(), MenuFlag_None}, m_entry{entry} {
this->SetActions(
std::make_pair(Button::L2, Action{"Select"_i18n, [this](){
// if both set, select all.
if (App::GetApp()->m_controller.GotHeld(Button::R2)) {
const auto set = m_selected_count != m_entries.size();
for (u32 i = 0; i < m_entries.size(); i++) {
auto& e = GetEntry(i);
if (e.selected != set) {
e.selected = set;
if (set) {
m_selected_count++;
} else {
m_selected_count--;
}
}
}
} else {
GetEntry().selected ^= 1;
if (GetEntry().selected) {
m_selected_count++;
} else {
m_selected_count--;
}
}
}}),
std::make_pair(Button::A, Action{"View Content"_i18n, [this](){
App::Push<meta_nca::Menu>(m_entry, GetEntry());
}}),
std::make_pair(Button::B, Action{"Back"_i18n, [this](){
SetPop();
}}),
std::make_pair(Button::X, Action{"Options"_i18n, [this](){
auto options = std::make_unique<Sidebar>("Content Options"_i18n, Sidebar::Side::RIGHT);
ON_SCOPE_EXIT(App::Push(std::move(options)));
if (!m_entries.empty()) {
options->Add<SidebarEntryCallback>("Export NSP"_i18n, [this](){
DumpGames();
});
options->Add<SidebarEntryCallback>("Export options"_i18n, [this](){
App::DisplayDumpOptions(false);
});
options->Add<SidebarEntryCallback>("Delete"_i18n, [this](){
App::Push<OptionBox>(
"Are you sure you want to delete the selected entries?"_i18n,
"Back"_i18n, "Delete"_i18n, 0, [this](auto op_index){
if (op_index && *op_index) {
DeleteGames();
}
}
);
}, true);
if (ncm::HasRequiredSystemVersion(GetEntry().status.meta_type)) {
options->Add<SidebarEntryCallback>("Reset required system version"_i18n, [this](){
App::Push<OptionBox>(
"Are you sure you want to reset required system version?"_i18n,
"Back"_i18n, "Reset"_i18n, 0, [this](auto op_index){
if (op_index && *op_index) {
const auto rc = ResetRequiredSystemVersion(GetEntry());
App::PushErrorBox(rc, "Failed to reset required system version"_i18n);
}
}
);
});
}
}
}})
);
// todo: maybe width is broken here?
const Vec4 v{485, GetY() + 1.f + 42.f, 720, 60};
m_list = std::make_unique<List>(1, 8, m_pos, v);
es::Initialize();
ON_SCOPE_EXIT(es::Exit());
// pre-fetch all ticket rights ids.
es::GetCommonTickets(m_common_tickets);
es::GetPersonalisedTickets(m_personalised_tickets);
char subtitle[128];
std::snprintf(subtitle, sizeof(subtitle), "by %s", entry.GetAuthor());
SetTitleSubHeading(subtitle);
Scan();
}
Menu::~Menu() {
}
void Menu::Update(Controller* controller, TouchInfo* touch) {
if (m_dirty) {
m_dirty = false;
Scan();
}
MenuBase::Update(controller, touch);
m_list->OnUpdate(controller, touch, m_index, m_entries.size(), [this](bool touch, auto i) {
if (touch && m_index == i) {
FireAction(Button::A);
} else {
App::PlaySoundEffect(SoundEffect_Focus);
SetIndex(i);
}
});
}
void Menu::Draw(NVGcontext* vg, Theme* theme) {
MenuBase::Draw(vg, theme);
// draw left-side grid background.
gfx::drawRect(vg, 30, 90, 375, 555, theme->GetColour(ThemeEntryID_GRID));
// draw the game icon (maybe remove this or reduce it's size).
const auto& e = m_entries[m_index];
gfx::drawImage(vg, 90, 130, 256, 256, m_entry.image ? m_entry.image : App::GetDefaultImage());
nvgSave(vg);
nvgIntersectScissor(vg, 50, 90, 325, 555);
char req_vers_buf[128];
const auto ver = e.content_meta.extened.application.required_system_version;
switch (e.status.meta_type) {
case NcmContentMetaType_Application: std::snprintf(req_vers_buf, sizeof(req_vers_buf), "Required System Version: %u.%u.%u", SYSVER_MAJOR(ver), SYSVER_MINOR(ver), SYSVER_MICRO(ver)); break;
case NcmContentMetaType_Patch: std::snprintf(req_vers_buf, sizeof(req_vers_buf), "Required System Version: %u.%u.%u", SYSVER_MAJOR(ver), SYSVER_MINOR(ver), SYSVER_MICRO(ver)); break;
case NcmContentMetaType_AddOnContent: std::snprintf(req_vers_buf, sizeof(req_vers_buf), "Required Application Version: v%u", ver >> 16); break;
}
if (e.missing_count) {
gfx::drawTextArgs(vg, 50, 415, 18.f, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_TEXT), "Content Count: %u (%u missing)", e.content_meta.header.content_count, e.missing_count);
} else {
gfx::drawTextArgs(vg, 50, 415, 18.f, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_TEXT), "Content Count: %u", e.content_meta.header.content_count);
}
gfx::drawTextArgs(vg, 50, 455, 18.f, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_TEXT), "Ticket: %s", TICKET_STR[e.ticket_type]);
gfx::drawTextArgs(vg, 50, 495, 18.f, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_TEXT), "Key Generation: %u (%s)", e.key_gen, nca::GetKeyGenStr(e.key_gen));
gfx::drawTextArgs(vg, 50, 535, 18.f, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_TEXT), "%s", req_vers_buf);
if (e.status.meta_type == NcmContentMetaType_Application || e.status.meta_type == NcmContentMetaType_Patch) {
gfx::drawTextArgs(vg, 50, 575, 18.f, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_TEXT), "Display Version: %s", e.nacp.display_version);
}
nvgRestore(vg);
// exit early if we have no entries (maybe?)
if (m_entries.empty()) {
// todo: center this.
gfx::drawTextArgs(vg, SCREEN_WIDTH / 2.f, SCREEN_HEIGHT / 2.f, 36.f, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE, theme->GetColour(ThemeEntryID_TEXT_INFO), "Empty..."_i18n.c_str());
return;
}
constexpr float text_xoffset{15.f};
m_list->Draw(vg, theme, m_entries.size(), [this](auto* vg, auto* theme, auto& v, auto i) {
const auto& [x, y, w, h] = v;
auto& e = m_entries[i];
auto text_id = ThemeEntryID_TEXT;
if (m_index == i) {
text_id = ThemeEntryID_TEXT_SELECTED;
gfx::drawRectOutline(vg, theme, 4.f, v);
} else {
if (i != m_entries.size() - 1) {
gfx::drawRect(vg, x, y + h, w, 1.f, theme->GetColour(ThemeEntryID_LINE_SEPARATOR));
}
}
gfx::drawTextArgs(vg, x + text_xoffset, y + (h / 2.f), 20.f, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE, theme->GetColour(text_id), "%s", ncm::GetReadableMetaTypeStr(e.status.meta_type));
gfx::drawTextArgs(vg, x + text_xoffset + 150, y + (h / 2.f), 20.f, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE, theme->GetColour(text_id), "%016lX", e.status.application_id);
gfx::drawTextArgs(vg, x + text_xoffset + 400, y + (h / 2.f), 20.f, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE, theme->GetColour(text_id), "v%u (%u)", e.status.version >> 16, e.status.version);
if (!e.checked) {
e.checked = true;
GetNcmSizeOfMetaStatus(e);
}
gfx::drawTextArgs(vg, x + w - text_xoffset, y + (h / 2.f) + 3, 16.f, NVG_ALIGN_RIGHT | NVG_ALIGN_TOP, theme->GetColour(text_id), "%s", ncm::GetReadableStorageIdStr(e.status.storageID));
if ((double)e.size / 1024.0 / 1024.0 <= 0.009) {
gfx::drawTextArgs(vg, x + w - text_xoffset, y + (h / 2.f) - 3, 16.f, NVG_ALIGN_RIGHT | NVG_ALIGN_BOTTOM, theme->GetColour(text_id), "%.2f KiB", (double)e.size / 1024.0);
} else if ((double)e.size / 1024.0 / 1024.0 / 1024.0 <= 0.009) {
gfx::drawTextArgs(vg, x + w - text_xoffset, y + (h / 2.f) - 3, 16.f, NVG_ALIGN_RIGHT | NVG_ALIGN_BOTTOM, theme->GetColour(text_id), "%.2f MiB", (double)e.size / 1024.0 / 1024.0);
} else {
gfx::drawTextArgs(vg, x + w - text_xoffset, y + (h / 2.f) - 3, 16.f, NVG_ALIGN_RIGHT | NVG_ALIGN_BOTTOM, theme->GetColour(text_id), "%.2f GiB", (double)e.size / 1024.0 / 1024.0 / 1024.0);
}
if (e.selected) {
gfx::drawText(vg, x + text_xoffset - 80 / 2, y + (h / 2.f) - (24.f / 2), 24.f, "\uE14B", nullptr, NVG_ALIGN_CENTER | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_TEXT_SELECTED));
}
});
}
void Menu::SetIndex(s64 index) {
m_index = index;
if (!m_index) {
m_list->SetYoff(0);
}
UpdateSubheading();
}
void Menu::Scan() {
m_dirty = false;
m_index = 0;
m_selected_count = 0;
m_entries.clear();
// todo: log errors here.
title::MetaEntries meta_entries;
if (R_SUCCEEDED(title::GetMetaEntries(m_entry.app_id, meta_entries))) {
m_entries.reserve(meta_entries.size());
for (const auto& e : meta_entries) {
m_entries.emplace_back(e);
}
}
SetIndex(0);
}
void Menu::UpdateSubheading() {
const auto index = m_entries.empty() ? 0 : m_index + 1;
this->SetSubHeading(std::to_string(index) + " / " + std::to_string(m_entries.size()));
}
Result Menu::GetNcmSizeOfMetaStatus(MetaEntry& entry) const {
entry.size = 0;
entry.missing_count = 0;
NcmMetaData meta;
R_TRY(GetNcmMetaFromMetaStatus(entry.status, meta));
// get the content meta header.
R_TRY(ncm::GetContentMeta(meta.db, &meta.key, entry.content_meta));
// fetch all the content infos.
std::vector<NcmContentInfo> infos;
R_TRY(ncm::GetContentInfos(meta.db, &meta.key, entry.content_meta.header, infos));
// calculate the size and fetch the rights id (if possible).
NcmRightsId rights_id{};
bool has_nacp{};
for (const auto& info : infos) {
u64 size;
ncmContentInfoSizeToU64(&info, &size);
entry.size += size;
// try and load nacp.
if (!has_nacp && info.content_type == NcmContentType_Control) {
// try and load from nca.
if (R_SUCCEEDED(GetMiniNacpFromContentId(meta.cs, meta.key, info.content_id, entry.nacp))) {
has_nacp = true;
} else {
// fallback to ns
std::vector<u8> buf(sizeof(NsApplicationControlData));
u64 actual_size;
if (R_SUCCEEDED(nsGetApplicationControlData(NsApplicationControlSource_Storage, meta.app_id, (NsApplicationControlData*)buf.data(), buf.size(), &actual_size))) {
has_nacp = true;
std::memcpy(&entry.nacp, buf.data() + MINI_NACP_OFFSET, sizeof(entry.nacp));
}
}
}
// ensure that we have the content id.
bool has;
R_TRY(ncmContentMetaDatabaseHasContent(meta.db, &has, &meta.key, &info.content_id));
if (!has) {
entry.missing_count++;
}
if (!es::IsRightsIdValid(rights_id.rights_id)) {
// todo: check if this gets the key gen if standard crypto is used.
if (R_SUCCEEDED(ncmContentStorageGetRightsIdFromContentId(meta.cs, &rights_id, &info.content_id, FsContentAttributes_All))) {
entry.key_gen = std::max(entry.key_gen, rights_id.key_generation);
}
}
}
// if we found a valid rights id, find the ticket type.
if (es::IsRightsIdValid(rights_id.rights_id)) {
if (es::IsRightsIdFound(rights_id.rights_id, m_common_tickets)) {
entry.ticket_type = TicketType_Common;
} else if (es::IsRightsIdFound(rights_id.rights_id, m_personalised_tickets)) {
entry.ticket_type = TicketType_Personalised;
} else {
entry.ticket_type = TicketType_Missing;
}
} else {
entry.ticket_type = TicketType_None;
}
R_SUCCEED();
}
void Menu::DumpGames() {
const auto entries = GetSelectedEntries();
App::PopToMenu();
std::vector<NspEntry> nsps;
BuildNspEntries(m_entry, entries, nsps);
DumpNsp(nsps);
}
void Menu::DeleteGames() {
m_dirty = true;
const auto entries = GetSelectedEntries();
App::PopToMenu();
DeleteMetaEntries(m_entry.app_id, m_entry.image, m_entry.GetName(), entries);
}
Result Menu::ResetRequiredSystemVersion(MetaEntry& entry) const {
entry.checked = false;
NcmMetaData meta;
R_TRY(GetNcmMetaFromMetaStatus(entry.status, meta));
return ncm::SetRequiredSystemVersion(meta.db, &meta.key, 0);
}
} // namespace sphaira::ui::menu::game::meta