Files
sphaira/sphaira/source/ui/menus/game_nca_menu.cpp
Yorunokyujitsu 28411fcdd1 i18n: Added translatable strings, new languages, and extended localization features.
- Added sub-keys to better manage long strings, allowing either the sub-key value or the original text to be used.
- Multi-line values are now supported in language.json to prevent overly long single lines.
- Added word-order adjustment for certain Asian languages such as Japanese and Korean.
- Added separate support for Simplified and Traditional Chinese.
2025-11-25 19:10:45 +09:00

432 lines
17 KiB
C++

#include "ui/menus/game_nca_menu.hpp"
#include "ui/menus/filebrowser.hpp"
#include "ui/nvg_util.hpp"
#include "ui/sidebar.hpp"
#include "ui/option_box.hpp"
#include "ui/progress_box.hpp"
#include "yati/nx/nca.hpp"
#include "yati/nx/ncm.hpp"
#include "yati/nx/keys.hpp"
#include "yati/nx/crypto.hpp"
#include "utils/utils.hpp"
#include "utils/devoptab.hpp"
#include "title_info.hpp"
#include "app.hpp"
#include "dumper.hpp"
#include "defines.hpp"
#include "log.hpp"
#include "i18n.hpp"
#include "image.hpp"
#include "hasher.hpp"
#include <cstring>
#include <algorithm>
namespace sphaira::ui::menu::game::meta_nca {
namespace {
struct NcaHashSource final : hash::BaseSource {
NcaHashSource(NcmContentStorage* cs, const NcaEntry& entry) : m_cs{cs}, m_entry{entry} {
}
Result Size(s64* out) override {
*out = m_entry.size;
R_SUCCEED();
}
Result Read(void* buf, s64 off, s64 size, u64* bytes_read) override {
const auto rc = ncmContentStorageReadContentIdFile(m_cs, buf, size, &m_entry.content_id, off);
if (R_SUCCEEDED(rc)) {
*bytes_read = size;
}
return rc;
}
private:
NcmContentStorage* const m_cs;
const NcaEntry& m_entry{};
};
struct NcaSource final : dump::BaseSource {
NcaSource(NcmContentStorage* cs, int icon, const std::vector<NcaEntry>& entries) : m_cs{cs}, m_icon{icon}, m_entries{entries} {
m_is_file_based_emummc = App::IsFileBaseEmummc();
}
Result Read(const std::string& path, void* buf, s64 off, s64 size, u64* bytes_read) override {
const auto it = std::ranges::find_if(m_entries, [&path](auto& e){
return path.find(utils::hexIdToStr(e.content_id).str) != path.npos;
});
R_UNLESS(it != m_entries.end(), Result_GameBadReadForDump);
const auto rc = ncmContentStorageReadContentIdFile(m_cs, buf, size, &it->content_id, off);
if (R_SUCCEEDED(rc)) {
*bytes_read = size;
}
if (m_is_file_based_emummc) {
svcSleepThread(2e+6); // 2ms
}
return rc;
}
auto GetName(const std::string& path) const -> std::string {
const auto it = std::ranges::find_if(m_entries, [&path](auto& e){
return path.find(utils::hexIdToStr(e.content_id).str) != path.npos;
});
if (it != m_entries.end()) {
return utils::hexIdToStr(it->content_id).str;
}
return {};
}
auto GetSize(const std::string& path) const -> s64 {
const auto it = std::ranges::find_if(m_entries, [&path](auto& e){
return path.find(utils::hexIdToStr(e.content_id).str) != path.npos;
});
if (it != m_entries.end()) {
return it->size;
}
return 0;
}
auto GetIcon(const std::string& path) const -> int override {
return m_icon ? m_icon : App::GetDefaultImage();
}
private:
NcmContentStorage* const m_cs;
const int m_icon;
std::vector<NcaEntry> m_entries{};
bool m_is_file_based_emummc{};
};
Result GetFsFileSystemType(u8 content_type, FsFileSystemType& out) {
switch (content_type) {
case nca::ContentType_Meta:
out = FsFileSystemType_ContentMeta;
R_SUCCEED();
case nca::ContentType_Control:
out = FsFileSystemType_ContentControl;
R_SUCCEED();
case nca::ContentType_Manual:
out = FsFileSystemType_ContentManual;
R_SUCCEED();
case nca::ContentType_Data:
out = FsFileSystemType_ContentData;
R_SUCCEED();
}
R_THROW(0x1);
}
} // namespace
Menu::Menu(Entry& entry, const meta::MetaEntry& meta_entry)
: MenuBase{entry.GetName(), MenuFlag_None}
, m_entry{entry}
, m_meta_entry{meta_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{"Mount Fs"_i18n, [this](){
// todo: handle error here.
if (!m_entries.empty() && !GetEntry().missing) {
const auto rc = MountNcaFs();
App::PushErrorBox(rc, "Failed to mount NCA"_i18n);
}
}}),
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>("NCA Options"_i18n, Sidebar::Side::RIGHT);
ON_SCOPE_EXIT(App::Push(std::move(options)));
if (!m_entries.empty()) {
options->Add<SidebarEntryCallback>("Export NCA"_i18n, [this](){
DumpNcas();
});
// todo:
#if 0
options->Add<SidebarEntryCallback>("Export NCA decrypted"_i18n, [this](){
DumpNcas();
}, "Exports the NCA with all fs sections decrypted (NCA header is still encrypted)."_i18n);
#endif
options->Add<SidebarEntryCallback>("Verify NCA 256 hash"_i18n, [this](){
static std::string hash_out;
hash_out.clear();
App::Push<ProgressBox>(m_entry.image, "Hashing"_i18n, utils::hexIdToStr(GetEntry().content_id).str, [this](auto pbox) -> Result{
auto source = std::make_unique<NcaHashSource>(m_meta.cs, GetEntry());
return hash::Hash(pbox, hash::Type::Sha256, source.get(), hash_out);
}, [this](Result rc){
App::PushErrorBox(rc, "Failed to hash file..."_i18n);
const auto str = utils::hexIdToStr(GetEntry().content_id);
if (R_SUCCEEDED(rc)) {
if (std::strncmp(hash_out.c_str(), str.str, std::strlen(str.str))) {
App::Push<OptionBox>("NCA hash missmatch!"_i18n, "OK"_i18n);
} else {
App::Push<OptionBox>("NCA hash valid."_i18n, "OK"_i18n);
}
}
});
}, i18n::get("nca_validate_info",
"Performs sha256 hash over the NCA to check if it's valid.\n\n"
"NOTE: This only detects if the hash is missmatched, it does not validate if "
"the content has been modified at all."));
options->Add<SidebarEntryCallback>("Verify NCA fixed key"_i18n, [this](){
if (R_FAILED(nca::VerifyFixedKey(GetEntry().header))) {
App::Push<OptionBox>("NCA fixed key is invalid!"_i18n, "OK"_i18n);
} else {
App::Push<OptionBox>("NCA fixed key is valid."_i18n, "OK"_i18n);
}
}, i18n::get("nca_fixedkey_info",
"Performs RSA NCA fixed key verification. "
"This is a hash over the NCA header. It is used to verify that the header has not been modified. "
"The header is signed by nintendo, thus it cannot be forged, and is reliable to detect modified NCA headers (such as NSP/XCI converts)."));
}
}})
);
keys::Keys keys;
parse_keys(keys, false);
if (R_FAILED(GetNcmMetaFromMetaStatus(m_meta_entry.status, m_meta))) {
log_write("[NCA-MENU] failed to GetNcmMetaFromMetaStatus()\n");
SetPop();
return;
}
// get the content meta header.
ncm::ContentMeta content_meta;
if (R_FAILED(ncm::GetContentMeta(m_meta.db, &m_meta.key, content_meta))) {
log_write("[NCA-MENU] failed to ncm::GetContentMeta()\n");
SetPop();
return;
}
// fetch all the content infos.
std::vector<NcmContentInfo> infos;
if (R_FAILED(ncm::GetContentInfos(m_meta.db, &m_meta.key, content_meta.header, infos))) {
log_write("[NCA-MENU] failed to ncm::GetContentInfos()\n");
SetPop();
return;
}
for (const auto& info : infos) {
NcaEntry entry{};
entry.content_id = info.content_id;
entry.content_type = info.content_type;
ncmContentInfoSizeToU64(&info, &entry.size);
bool has = false;
if (R_FAILED(ncmContentMetaDatabaseHasContent(m_meta.db, &has, &m_meta.key, &info.content_id)) || !has) {
log_write("[NCA-MENU] does not have nca!\n");
}
entry.missing = !has;
// decrypt header.
if (has && R_SUCCEEDED(ncmContentStorageReadContentIdFile(m_meta.cs, &entry.header, sizeof(entry.header), &info.content_id, 0))) {
log_write("[NCA-MENU] reading to decrypt header\n");
crypto::cryptoAes128Xts(&entry.header, &entry.header, keys.header_key, 0, 0x200, sizeof(entry.header), false);
} else {
log_write("[NCA-MENU] failed to read nca from ncm\n");
}
m_entries.emplace_back(entry);
}
// 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);
char subtitle[128];
std::snprintf(subtitle, sizeof(subtitle), "by %s", entry.GetAuthor());
SetTitleSubHeading(subtitle);
SetIndex(0);
}
Menu::~Menu() {
}
void Menu::Update(Controller* controller, TouchInfo* touch) {
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());
if (e.header.magic != NCA3_MAGIC) {
gfx::drawTextArgs(vg, 50, 415, 18.f, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_TEXT), "Failed to decrypt NCA"_i18n.c_str());
} else {
nvgSave(vg);
nvgIntersectScissor(vg, 50, 90, 325, 555);
gfx::drawTextArgs(vg, 50, 415, 18.f, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_TEXT), "Application Type: %s"_i18n.c_str(), i18n::get(ncm::GetReadableMetaTypeStr(m_meta_entry.status.meta_type)).c_str());
gfx::drawTextArgs(vg, 50, 455, 18.f, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_TEXT), "Content Type: %s"_i18n.c_str(), nca::GetContentTypeStr(e.header.content_type));
gfx::drawTextArgs(vg, 50, 495, 18.f, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_TEXT), "Distribution Type: %s"_i18n.c_str(), nca::GetDistributionTypeStr(e.header.distribution_type));
gfx::drawTextArgs(vg, 50, 535, 18.f, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_TEXT), "Program ID: %016lX"_i18n.c_str(), e.header.program_id);
gfx::drawTextArgs(vg, 50, 575, 18.f, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_TEXT), "Key Generation: %u (%s)"_i18n.c_str(), e.header.GetKeyGeneration(), nca::GetKeyGenStr(e.header.GetKeyGeneration()));
gfx::drawTextArgs(vg, 50, 615, 18.f, NVG_ALIGN_LEFT | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_TEXT), "SDK Version: %u.%u.%u.%u"_i18n.c_str(), e.header.sdk_major, e.header.sdk_minor, e.header.sdk_micro, e.header.sdk_revision);
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::GetContentTypeStr(e.content_type));
gfx::drawTextArgs(vg, x + text_xoffset + 150, y + (h / 2.f), 20.f, NVG_ALIGN_LEFT | NVG_ALIGN_MIDDLE, theme->GetColour(text_id), "%s", utils::hexIdToStr(e.content_id).str);
gfx::drawTextArgs(vg, x + w - text_xoffset, y + (h / 2.f), 16.f, NVG_ALIGN_RIGHT | NVG_ALIGN_MIDDLE, theme->GetColour(ThemeEntryID_TEXT_INFO), "%s", utils::formatSizeStorage(e.size).c_str());
if (e.missing) {
gfx::drawText(vg, x + text_xoffset - 80 / 2, y + (h / 2.f) - (24.f / 2), 24.f, "\uE140", nullptr, NVG_ALIGN_CENTER | NVG_ALIGN_TOP, theme->GetColour(ThemeEntryID_ERROR));
} else 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::UpdateSubheading() {
const auto index = m_entries.empty() ? 0 : m_index + 1;
this->SetSubHeading(std::to_string(index) + " / " + std::to_string(m_entries.size()));
}
void Menu::DumpNcas() {
const auto entries = GetSelectedEntries();
App::PopToMenu();
fs::FsPath name_buf = m_entry.GetName();
title::utilsReplaceIllegalCharacters(name_buf, true);
char version[sizeof(NacpStruct::display_version) + 1]{};
if (m_meta_entry.status.meta_type == NcmContentMetaType_Patch) {
std::snprintf(version, sizeof(version), "%s ", m_meta_entry.nacp.display_version);
}
std::vector<fs::FsPath> paths;
for (auto& e : entries) {
char nca_name[64];
std::snprintf(nca_name, sizeof(nca_name), "%s%s", utils::hexIdToStr(e.content_id).str, e.content_type == NcmContentType_Meta ? ".cnmt.nca" : ".nca");
fs::FsPath path;
std::snprintf(path, sizeof(path), "/dumps/NCA/%s %s[%016lX][v%u][%s]/%s", name_buf.s, version, m_meta_entry.status.application_id, m_meta_entry.status.version, ncm::GetMetaTypeShortStr(m_meta_entry.status.meta_type), nca_name);
paths.emplace_back(path);
}
auto source = std::make_shared<NcaSource>(m_meta.cs, m_entry.image, entries);
dump::Dump(source, paths, nullptr, dump::DumpLocationFlag_All &~ dump::DumpLocationFlag_UsbS2S);
}
Result Menu::MountNcaFs() {
const auto& e = GetEntry();
// mount using devoptab instead if fails.
FsFileSystemType type;
if (R_FAILED(GetFsFileSystemType(e.header.content_type, type))) {
fs::FsPath root;
R_TRY(devoptab::MountNcaNcm(m_meta.cs, &e.content_id, root));
auto fs = std::make_shared<filebrowser::FsStdioWrapper>(root, [root](){
devoptab::UmountNeworkDevice(root);
});
filebrowser::MountFsHelper(fs, utils::hexIdToStr(e.content_id).str);
} else {
// get fs path from ncm.
u64 program_id;
fs::FsPath path;
R_TRY(ncm::GetFsPathFromContentId(m_meta.cs, m_meta.key, e.content_id, &program_id, &path));
// ensure that mounting worked.
auto fs = std::make_shared<fs::FsNativeId>(program_id, type, path);
R_TRY(fs->GetFsOpenResult());
filebrowser::MountFsHelper(fs, utils::hexIdToStr(e.content_id).str);
}
R_SUCCEED();
}
} // namespace sphaira::ui::menu::game::meta_nca