Refactor InnerFAT into Savegame and Extdata

And derive TitleDB from it too.
This commit is contained in:
Pengfei
2021-07-06 16:11:03 +08:00
parent 184d73d465
commit c81db424bb
13 changed files with 711 additions and 714 deletions
+20
View File
@@ -15,6 +15,7 @@
#include <type_traits>
#include <vector>
#include "common/common_types.h"
#include "common/logging/log.h"
#ifdef _MSC_VER
#include "common/string_util.h"
#endif
@@ -251,6 +252,25 @@ private:
bool m_good = true;
};
template <typename T>
bool WriteBytesToFile(const std::string& path, T* data, std::size_t length) {
if (!CreateFullPath(path)) {
LOG_ERROR(Core, "Could not create path {}", path);
return false;
}
IOFile file(path, "wb");
if (!file.IsOpen()) {
LOG_ERROR(Core, "Could not open file {}", path);
return false;
}
if (file.WriteBytes(data, length) != length) {
LOG_ERROR(Core, "Write data failed (file: {})", path);
return false;
}
return true;
}
} // namespace FileUtil
// To deal with Windows being dumb at unicode:
+5 -2
View File
@@ -3,10 +3,11 @@ add_library(core STATIC
data_container.h
decryptor.cpp
decryptor.h
extdata.cpp
extdata.h
importer.cpp
importer.h
inner_fat.cpp
inner_fat.h
inner_fat.hpp
key/arithmetic128.cpp
key/arithmetic128.h
key/key.cpp
@@ -26,6 +27,8 @@ add_library(core STATIC
quick_decryptor.cpp
quick_decryptor.h
result_status.h
savegame.cpp
savegame.h
title_db.cpp
title_db.h
)
+141
View File
@@ -0,0 +1,141 @@
// Copyright 2021 threeSD Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#include "core/data_container.h"
#include "core/decryptor.h"
#include "core/extdata.h"
namespace Core {
Extdata::Extdata(std::string data_path_, const SDMCDecryptor& decryptor_)
: data_path(std::move(data_path_)), decryptor(&decryptor_) {
if (data_path.back() != '/' && data_path.back() != '\\') {
data_path += '/';
}
use_decryptor = true;
is_good = Init();
}
Extdata::Extdata(std::string data_path_) : data_path(std::move(data_path_)) {
if (data_path.back() != '/' && data_path.back() != '\\') {
data_path += '/';
}
use_decryptor = false;
is_good = Init();
}
Extdata::~Extdata() = default;
bool Extdata::CheckMagic() const {
if (header.fat_header.magic != MakeMagic('V', 'S', 'X', 'E') ||
header.fat_header.version != 0x30000) {
LOG_ERROR(Core, "File is invalid, decryption errors may have happened.");
return false;
}
return true;
}
bool Extdata::IsGood() const {
return is_good;
}
bool Extdata::Extract(std::string path) const {
if (path.back() != '/' && path.back() != '\\') {
path += '/';
}
if (!ExtractDirectory(path, 1)) {
return false;
}
// Write format info
const auto format_info = GetFormatInfo();
return FileUtil::WriteBytesToFile(path + "metadata", &format_info, sizeof(format_info));
}
std::vector<u8> Extdata::ReadFile(const std::string& path) const {
if (use_decryptor) {
return decryptor->DecryptFile(path);
} else {
FileUtil::IOFile file(path, "rb");
return file.GetData();
}
}
bool Extdata::Init() {
// Read VSXE file
auto vsxe_raw = ReadFile(data_path + "00000000/00000001");
if (vsxe_raw.empty()) {
LOG_ERROR(Core, "Failed to load or decrypt VSXE");
return false;
}
DataContainer vsxe_container(std::move(vsxe_raw));
if (!vsxe_container.IsGood()) {
return false;
}
std::vector<std::vector<u8>> data;
if (!vsxe_container.GetIVFCLevel4Data(data)) {
return false;
}
return Archive<Extdata>::Init(std::move(data));
}
bool Extdata::ExtractFile(const std::string& path, std::size_t index) const {
/// Maximum amount of device files a device directory can hold.
constexpr u32 DeviceDirCapacity = 126;
FileUtil::IOFile file(path, "wb");
if (!file) {
LOG_ERROR(Core, "Could not open file {}", path);
return false;
}
u32 file_index = index + 1;
u32 sub_directory_id = file_index / DeviceDirCapacity;
u32 sub_file_id = file_index % DeviceDirCapacity;
std::string device_file_path =
fmt::format("{}{:08x}/{:08x}", data_path, sub_directory_id, sub_file_id);
auto container_data = ReadFile(device_file_path);
if (container_data.empty()) { // File does not exist?
LOG_WARNING(Core, "Ignoring file {}", device_file_path);
return true;
}
DataContainer container(std::move(container_data));
if (!container.IsGood()) {
return false;
}
std::vector<std::vector<u8>> data;
if (!container.GetIVFCLevel4Data(data)) {
return false;
}
if (file.WriteBytes(data[0].data(), data[0].size()) != data[0].size()) {
LOG_ERROR(Core, "Write data failed (file: {})", path);
return false;
}
return true;
}
ArchiveFormatInfo Extdata::GetFormatInfo() const {
// This information is based on how Citra created the metadata in FS
ArchiveFormatInfo format_info = {/* total_size */ 0,
/* number_directories */ fs_info.maximum_directory_count,
/* number_files */ fs_info.maximum_file_count,
/* duplicate_data */ false};
return format_info;
}
} // namespace Core
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2021 threeSD Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#pragma once
#include "core/inner_fat.hpp"
namespace Core {
class SDMCDecryptor;
class Extdata final : public Archive<Extdata> {
public:
/**
* Loads an SD extdata folder.
* @param data_path Path to the ENCRYPTED SD extdata folder, relative to decryptor root
* @param decryptor Const reference to the SDMCDecryptor.
*/
explicit Extdata(std::string data_path, const SDMCDecryptor& decryptor);
/**
* Loads an Extdata folder without encryption.
* @param data_path Path to the DECRYPTED extdata folder
*/
explicit Extdata(std::string data_path);
~Extdata();
bool IsGood() const;
bool Extract(std::string path) const;
private:
bool Init();
bool CheckMagic() const;
std::vector<u8> ReadFile(const std::string& path) const;
bool ExtractFile(const std::string& path, std::size_t index) const;
ArchiveFormatInfo GetFormatInfo() const;
bool is_good = false;
std::string data_path;
const SDMCDecryptor* decryptor = nullptr;
bool use_decryptor = true;
friend class Archive<Extdata>;
friend class InnerFAT<Extdata>;
};
} // namespace Core
+7 -6
View File
@@ -9,14 +9,15 @@
#include "common/string_util.h"
#include "core/data_container.h"
#include "core/decryptor.h"
#include "core/extdata.h"
#include "core/importer.h"
#include "core/inner_fat.h"
#include "core/key/key.h"
#include "core/ncch/cia_builder.h"
#include "core/ncch/ncch_container.h"
#include "core/ncch/seed_db.h"
#include "core/ncch/smdh.h"
#include "core/ncch/title_metadata.h"
#include "core/savegame.h"
#include "core/title_db.h"
namespace Core {
@@ -209,7 +210,7 @@ bool SDMCImporter::ImportSavegame(u64 id,
return false;
}
SDSavegame save(std::move(container_data));
Savegame save(std::move(container_data));
if (!save.IsGood()) {
return false;
}
@@ -236,7 +237,7 @@ bool SDMCImporter::ImportNandSavegame(u64 id,
return false;
}
SDSavegame save(std::move(container_data));
Savegame save(std::move(container_data));
if (!save.IsGood()) {
return false;
}
@@ -249,7 +250,7 @@ bool SDMCImporter::ImportNandSavegame(u64 id,
bool SDMCImporter::ImportExtdata(u64 id,
[[maybe_unused]] const Common::ProgressCallback& callback) {
const auto path = fmt::format("extdata/{:08x}/{:08x}/", (id >> 32), (id & 0xFFFFFFFF));
SDExtdata extdata("/" + path, *decryptor);
Extdata extdata("/" + path, *decryptor);
if (!extdata.IsGood()) {
return false;
}
@@ -262,7 +263,7 @@ bool SDMCImporter::ImportExtdata(u64 id,
bool SDMCImporter::ImportNandExtdata(u64 id,
[[maybe_unused]] const Common::ProgressCallback& callback) {
const auto path = fmt::format("extdata/{:08x}/{:08x}/", (id >> 32), (id & 0xFFFFFFFF));
SDExtdata extdata(config.nand_data_path + path);
Extdata extdata(config.nand_data_path + path);
if (!extdata.IsGood()) {
return false;
}
@@ -412,7 +413,7 @@ bool SDMCImporter::ImportSysdata(u64 id,
return false;
}
SDSavegame save(std::move(container_data));
Savegame save(std::move(container_data));
if (!save.IsGood()) {
return false;
}
-419
View File
@@ -1,419 +0,0 @@
// Copyright 2019 threeSD Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#include <fmt/format.h>
#include "common/assert.h"
#include "common/common_funcs.h"
#include "common/file_util.h"
#include "core/data_container.h"
#include "core/decryptor.h"
#include "core/inner_fat.h"
namespace Core {
InnerFAT::~InnerFAT() = default;
bool InnerFAT::IsGood() const {
return is_good;
}
bool InnerFAT::ExtractDirectory(const std::string& path, std::size_t index) const {
if (index >= directory_entry_table.size()) {
LOG_ERROR(Core, "Index out of bound {}", index);
return false;
}
auto entry = directory_entry_table[index];
std::array<char, 17> name_data = {}; // Append a null terminator
std::memcpy(name_data.data(), entry.name.data(), entry.name.size());
std::string name = name_data.data();
std::string new_path = name.empty() ? path : path + name + "/"; // Name is empty for root
if (!FileUtil::CreateFullPath(new_path)) {
LOG_ERROR(Core, "Could not create path {}", new_path);
return false;
}
// Files
u32 cur = entry.first_file_index;
while (cur != 0) {
if (!ExtractFile(new_path, cur))
return false;
cur = file_entry_table[cur].next_sibling_index;
}
// Subdirectories
cur = entry.first_subdirectory_index;
while (cur != 0) {
if (!ExtractDirectory(new_path, cur))
return false;
cur = directory_entry_table[cur].next_sibling_index;
}
return true;
}
bool InnerFAT::WriteMetadata(const std::string& path) const {
if (!FileUtil::CreateFullPath(path)) {
LOG_ERROR(Core, "Could not create path {}", path);
return false;
}
auto format_info = GetFormatInfo();
FileUtil::IOFile file(path, "wb");
if (!file.IsOpen()) {
LOG_ERROR(Core, "Could not open file {}", path);
return false;
}
if (file.WriteBytes(&format_info, sizeof(format_info)) != sizeof(format_info)) {
LOG_ERROR(Core, "Write data failed (file: {})", path);
return false;
}
return true;
}
SDSavegame::SDSavegame(std::vector<std::vector<u8>> partitions) {
if (partitions.size() == 1) {
duplicate_data = true;
data = std::move(partitions[0]);
} else if (partitions.size() == 2) {
duplicate_data = false;
partitionA = std::move(partitions[0]);
partitionB = std::move(partitions[1]);
} else {
UNREACHABLE();
}
is_good = Init();
}
SDSavegame::~SDSavegame() = default;
bool SDSavegame::Init() {
const auto& header_vector = duplicate_data ? data : partitionA;
// Read header
TRY(CheckedMemcpy(&header, header_vector, 0, sizeof(header)),
LOG_ERROR(Core, "File size is too small"));
if (header.magic != MakeMagic('S', 'A', 'V', 'E') || header.version != 0x40000) {
LOG_ERROR(Core, "File is invalid, decryption errors may have happened.");
return false;
}
// Read filesystem information
TRY(CheckedMemcpy(&fs_info, header_vector, header.filesystem_information_offset,
sizeof(fs_info)),
LOG_ERROR(Core, "File size is too small"));
// Read data region
if (duplicate_data) {
data_region.resize(fs_info.data_region_block_count *
static_cast<std::size_t>(fs_info.data_region_block_size));
TRY(CheckedMemcpy(data_region.data(), data, fs_info.data_region_offset, data_region.size()),
LOG_ERROR(Core, "File size is too small"));
} else {
data_region = std::move(partitionB);
}
// Directory & file entry tables are allocated in the data region as if they were normal
// files. However, only continuous allocation has been observed so far according to 3DBrew,
// so it should be safe to directly read the bytes.
// Read directory entry table
directory_entry_table.resize(fs_info.maximum_directory_count + 2); // including head and root
auto directory_entry_table_pos =
duplicate_data ? fs_info.data_region_offset +
fs_info.directory_entry_table.duplicate.block_index *
static_cast<std::size_t>(fs_info.data_region_block_size)
: fs_info.directory_entry_table.non_duplicate;
TRY(CheckedMemcpy(directory_entry_table.data(), header_vector, directory_entry_table_pos,
directory_entry_table.size() * sizeof(DirectoryEntryTableEntry)),
LOG_ERROR(Core, "File is too small"));
// Read file entry table
file_entry_table.resize(fs_info.maximum_file_count + 1); // including head
auto file_entry_table_pos =
duplicate_data ? fs_info.data_region_offset +
fs_info.file_entry_table.duplicate.block_index *
static_cast<std::size_t>(fs_info.data_region_block_size)
: fs_info.file_entry_table.non_duplicate;
TRY(CheckedMemcpy(file_entry_table.data(), header_vector, file_entry_table_pos,
file_entry_table.size() * sizeof(FileEntryTableEntry)),
LOG_ERROR(Core, "File is too small"));
// Read file allocation table
fat.resize(fs_info.file_allocation_table_entry_count);
TRY(CheckedMemcpy(fat.data(), header_vector, fs_info.file_allocation_table_offset,
fat.size() * sizeof(FATNode)),
LOG_ERROR(Core, "File size is too small"));
return true;
}
bool SDSavegame::ExtractFile(const std::string& path, std::size_t index) const {
if (!FileUtil::CreateFullPath(path)) {
LOG_ERROR(Core, "Could not create path {}", path);
return false;
}
if (index >= file_entry_table.size()) {
LOG_ERROR(Core, "Index out of bound {}", index);
return false;
}
auto entry = file_entry_table[index];
std::array<char, 17> name_data = {}; // Append a null terminator
std::memcpy(name_data.data(), entry.name.data(), entry.name.size());
std::string name = name_data.data();
FileUtil::IOFile file(path + name, "wb");
if (!file) {
LOG_ERROR(Core, "Could not open file {}", path + name);
return false;
}
u32 block = entry.data_block_index;
if (block == 0x80000000) { // empty file
return true;
}
u64 file_size = entry.file_size;
while (true) {
// Entry index is block index + 1
auto block_data = fat[block + 1];
u32 last_block = block;
if (block_data.v.flag) { // This node has multiple entries
last_block = fat[block + 2].v.index - 1;
}
const std::size_t size =
static_cast<std::size_t>(fs_info.data_region_block_size) * (last_block - block + 1);
const std::size_t to_write = std::min<std::size_t>(file_size, size);
if (data_region.size() <
static_cast<std::size_t>(fs_info.data_region_block_size) * block + to_write) {
LOG_ERROR(Core, "Out of bound block: {} to_write: {}", block, to_write);
return false;
}
if (file.WriteBytes(data_region.data() + fs_info.data_region_block_size * block,
to_write) != to_write) {
LOG_ERROR(Core, "Write data failed (file: {})", path + name);
return false;
}
file_size -= to_write;
if (block_data.v.index == 0 || file_size == 0) // last node
break;
block = block_data.v.index - 1;
}
return true;
}
bool SDSavegame::Extract(std::string path) const {
if (path.back() != '/' && path.back() != '\\') {
path += '/';
}
// All saves on a physical 3DS are called 00000001.sav
if (!ExtractDirectory(path + "00000001/", 1)) { // Directory 1 = root
return false;
}
if (!WriteMetadata(path + "00000001.metadata")) {
return false;
}
return true;
}
ArchiveFormatInfo SDSavegame::GetFormatInfo() const {
// Tests on a physical 3DS shows that the `total_size` field seems to always be 0
// when requested with the UserSaveData archive, and 134328448 when requested with
// the SaveData archive. More investigation is required to tell whether this is a fixed value.
ArchiveFormatInfo format_info = {/* total_size */ 0x40000,
/* number_directories */ fs_info.maximum_directory_count,
/* number_files */ fs_info.maximum_file_count,
/* duplicate_data */ duplicate_data};
return format_info;
}
SDExtdata::SDExtdata(std::string data_path_, const SDMCDecryptor& decryptor_)
: data_path(std::move(data_path_)), decryptor(&decryptor_) {
if (data_path.back() != '/' && data_path.back() != '\\') {
data_path += '/';
}
use_decryptor = true;
is_good = Init();
}
SDExtdata::SDExtdata(std::string data_path_) : data_path(std::move(data_path_)) {
if (data_path.back() != '/' && data_path.back() != '\\') {
data_path += '/';
}
use_decryptor = false;
is_good = Init();
}
SDExtdata::~SDExtdata() = default;
std::vector<u8> SDExtdata::ReadFile(const std::string& path) const {
if (use_decryptor) {
return decryptor->DecryptFile(path);
} else {
FileUtil::IOFile file(path, "rb");
return file.GetData();
}
}
bool SDExtdata::Init() {
// Read VSXE file
auto vsxe_raw = ReadFile(data_path + "00000000/00000001");
if (vsxe_raw.empty()) {
LOG_ERROR(Core, "Failed to load or decrypt VSXE");
return false;
}
DataContainer vsxe_container(std::move(vsxe_raw));
if (!vsxe_container.IsGood()) {
return false;
}
std::vector<std::vector<u8>> container_data;
if (!vsxe_container.GetIVFCLevel4Data(container_data)) {
return false;
}
const auto& vsxe = container_data[0];
// Read header
TRY(CheckedMemcpy(&header, vsxe, 0, sizeof(header)), LOG_ERROR(Core, "File size is too small"));
if (header.magic != MakeMagic('V', 'S', 'X', 'E') || header.version != 0x30000) {
LOG_ERROR(Core, "File is invalid, decryption errors may have happened.");
return false;
}
// Read filesystem information
TRY(CheckedMemcpy(&fs_info, vsxe, header.filesystem_information_offset, sizeof(fs_info)),
LOG_ERROR(Core, "File size is too small"));
// Read data region
TRY(CheckedMemcpy(data_region.data(), vsxe, fs_info.data_region_offset, data_region.size()),
LOG_ERROR(Core, "File size is too small"));
// Read directory entry table
directory_entry_table.resize(fs_info.maximum_directory_count + 2); // including head and root
const auto directory_entry_table_pos =
fs_info.data_region_offset + fs_info.directory_entry_table.duplicate.block_index *
static_cast<std::size_t>(fs_info.data_region_block_size);
TRY(CheckedMemcpy(directory_entry_table.data(), vsxe, directory_entry_table_pos,
directory_entry_table.size() * sizeof(DirectoryEntryTableEntry)),
LOG_ERROR(Core, "File size is too small"));
// Read file entry table
file_entry_table.resize(fs_info.maximum_file_count + 1); // including head
const auto file_entry_table_pos =
fs_info.data_region_offset + fs_info.file_entry_table.duplicate.block_index *
static_cast<std::size_t>(fs_info.data_region_block_size);
TRY(CheckedMemcpy(file_entry_table.data(), vsxe, file_entry_table_pos,
file_entry_table.size() * sizeof(FileEntryTableEntry)),
LOG_ERROR(Core, "File size is too small"));
// File allocation table isn't needed here, as the only files allocated by them are
// directory/file entry tables which we already read above.
return true;
}
bool SDExtdata::Extract(std::string path) const {
if (path.back() != '/' && path.back() != '\\') {
path += '/';
}
if (!ExtractDirectory(path, 1)) {
return false;
}
if (!WriteMetadata(path + "metadata")) {
return false;
}
return true;
}
bool SDExtdata::ExtractFile(const std::string& path, std::size_t index) const {
/// Maximum amount of device files a device directory can hold.
constexpr u32 DeviceDirCapacity = 126;
if (index >= file_entry_table.size()) {
LOG_ERROR(Core, "Index out of bound {}", index);
return false;
}
auto entry = file_entry_table[index];
std::array<char, 17> name_data = {}; // Append a null terminator
std::memcpy(name_data.data(), entry.name.data(), entry.name.size());
std::string name = name_data.data();
FileUtil::IOFile file(path + name, "wb");
if (!file) {
LOG_ERROR(Core, "Could not open file {}", path + name);
return false;
}
u32 file_index = index + 1;
u32 sub_directory_id = file_index / DeviceDirCapacity;
u32 sub_file_id = file_index % DeviceDirCapacity;
std::string device_file_path =
fmt::format("{}{:08x}/{:08x}", data_path, sub_directory_id, sub_file_id);
auto container_data = ReadFile(device_file_path);
if (container_data.empty()) { // File does not exist?
LOG_WARNING(Core, "Ignoring file {}", device_file_path);
return true;
}
DataContainer container(std::move(container_data));
if (!container.IsGood()) {
return false;
}
std::vector<std::vector<u8>> data;
if (!container.GetIVFCLevel4Data(data)) {
return false;
}
if (file.WriteBytes(data[0].data(), data[0].size()) != data[0].size()) {
LOG_ERROR(Core, "Write data failed (file: {})", path + name);
return false;
}
return true;
}
ArchiveFormatInfo SDExtdata::GetFormatInfo() const {
// This information is based on how Citra created the metadata in FS
ArchiveFormatInfo format_info = {/* total_size */ 0,
/* number_directories */ fs_info.maximum_directory_count,
/* number_files */ fs_info.maximum_file_count,
/* duplicate_data */ false};
return format_info;
}
} // namespace Core
-209
View File
@@ -1,209 +0,0 @@
// Copyright 2019 threeSD Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#pragma once
#include <array>
#include <type_traits>
#include <vector>
#include "common/bit_field.h"
#include "common/common_funcs.h"
#include "common/common_types.h"
#include "common/swap.h"
namespace Core {
class SDMCDecryptor;
/// Parameters of the archive, as specified in the Create or Format call.
struct ArchiveFormatInfo {
u32_le total_size; ///< The pre-defined size of the archive.
u32_le number_directories; ///< The pre-defined number of directories in the archive.
u32_le number_files; ///< The pre-defined number of files in the archive.
u8 duplicate_data; ///< Whether the archive should duplicate the data.
};
static_assert(std::is_standard_layout_v<ArchiveFormatInfo> && std::is_trivial_v<ArchiveFormatInfo>,
"ArchiveFormatInfo is not POD");
union TableOffset {
// This has different meanings for different savegame layouts
struct { // duplicate data = true
u32_le block_index;
u32_le block_count;
} duplicate;
u64_le non_duplicate; // duplicate data = false
};
struct FATHeader {
u32_le magic;
u32_le version;
u64_le filesystem_information_offset;
u64_le image_size;
u32_le image_block_size;
INSERT_PADDING_BYTES(4);
};
static_assert(sizeof(FATHeader) == 0x20, "FATHeader has incorrect size");
struct FileSystemInformation {
INSERT_PADDING_BYTES(4);
u32_le data_region_block_size;
u64_le directory_hash_table_offset;
u32_le directory_hash_table_bucket_count;
INSERT_PADDING_BYTES(4);
u64_le file_hash_table_offset;
u32_le file_hash_table_bucket_count;
INSERT_PADDING_BYTES(4);
u64_le file_allocation_table_offset;
u32_le file_allocation_table_entry_count;
INSERT_PADDING_BYTES(4);
u64_le data_region_offset;
u32_le data_region_block_count;
INSERT_PADDING_BYTES(4);
TableOffset directory_entry_table;
u32_le maximum_directory_count;
INSERT_PADDING_BYTES(4);
TableOffset file_entry_table;
u32_le maximum_file_count;
INSERT_PADDING_BYTES(4);
};
static_assert(sizeof(FileSystemInformation) == 0x68, "FileSystemInformation has incorrect size");
struct DirectoryEntryTableEntry {
u32_le parent_directory_index;
std::array<char, 16> name;
u32_le next_sibling_index;
u32_le first_subdirectory_index;
u32_le first_file_index;
INSERT_PADDING_BYTES(4);
u32_le next_hash_bucket_entry;
};
static_assert(sizeof(DirectoryEntryTableEntry) == 0x28,
"DirectoryEntryTableEntry has incorrect size");
struct FileEntryTableEntry {
u32_le parent_directory_index;
std::array<char, 16> name;
u32_le next_sibling_index;
INSERT_PADDING_BYTES(4);
u32_le data_block_index;
u64_le file_size;
INSERT_PADDING_BYTES(4);
u32_le next_hash_bucket_entry;
};
static_assert(sizeof(FileEntryTableEntry) == 0x30, "FileEntryTableEntry has incorrect size");
struct FATNode {
union {
BitField<0, 31, u32> index;
BitField<31, 1, u32> flag;
u32_le raw;
} u, v;
};
/**
* Virtual interface for the inner FAT filesystem of SD Savegames/Extdata/TitleDB.
*/
class InnerFAT {
public:
virtual ~InnerFAT();
/**
* Returns whether the filesystem is in "good" state, i.e. successfully initialized.
*/
bool IsGood() const;
/**
* Completely extracts everything from this filesystem, including files, directories
* and metadata used by Citra.
* @return true on success, false otherwise
*/
virtual bool Extract(std::string path) const = 0;
/**
* Extracts the index-th file in the file entry table to a certain path. (The path does not
* contain the file name).
* @return true on success, false otherwise
*/
virtual bool ExtractFile(const std::string& path, std::size_t index) const = 0;
/**
* Recursively extracts the index-th directory in the directory entry table.
* @return true on success, false otherwise
*/
bool ExtractDirectory(const std::string& path, std::size_t index) const;
/**
* Writes the corresponding archive metadata to a certain path.
* @return true on success, false otherwise
*/
bool WriteMetadata(const std::string& path) const;
protected:
/**
* Gets the ArchiveFormatInfo of this archive, used for writing the archive metadata.
*/
virtual ArchiveFormatInfo GetFormatInfo() const = 0;
bool is_good = false;
FATHeader header;
FileSystemInformation fs_info;
std::vector<DirectoryEntryTableEntry> directory_entry_table;
std::vector<FileEntryTableEntry> file_entry_table;
std::vector<u8> data_region;
};
class SDSavegame : public InnerFAT {
public:
explicit SDSavegame(std::vector<std::vector<u8>> partitions);
~SDSavegame() override;
bool Extract(std::string path) const override;
private:
bool Init();
bool ExtractFile(const std::string& path, std::size_t index) const override;
ArchiveFormatInfo GetFormatInfo() const override;
std::vector<FATNode> fat;
bool duplicate_data; // Layout variant
// Temporary storage for construction data
std::vector<u8> data;
std::vector<u8> partitionA;
std::vector<u8> partitionB;
};
class SDExtdata : public InnerFAT {
public:
/**
* Loads an SD extdata folder.
* @param data_path Path to the ENCRYPTED SD extdata folder, relative to decryptor root
* @param decryptor Const reference to the SDMCDecryptor.
*/
explicit SDExtdata(std::string data_path, const SDMCDecryptor& decryptor);
/**
* Loads an Extdata folder without encryption.
* @param data_path Path to the DECRYPTED extdata folder
*/
explicit SDExtdata(std::string data_path);
~SDExtdata() override;
bool Extract(std::string path) const override;
private:
std::vector<u8> ReadFile(const std::string& path) const;
bool Init();
bool ExtractFile(const std::string& path, std::size_t index) const override;
ArchiveFormatInfo GetFormatInfo() const override;
std::string data_path;
const SDMCDecryptor* decryptor = nullptr;
bool use_decryptor = true;
};
} // namespace Core
+341
View File
@@ -0,0 +1,341 @@
// Copyright 2019 threeSD Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#pragma once
#include <array>
#include <type_traits>
#include <vector>
#include "common/assert.h"
#include "common/bit_field.h"
#include "common/common_funcs.h"
#include "common/common_types.h"
#include "common/file_util.h"
#include "common/logging/log.h"
#include "common/swap.h"
namespace Core {
union TableOffset {
// This has different meanings for different savegame layouts
struct { // duplicate data = true
u32_le block_index;
u32_le block_count;
} duplicate;
u64_le non_duplicate; // duplicate data = false
};
struct FATHeader {
u32_le magic;
u32_le version;
u64_le filesystem_information_offset;
u64_le image_size;
u32_le image_block_size;
INSERT_PADDING_BYTES(4);
};
static_assert(sizeof(FATHeader) == 0x20, "FATHeader has incorrect size");
struct FileSystemInformation {
INSERT_PADDING_BYTES(4);
u32_le data_region_block_size;
u64_le directory_hash_table_offset;
u32_le directory_hash_table_bucket_count;
INSERT_PADDING_BYTES(4);
u64_le file_hash_table_offset;
u32_le file_hash_table_bucket_count;
INSERT_PADDING_BYTES(4);
u64_le file_allocation_table_offset;
u32_le file_allocation_table_entry_count;
INSERT_PADDING_BYTES(4);
u64_le data_region_offset;
u32_le data_region_block_count;
INSERT_PADDING_BYTES(4);
TableOffset directory_entry_table;
u32_le maximum_directory_count;
INSERT_PADDING_BYTES(4);
TableOffset file_entry_table;
u32_le maximum_file_count;
INSERT_PADDING_BYTES(4);
};
static_assert(sizeof(FileSystemInformation) == 0x68, "FileSystemInformation has incorrect size");
struct DirectoryEntryTableEntry {
u32_le parent_directory_index;
std::array<char, 16> name;
u32_le next_sibling_index;
u32_le first_subdirectory_index;
u32_le first_file_index;
INSERT_PADDING_BYTES(4);
u32_le next_hash_bucket_entry;
};
static_assert(sizeof(DirectoryEntryTableEntry) == 0x28,
"DirectoryEntryTableEntry has incorrect size");
struct FileEntryTableEntry {
u32_le parent_directory_index;
std::array<char, 16> name;
u32_le next_sibling_index;
INSERT_PADDING_BYTES(4);
u32_le data_block_index;
u64_le file_size;
INSERT_PADDING_BYTES(4);
u32_le next_hash_bucket_entry;
};
static_assert(sizeof(FileEntryTableEntry) == 0x30, "FileEntryTableEntry has incorrect size");
struct FATNode {
union {
BitField<0, 31, u32> index;
BitField<31, 1, u32> flag;
u32_le raw;
} u, v;
};
namespace detail {
#pragma pack(push, 1)
template <typename Preheader>
struct FullHeaderInternal {
static constexpr std::size_t PreheaderSize = sizeof(Preheader);
Preheader pre_header;
FATHeader fat_header;
};
template <>
struct FullHeaderInternal<void> {
static constexpr std::size_t PreheaderSize = 0;
FATHeader fat_header;
};
#pragma pack(pop)
template <typename Preheader>
struct FullHeaderInternal2 {
using Type = FullHeaderInternal<Preheader>;
static_assert(sizeof(Type) == sizeof(Preheader) + sizeof(FATHeader));
static_assert(std::is_standard_layout_v<Type>);
};
template <>
struct FullHeaderInternal2<void> {
using Type = FullHeaderInternal<void>;
static_assert(sizeof(Type) == sizeof(FATHeader));
static_assert(std::is_standard_layout_v<Type>);
};
} // namespace detail
template <typename Preheader = void>
using FullHeader = detail::FullHeaderInternal2<Preheader>::Type;
template <typename T, typename Preheader = void,
typename DirectoryEntryType = DirectoryEntryTableEntry,
typename FileEntryType = FileEntryTableEntry>
class InnerFAT {
protected:
bool Init(std::vector<std::vector<u8>> partitions) {
duplicate_data = partitions.size() == 1;
const auto& header_vector = partitions[0];
// Read header
TRY(CheckedMemcpy(&header, header_vector, 0, sizeof(header)),
LOG_ERROR(Core, "File size is too small"));
if (!static_cast<const T*>(this)->CheckMagic()) {
LOG_ERROR(Core, "File is invalid, decryption errors may have happened.");
return false;
}
static constexpr std::size_t PreheaderSize = FullHeader<Preheader>::PreheaderSize;
// Read filesystem information
TRY(CheckedMemcpy(&fs_info, header_vector,
PreheaderSize + header.fat_header.filesystem_information_offset,
sizeof(fs_info)),
LOG_ERROR(Core, "File size is too small"));
// Read data region
if (duplicate_data) {
data_region.resize(fs_info.data_region_block_count *
static_cast<std::size_t>(fs_info.data_region_block_size));
// This check is relaxed (not counting in PreheaderSize) for title.db
if (partitions[0].size() < fs_info.data_region_offset + data_region.size()) {
LOG_ERROR(Core, "File size is too small");
return false;
}
const auto offset = PreheaderSize + fs_info.data_region_offset;
ASSERT(partitions[0].size() > offset);
const auto to_copy = std::min(data_region.size(), partitions[0].size() - offset);
std::memcpy(data_region.data(), partitions[0].data() + offset, to_copy);
} else {
data_region = std::move(partitions[1]);
}
// Directory & file entry tables are allocated in the data region as if they were normal
// files. However, only continuous allocation has been observed so far according to 3DBrew,
// so it should be safe to directly read the bytes.
// Read directory entry table, +2 to include head and root
directory_entry_table.resize(fs_info.maximum_directory_count + 2);
auto directory_entry_table_pos =
duplicate_data ? PreheaderSize + fs_info.data_region_offset +
fs_info.directory_entry_table.duplicate.block_index *
static_cast<std::size_t>(fs_info.data_region_block_size)
: PreheaderSize + fs_info.directory_entry_table.non_duplicate;
TRY(CheckedMemcpy(directory_entry_table.data(), header_vector, directory_entry_table_pos,
directory_entry_table.size() * sizeof(DirectoryEntryType)),
LOG_ERROR(Core, "File is too small"));
// Read file entry table
file_entry_table.resize(fs_info.maximum_file_count + 1); // including head
auto file_entry_table_pos =
duplicate_data ? PreheaderSize + fs_info.data_region_offset +
fs_info.file_entry_table.duplicate.block_index *
static_cast<std::size_t>(fs_info.data_region_block_size)
: PreheaderSize + fs_info.file_entry_table.non_duplicate;
TRY(CheckedMemcpy(file_entry_table.data(), header_vector, file_entry_table_pos,
file_entry_table.size() * sizeof(FileEntryType)),
LOG_ERROR(Core, "File is too small"));
// Read file allocation table
fat.resize(fs_info.file_allocation_table_entry_count);
TRY(CheckedMemcpy(fat.data(), header_vector,
PreheaderSize + fs_info.file_allocation_table_offset,
fat.size() * sizeof(FATNode)),
LOG_ERROR(Core, "File size is too small"));
return true;
}
bool GetFileData(std::vector<u8>& out, std::size_t index) const {
if (index >= file_entry_table.size()) {
LOG_ERROR(Core, "Index out of bound {}", index);
return false;
}
auto entry = file_entry_table[index];
u32 block = entry.data_block_index;
if (block == 0x80000000) { // empty file
return true;
}
u64 file_size = entry.file_size;
if (file_size >= 64 * 1024 * 1024) {
LOG_ERROR(Core, "File size too large");
return false;
}
out.resize(file_size);
std::size_t written = 0;
while (true) {
// Entry index is block index + 1
auto block_data = fat[block + 1];
u32 last_block = block;
if (block_data.v.flag) { // This node has multiple entries
last_block = fat[block + 2].v.index - 1;
}
const std::size_t size =
static_cast<std::size_t>(fs_info.data_region_block_size) * (last_block - block + 1);
const std::size_t to_write = std::min<std::size_t>(file_size, size);
TRY(CheckedMemcpy(out.data() + written, data_region,
fs_info.data_region_block_size * block, to_write),
LOG_ERROR(Core, "File data out of bound"));
file_size -= to_write;
written += to_write;
if (block_data.v.index == 0 || file_size == 0) // last node
break;
block = block_data.v.index - 1;
}
return true;
}
bool duplicate_data;
FullHeader<Preheader> header;
FileSystemInformation fs_info;
std::vector<DirectoryEntryType> directory_entry_table;
std::vector<FileEntryType> file_entry_table;
std::vector<FATNode> fat;
std::vector<u8> data_region;
};
/// Parameters of the archive, as specified in the Create or Format call.
struct ArchiveFormatInfo {
u32_le total_size; ///< The pre-defined size of the archive.
u32_le number_directories; ///< The pre-defined number of directories in the archive.
u32_le number_files; ///< The pre-defined number of files in the archive.
u8 duplicate_data; ///< Whether the archive should duplicate the data.
};
static_assert(std::is_standard_layout_v<ArchiveFormatInfo> && std::is_trivial_v<ArchiveFormatInfo>,
"ArchiveFormatInfo is not POD");
/**
* Represents an Archive-like pack where there are directory structures.
* Has an ExtractDirectory function that recursively extracts directories.
* Expects implementor to have ExtractFile.
*/
template <typename T>
class Archive : protected InnerFAT<T> {
public:
bool ExtractDirectory(const std::string& path, std::size_t index) const {
if (index >= this->directory_entry_table.size()) {
LOG_ERROR(Core, "Index out of bound {}", index);
return false;
}
const auto& entry = this->directory_entry_table[index];
std::array<char, 17> name_data = {}; // Append a null terminator
std::memcpy(name_data.data(), entry.name.data(), entry.name.size());
std::string name = name_data.data();
std::string new_path = name.empty() ? path : path + name + "/"; // Name is empty for root
if (!FileUtil::CreateFullPath(new_path)) {
LOG_ERROR(Core, "Could not create path {}", new_path);
return false;
}
// Files
u32 cur = entry.first_file_index;
while (cur != 0) {
if (cur >= this->file_entry_table.size()) {
LOG_ERROR(Core, "Index out of bound {}", cur);
return false;
}
const auto& file_entry = this->file_entry_table[cur];
std::array<char, 17> name_data = {}; // Append a null terminator
std::memcpy(name_data.data(), file_entry.name.data(), file_entry.name.size());
if (!static_cast<const T*>(this)->ExtractFile(new_path + std::string{name_data.data()},
cur))
return false;
cur = this->file_entry_table[cur].next_sibling_index;
}
// Subdirectories
cur = entry.first_subdirectory_index;
while (cur != 0) {
if (!ExtractDirectory(new_path, cur))
return false;
cur = this->directory_entry_table[cur].next_sibling_index;
}
return true;
}
};
} // namespace Core
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2021 threeSD Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#include "core/savegame.h"
namespace Core {
Savegame::Savegame(std::vector<std::vector<u8>> partitions) {
is_good = Archive<Savegame>::Init(std::move(partitions));
}
Savegame::~Savegame() = default;
bool Savegame::CheckMagic() const {
if (header.fat_header.magic != MakeMagic('S', 'A', 'V', 'E') ||
header.fat_header.version != 0x40000) {
LOG_ERROR(Core, "File is invalid, decryption errors may have happened.");
return false;
}
return true;
}
bool Savegame::IsGood() const {
return is_good;
}
bool Savegame::ExtractFile(const std::string& path, std::size_t index) const {
std::vector<u8> data;
if (!GetFileData(data, index)) {
LOG_ERROR(Core, "Could not get file data for index {}", index);
return false;
}
return FileUtil::WriteBytesToFile(path, data.data(), data.size());
}
bool Savegame::Extract(std::string path) const {
if (path.back() != '/' && path.back() != '\\') {
path += '/';
}
// All saves on a physical 3DS are called 00000001.sav
if (!ExtractDirectory(path + "00000001/", 1)) { // Directory 1 = root
return false;
}
// Write format info
const auto format_info = GetFormatInfo();
return FileUtil::WriteBytesToFile(path + "00000001.metadata", &format_info,
sizeof(format_info));
}
ArchiveFormatInfo Savegame::GetFormatInfo() const {
// Tests on a physical 3DS shows that the `total_size` field seems to always be 0
// when requested with the UserSaveData archive, and 134328448 when requested with
// the SaveData archive. More investigation is required to tell whether this is a fixed value.
ArchiveFormatInfo format_info = {/* total_size */ 0x40000,
/* number_directories */ fs_info.maximum_directory_count,
/* number_files */ fs_info.maximum_file_count,
/* duplicate_data */ duplicate_data};
return format_info;
}
} // namespace Core
+30
View File
@@ -0,0 +1,30 @@
// Copyright 2021 threeSD Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#pragma once
#include "core/inner_fat.hpp"
namespace Core {
class Savegame final : public Archive<Savegame> {
public:
explicit Savegame(std::vector<std::vector<u8>> partitions);
~Savegame();
bool IsGood() const;
bool Extract(std::string path) const;
private:
bool CheckMagic() const;
bool ExtractFile(const std::string& path, std::size_t index) const;
ArchiveFormatInfo GetFormatInfo() const;
bool is_good = false;
friend class Archive<Savegame>;
friend class InnerFAT<Savegame>;
};
} // namespace Core
+24 -62
View File
@@ -15,14 +15,24 @@ bool TitleDB::IsGood() const {
return is_good;
}
// Note: Title DB is actually a degenerate version of the Inner FAT.
// We are simplifying things as much as possible and not actually dealing with FAT nodes.
bool TitleDB::Init(std::vector<u8> data) {
// Read header, FAT header and filesystem information
TRY(CheckedMemcpy(&header, data, 0, sizeof(header)), LOG_ERROR(Core, "File size is too small"));
if (!InnerFAT_TitleDB::Init({std::move(data)})) {
return false;
}
if (header.db_magic != MakeMagic('N', 'A', 'N', 'D', 'T', 'D', 'B', 0) &&
header.db_magic != MakeMagic('T', 'E', 'M', 'P', 'T', 'D', 'B', 0)) {
u32 cur = directory_entry_table[1].first_file_index;
while (cur != 0) {
if (!LoadTitleInfo(cur)) {
return false;
}
cur = file_entry_table[cur].next_sibling_index;
}
return true;
}
bool TitleDB::CheckMagic() const {
if (header.pre_header.db_magic != MakeMagic('N', 'A', 'N', 'D', 'T', 'D', 'B', 0) &&
header.pre_header.db_magic != MakeMagic('T', 'E', 'M', 'P', 'T', 'D', 'B', 0)) {
LOG_ERROR(Core, "File is invalid, decryption errors may have happened.");
return false;
@@ -34,70 +44,22 @@ bool TitleDB::Init(std::vector<u8> data) {
LOG_ERROR(Core, "File is invalid, decryption errors may have happened.");
return false;
}
if (header.fat_header.image_block_size != 0x80 ||
header.fs_info.data_region_block_size != 0x80) { // This simplifies things
LOG_ERROR(Core, "Unexpected block size (this may be a bug)");
return false;
}
// Read file entry table
file_entry_table.resize(header.fs_info.maximum_file_count + 1); // including head
auto file_entry_table_pos = TitleDBPreheaderSize + header.fs_info.data_region_offset +
header.fs_info.file_entry_table.duplicate.block_index *
static_cast<std::size_t>(header.fs_info.data_region_block_size);
TRY(CheckedMemcpy(file_entry_table.data(), data, file_entry_table_pos,
file_entry_table.size() * sizeof(TitleDBFileEntryTableEntry)),
LOG_ERROR(Core, "File is too small"));
// Read directory entry table for first file index
auto first_file_index_pos =
TitleDBPreheaderSize + header.fs_info.data_region_offset +
header.fs_info.directory_entry_table.duplicate.block_index *
static_cast<std::size_t>(header.fs_info.data_region_block_size) +
0x20 /* sizeof TitleDB's directory entry (to skip head) */ +
0x0C /* offset of first_file_index in directory entry of Title DB */;
if (data.size() < first_file_index_pos + 4) {
LOG_ERROR(Core, "File size is too small");
return false;
}
const u32 first_file_index = *reinterpret_cast<u32_le*>(data.data() + first_file_index_pos);
LOG_INFO(Core, "First file index is {}", first_file_index);
u32 cur = first_file_index;
while (cur != 0) {
if (!LoadTitleInfo(data, cur)) {
return false;
}
cur = file_entry_table[cur].next_sibling_index;
}
return true;
}
bool TitleDB::LoadTitleInfo(const std::vector<u8>& data, u32 index) {
auto entry = file_entry_table[index];
u32 block = entry.data_block_index;
if (block == 0x80000000) { // empty file
LOG_ERROR(Core, "Entry is an empty file");
return false;
}
u64 file_size = entry.file_size;
if (file_size != sizeof(TitleInfoEntry)) {
LOG_ERROR(Core, "Entry has incorrect size {}", file_size);
bool TitleDB::LoadTitleInfo(u32 index) {
std::vector<u8> data;
if (!GetFileData(data, index)) {
return false;
}
TitleInfoEntry title;
const auto offset = TitleDBPreheaderSize + header.fs_info.data_region_offset +
block * header.fs_info.data_region_block_size;
TRY(CheckedMemcpy(&title, data, offset, sizeof(TitleInfoEntry)),
LOG_ERROR(Core, "File size is too small"));
if (data.size() != sizeof(TitleInfoEntry)) {
LOG_ERROR(Core, "Entry {} has incorrect size", index);
}
std::memcpy(&title, data.data(), data.size());
titles.emplace(entry.title_id, title);
titles.emplace(file_entry_table[index].title_id, title);
return true;
}
+23 -12
View File
@@ -10,22 +10,28 @@
#include "common/common_funcs.h"
#include "common/common_types.h"
#include "common/swap.h"
#include "core/inner_fat.h"
#include "core/inner_fat.hpp"
namespace Core {
struct TitleDBHeader {
struct TitleDBPreheader {
u64_le db_magic;
INSERT_PADDING_BYTES(0x78);
FATHeader fat_header;
FileSystemInformation fs_info;
};
constexpr std::size_t TitleDBPreheaderSize = 0x80;
static_assert(sizeof(TitleDBHeader) ==
TitleDBPreheaderSize + sizeof(FATHeader) + sizeof(FileSystemInformation),
"TitleDB preheader has incorrect size");
static_assert(sizeof(TitleDBPreheader) == 0x80, "TitleDB pre-header has incorrect size");
#pragma pack(push, 1)
struct TitleDBDirectoryEntryTableEntry {
u32_le parent_directory_index;
u32_le next_sibling_index;
u32_le first_subdirectory_index;
u32_le first_file_index;
INSERT_PADDING_BYTES(12);
u32_le next_hash_bucket_entry;
};
static_assert(sizeof(TitleDBDirectoryEntryTableEntry) == 0x20,
"TitleDBDirectoryEntryTableEntry has incorrect size");
struct TitleDBFileEntryTableEntry {
u32_le parent_directory_index;
u64_le title_id;
@@ -56,7 +62,11 @@ struct TitleInfoEntry {
};
static_assert(sizeof(TitleInfoEntry) == 0x80, "TitleInfoEntry has incorrect size");
class TitleDB {
class TitleDB;
using InnerFAT_TitleDB = InnerFAT<TitleDB, TitleDBPreheader, TitleDBDirectoryEntryTableEntry,
TitleDBFileEntryTableEntry>;
class TitleDB final : public InnerFAT_TitleDB {
public:
explicit TitleDB(std::vector<u8> data);
bool IsGood() const;
@@ -65,11 +75,12 @@ public:
private:
bool Init(std::vector<u8> data);
bool LoadTitleInfo(const std::vector<u8>& data, u32 index);
bool CheckMagic() const;
bool LoadTitleInfo(u32 index);
bool is_good = false;
TitleDBHeader header;
std::vector<TitleDBFileEntryTableEntry> file_entry_table;
friend InnerFAT_TitleDB;
};
} // namespace Core
+5 -4
View File
@@ -10,9 +10,10 @@
#include <QtConcurrent/QtConcurrentRun>
#include "core/data_container.h"
#include "core/decryptor.h"
#include "core/inner_fat.h"
#include "core/extdata.h"
#include "core/key/key.h"
#include "core/ncch/ncch_container.h"
#include "core/savegame.h"
#include "frontend/select_files_dialog.h"
#include "frontend/utilities.h"
#include "ui_utilities.h"
@@ -211,7 +212,7 @@ void UtilitiesDialog::SaveDataExtractionTool() {
return false;
}
Core::SDSavegame save(std::move(container_data));
Core::Savegame save(std::move(container_data));
if (!save.IsGood()) {
return false;
}
@@ -237,7 +238,7 @@ void UtilitiesDialog::SaveDataExtractionTool() {
return false;
}
Core::SDSavegame save(std::move(container_data));
Core::Savegame save(std::move(container_data));
if (!save.IsGood()) {
return false;
}
@@ -264,7 +265,7 @@ void UtilitiesDialog::ExtdataExtractionTool() {
ShowProgressDialog(
[sdmc_root = sdmc_root, relative_source = relative_source, destination = destination] {
Core::SDMCDecryptor decryptor(sdmc_root);
Core::SDExtdata extdata(relative_source, decryptor);
Core::Extdata extdata(relative_source, decryptor);
if (!extdata.IsGood()) {
return false;
}