IOS: persist Starlet NAND writes across restarts
Keep the source dump immutable and replay checksummed copy-on-write transactions from the active user profile. Persist program, erase, pairing and restored snapshot writes. Preserve interrupted tails before recovery and reject corrupt journals or concurrent writers. Validated in the current build with 127 targeted tests and a user-confirmed channel deletion surviving process exit and restart; 2944 saved NAND pages reloaded successfully.
This commit is contained in:
@@ -404,6 +404,8 @@ add_library(core
|
||||
IOS/MIOS.h
|
||||
IOS/Starlet/ARMCore.cpp
|
||||
IOS/Starlet/ARMCore.h
|
||||
IOS/Starlet/NANDJournal.cpp
|
||||
IOS/Starlet/NANDJournal.h
|
||||
IOS/Starlet/Starlet.cpp
|
||||
IOS/Starlet/Starlet.h
|
||||
IOS/Starlet/StarletMemory.cpp
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright 2026 Dolphin Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include "Core/IOS/Starlet/NANDJournal.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <sys/file.h>
|
||||
#endif
|
||||
|
||||
#include "Common/FileUtil.h"
|
||||
#include "Common/Logging/Log.h"
|
||||
|
||||
namespace IOS::LLE
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr std::array<u8, 8> MAGIC{'D', 'L', 'N', 'A', 'N', 'D', '0', '1'};
|
||||
constexpr u32 TRANSACTION_MAGIC = 0x314e5854; // TXN1, little endian
|
||||
constexpr size_t HEADER_SIZE = 32;
|
||||
constexpr size_t TRANSACTION_HEADER_SIZE = 12;
|
||||
constexpr size_t ENTRY_SIZE = 4 + NANDJournal::PAGE_SIZE;
|
||||
|
||||
void Put32(std::vector<u8>& bytes, u32 value)
|
||||
{
|
||||
for (unsigned shift = 0; shift < 32; shift += 8)
|
||||
bytes.push_back(static_cast<u8>(value >> shift));
|
||||
}
|
||||
|
||||
u32 Get32(std::span<const u8> bytes)
|
||||
{
|
||||
return u32(bytes[0]) | (u32(bytes[1]) << 8) | (u32(bytes[2]) << 16) | (u32(bytes[3]) << 24);
|
||||
}
|
||||
|
||||
std::vector<u8> MakeHeader(const Common::SHA1::Digest& digest)
|
||||
{
|
||||
std::vector<u8> header(MAGIC.begin(), MAGIC.end());
|
||||
header.insert(header.end(), digest.begin(), digest.end());
|
||||
Put32(header, NANDJournal::PAGE_COUNT);
|
||||
return header;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool NANDJournal::Open(const std::string& path, const Common::SHA1::Digest& source_digest,
|
||||
Pages* pages, std::string* error)
|
||||
{
|
||||
const auto fail = [&](const std::string& message) {
|
||||
if (error)
|
||||
*error = message + ": " + path;
|
||||
m_file.Close();
|
||||
return false;
|
||||
};
|
||||
const auto header = MakeHeader(source_digest);
|
||||
// Exclusive creation avoids truncating a journal created concurrently by another instance.
|
||||
if (!File::Exists(path))
|
||||
{
|
||||
File::IOFile created(path, "wbx");
|
||||
if (created && (!created.WriteArray(header.data(), header.size()) || !created.Flush()))
|
||||
return fail("Could not initialize the persistent NAND journal");
|
||||
}
|
||||
if (!m_file.Open(path, "r+b", File::SharedAccess::Read))
|
||||
return fail("Could not open the NAND journal for writing (another instance may be using it)");
|
||||
#ifndef _WIN32
|
||||
if (flock(fileno(m_file.GetHandle()), LOCK_EX | LOCK_NB) != 0)
|
||||
return fail("Another instance is using this NAND journal");
|
||||
#endif
|
||||
|
||||
const u64 size = m_file.GetSize();
|
||||
std::array<u8, HEADER_SIZE> actual_header{};
|
||||
if (size < HEADER_SIZE || !m_file.ReadArray(&actual_header) ||
|
||||
!std::ranges::equal(actual_header, header))
|
||||
return fail("NAND journal header is invalid or belongs to a different source dump");
|
||||
|
||||
Pages loaded;
|
||||
u64 position = HEADER_SIZE;
|
||||
while (position < size)
|
||||
{
|
||||
if (size - position < TRANSACTION_HEADER_SIZE)
|
||||
break; // An interrupted final transaction is never applied.
|
||||
std::array<u8, TRANSACTION_HEADER_SIZE> transaction_header{};
|
||||
if (!m_file.ReadArray(&transaction_header))
|
||||
return fail("Could not read the NAND journal");
|
||||
const u32 flags = Get32(std::span(transaction_header).subspan(4));
|
||||
const u32 count = Get32(std::span(transaction_header).subspan(8));
|
||||
if (Get32(transaction_header) != TRANSACTION_MAGIC || flags > 1 || count > PAGE_COUNT)
|
||||
return fail("NAND journal contains an invalid transaction header");
|
||||
const u64 record_size =
|
||||
TRANSACTION_HEADER_SIZE + u64(count) * ENTRY_SIZE + Common::SHA1::DIGEST_LEN;
|
||||
if (record_size > size - position)
|
||||
break;
|
||||
std::vector<u8> record(transaction_header.begin(), transaction_header.end());
|
||||
record.resize(static_cast<size_t>(record_size - Common::SHA1::DIGEST_LEN));
|
||||
Common::SHA1::Digest checksum{};
|
||||
if (!m_file.ReadBytes(record.data() + TRANSACTION_HEADER_SIZE,
|
||||
record.size() - TRANSACTION_HEADER_SIZE) ||
|
||||
!m_file.ReadArray(&checksum))
|
||||
return fail("Could not read a NAND journal transaction");
|
||||
if (Common::SHA1::CalculateDigest(record) != checksum)
|
||||
return fail("NAND journal checksum mismatch; the file has been preserved");
|
||||
Pages transaction;
|
||||
for (u32 i = 0; i < count; ++i)
|
||||
{
|
||||
const size_t offset = TRANSACTION_HEADER_SIZE + size_t(i) * ENTRY_SIZE;
|
||||
const u32 page = Get32(std::span(record).subspan(offset));
|
||||
if (page >= PAGE_COUNT || transaction.contains(page))
|
||||
return fail("NAND journal contains an invalid or duplicate page");
|
||||
Page data;
|
||||
std::copy_n(record.data() + offset + 4, PAGE_SIZE, data.begin());
|
||||
transaction.emplace(page, std::move(data));
|
||||
}
|
||||
if (flags == 1)
|
||||
loaded.clear();
|
||||
for (auto& [page, data] : transaction)
|
||||
loaded.insert_or_assign(page, std::move(data));
|
||||
position += record_size;
|
||||
}
|
||||
if (position != size)
|
||||
{
|
||||
// Preserve the interrupted bytes for recovery before removing only the incomplete tail.
|
||||
std::string backup = path + ".interrupted";
|
||||
for (unsigned i = 1; File::Exists(backup); ++i)
|
||||
backup = path + ".interrupted." + std::to_string(i);
|
||||
if (!File::Copy(path, backup) || !m_file.Resize(position))
|
||||
return fail("Could not preserve and recover an interrupted NAND transaction");
|
||||
WARN_LOG_FMT(IOS, "Recovered NAND journal through byte {}; interrupted file preserved at {}",
|
||||
position, backup);
|
||||
}
|
||||
if (!m_file.Seek(static_cast<s64>(position), File::SeekOrigin::Begin))
|
||||
return fail("Could not seek the NAND journal");
|
||||
*pages = std::move(loaded);
|
||||
INFO_LOG_FMT(IOS, "Loaded {} persistent NAND pages from {}", pages->size(), path);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NANDJournal::Append(const Pages& pages, bool replace)
|
||||
{
|
||||
if (!m_file || pages.size() > PAGE_COUNT)
|
||||
return false;
|
||||
if (pages.empty() && !replace)
|
||||
return true;
|
||||
std::vector<u8> record;
|
||||
record.reserve(TRANSACTION_HEADER_SIZE + pages.size() * ENTRY_SIZE);
|
||||
Put32(record, TRANSACTION_MAGIC);
|
||||
Put32(record, replace ? 1 : 0);
|
||||
Put32(record, static_cast<u32>(pages.size()));
|
||||
for (const auto& [page, data] : pages)
|
||||
{
|
||||
if (page >= PAGE_COUNT)
|
||||
return false;
|
||||
Put32(record, page);
|
||||
record.insert(record.end(), data.begin(), data.end());
|
||||
}
|
||||
const auto checksum = Common::SHA1::CalculateDigest(record);
|
||||
if (!m_file.WriteArray(record.data(), record.size()) || !m_file.WriteArray(checksum) ||
|
||||
!m_file.Flush())
|
||||
{
|
||||
// Never acknowledge volatile writes as successful after a storage error.
|
||||
ERROR_LOG_FMT(IOS, "Persistent NAND write failed; subsequent NAND writes are disabled");
|
||||
m_file.Close();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} // namespace IOS::LLE
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 Dolphin Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
#include "Common/CommonTypes.h"
|
||||
#include "Common/Crypto/SHA1.h"
|
||||
#include "Common/IOFile.h"
|
||||
|
||||
namespace IOS::LLE
|
||||
{
|
||||
// Persistent copy-on-write pages. The console's source dump is never opened for writing.
|
||||
// A complete checksummed transaction is flushed before a NAND command reports success.
|
||||
class NANDJournal final
|
||||
{
|
||||
public:
|
||||
static constexpr u32 PAGE_SIZE = 0x840;
|
||||
static constexpr u32 PAGE_COUNT = 0x40000;
|
||||
using Page = std::array<u8, PAGE_SIZE>;
|
||||
using Pages = std::map<u32, Page>;
|
||||
|
||||
bool Open(const std::string& path, const Common::SHA1::Digest& source_digest, Pages* pages,
|
||||
std::string* error);
|
||||
bool Append(const Pages& pages, bool replace = false);
|
||||
|
||||
private:
|
||||
File::IOFile m_file;
|
||||
};
|
||||
} // namespace IOS::LLE
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "Common/Crypto/HMAC.h"
|
||||
#include "Common/FileUtil.h"
|
||||
#include "Common/Logging/Log.h"
|
||||
#include "Common/MsgHandler.h"
|
||||
#include "Common/SDCardUtil.h"
|
||||
#include "Common/StringUtil.h"
|
||||
#include "Core/Config/MainSettings.h"
|
||||
@@ -467,6 +468,8 @@ StarletMemory::StarletMemory(Core::System& system) : m_system(system)
|
||||
|
||||
bool StarletMemory::Init(const std::string& dump_directory, std::string* error)
|
||||
{
|
||||
m_nand_journal.reset();
|
||||
m_nand_overlay.clear();
|
||||
const std::string boot_path = PathInDirectory(dump_directory, "boot0.bin");
|
||||
const std::string keys_path = PathInDirectory(dump_directory, "keys.bin");
|
||||
const std::string nand_path = PathInDirectory(dump_directory, "nand.bin");
|
||||
@@ -522,6 +525,36 @@ bool StarletMemory::Init(const std::string& dump_directory, std::string* error)
|
||||
}
|
||||
}
|
||||
|
||||
// Bind saved pages to the complete immutable source dump. Different profiles and different
|
||||
// source dumps must not silently share or replay each other's NAND writes.
|
||||
auto hash = Common::SHA1::CreateContext();
|
||||
std::array<u8, 0x10000> buffer{};
|
||||
if (!m_nand.Seek(0, File::SeekOrigin::Begin))
|
||||
return false;
|
||||
for (u64 offset = 0; offset < nand_size; offset += buffer.size())
|
||||
{
|
||||
const size_t count = static_cast<size_t>(std::min<u64>(buffer.size(), nand_size - offset));
|
||||
if (!m_nand.ReadArray(buffer.data(), count))
|
||||
{
|
||||
if (error)
|
||||
*error = "could not fingerprint the source NAND dump";
|
||||
return false;
|
||||
}
|
||||
hash->Update(buffer.data(), count);
|
||||
}
|
||||
const auto source_digest = hash->Finish();
|
||||
const std::string journal_path = File::GetUserPath(D_USER_IDX) + "Starlet/" +
|
||||
Common::SHA1::DigestToString(source_digest) + ".nand-journal";
|
||||
if (!File::CreateFullPath(journal_path))
|
||||
{
|
||||
if (error)
|
||||
*error = "could not create the persistent Starlet NAND directory";
|
||||
return false;
|
||||
}
|
||||
m_nand_journal = std::make_unique<NANDJournal>();
|
||||
if (!m_nand_journal->Open(journal_path, source_digest, &m_nand_overlay, error))
|
||||
return false;
|
||||
|
||||
// Pairing identities must be selected before WiimoteDevice installs callbacks
|
||||
// on the input sources. Constructing the synthetic devices in Reset and
|
||||
// replacing them immediately after reading BT.DINF leaves a short-lived
|
||||
@@ -582,7 +615,7 @@ void StarletMemory::Reset()
|
||||
m_sram.fill(0);
|
||||
m_registers.clear();
|
||||
ClearRegisterCache();
|
||||
m_nand_overlay.clear();
|
||||
// A hardware reset does not erase nonvolatile NAND pages.
|
||||
m_nand_control_before_write = 0;
|
||||
ResetNANDOperationState();
|
||||
m_aes_key.fill(0);
|
||||
@@ -756,6 +789,12 @@ void StarletMemory::DoState(PointerWrap& p)
|
||||
p.Do(m_initialized);
|
||||
p.Do(m_boot0_mapped);
|
||||
p.Do(m_sram_split_mode);
|
||||
if (p.IsReadMode() && m_nand_journal && !m_nand_journal->Append(m_nand_overlay, true))
|
||||
{
|
||||
PanicAlertFmtT("Could not persist the NAND restored from the savestate. "
|
||||
"Further NAND writes are disabled; check disk space and permissions.");
|
||||
p.SetVerifyMode();
|
||||
}
|
||||
}
|
||||
|
||||
u32 StarletMemory::GetTimer() const
|
||||
@@ -3750,6 +3789,15 @@ bool StarletMemory::ReadRawNANDPage(u32 page, NANDPage* raw)
|
||||
m_nand.ReadArray(raw);
|
||||
}
|
||||
|
||||
bool StarletMemory::CommitNANDPages(const NANDJournal::Pages& pages)
|
||||
{
|
||||
if (m_nand_journal && !m_nand_journal->Append(pages))
|
||||
return false;
|
||||
for (const auto& [page, data] : pages)
|
||||
m_nand_overlay.insert_or_assign(page, data);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool StarletMemory::InstallEmulatedWiimotePairings()
|
||||
{
|
||||
using NANDSuperblock = DiscIO::NANDImporter::NANDSuperblock;
|
||||
@@ -3961,9 +4009,10 @@ bool StarletMemory::InstallEmulatedWiimotePairings()
|
||||
std::ranges::copy(pairing, sysconf.begin() + static_cast<ptrdiff_t>(*pairing_offset));
|
||||
|
||||
// Re-encrypt only the file's SFFS clusters and place their raw pages in the
|
||||
// session overlay. Preserve all spare metadata from the dump and replace only
|
||||
// persistent overlay. Preserve all spare metadata from the dump and replace only
|
||||
// the ECC for the modified ciphertext.
|
||||
size_t file_offset = 0;
|
||||
NANDJournal::Pages pairing_pages;
|
||||
for (size_t cluster_number = 0; cluster_number < cluster_indices.size(); ++cluster_number)
|
||||
{
|
||||
auto& plaintext = decrypted_clusters[cluster_number];
|
||||
@@ -4017,11 +4066,13 @@ bool StarletMemory::InstallEmulatedWiimotePairings()
|
||||
std::ranges::copy(hmac, raw.begin() + NAND_PAGE_DATA_SIZE + 0x0c);
|
||||
const auto ecc = CalculateNANDECC(raw.data());
|
||||
std::ranges::copy(ecc, raw.begin() + NAND_PAGE_DATA_SIZE + 0x30);
|
||||
m_nand_overlay.insert_or_assign(page, raw);
|
||||
pairing_pages.insert_or_assign(page, raw);
|
||||
}
|
||||
}
|
||||
|
||||
INFO_LOG_FMT(IOS_WIIMOTE, "Installed emulated Wii Remote pairings in the raw "
|
||||
if (!CommitNANDPages(pairing_pages))
|
||||
return false;
|
||||
INFO_LOG_FMT(IOS_WIIMOTE, "Installed emulated Wii Remote pairings in the persistent "
|
||||
"NAND copy-on-write overlay");
|
||||
return true;
|
||||
}
|
||||
@@ -4144,8 +4195,8 @@ bool StarletMemory::CommitNANDProgram()
|
||||
return false;
|
||||
|
||||
NANDPage raw{};
|
||||
const bool read_succeeded = ReadRawNANDPage(m_nand_program_page, &raw);
|
||||
if (read_succeeded)
|
||||
bool succeeded = ReadRawNANDPage(m_nand_program_page, &raw);
|
||||
if (succeeded)
|
||||
{
|
||||
// ECC-enabled page programming calculates the syndrome from the page data
|
||||
// and places it in the final 16 bytes of the NAND spare area. IOS supplies
|
||||
@@ -4160,7 +4211,10 @@ bool StarletMemory::CommitNANDProgram()
|
||||
// to restore it.
|
||||
for (u32 i = 0; i < NAND_RAW_PAGE_SIZE; ++i)
|
||||
raw[i] &= m_nand_program_data[i];
|
||||
m_nand_overlay.insert_or_assign(m_nand_program_page, raw);
|
||||
succeeded = CommitNANDPages({{m_nand_program_page, raw}});
|
||||
}
|
||||
if (succeeded)
|
||||
{
|
||||
m_nand_status &= ~NAND_STATUS_FAIL;
|
||||
}
|
||||
else
|
||||
@@ -4170,7 +4224,7 @@ bool StarletMemory::CommitNANDProgram()
|
||||
|
||||
m_nand_program_pending = false;
|
||||
m_nand_program_ecc_enabled = false;
|
||||
return read_succeeded;
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
bool StarletMemory::StageNANDErase()
|
||||
@@ -4190,10 +4244,16 @@ bool StarletMemory::CommitNANDErase()
|
||||
const u32 block_start = m_nand_erase_page & ~(NAND_PAGES_PER_BLOCK - 1);
|
||||
NANDPage erased_page{};
|
||||
erased_page.fill(0xff);
|
||||
NANDJournal::Pages erased_pages;
|
||||
for (u32 page = block_start; page < block_start + NAND_PAGES_PER_BLOCK; ++page)
|
||||
m_nand_overlay.insert_or_assign(page, erased_page);
|
||||
erased_pages.emplace(page, erased_page);
|
||||
|
||||
m_nand_erase_pending = false;
|
||||
if (!CommitNANDPages(erased_pages))
|
||||
{
|
||||
m_nand_status |= NAND_STATUS_FAIL;
|
||||
return false;
|
||||
}
|
||||
m_nand_status &= ~NAND_STATUS_FAIL;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "Common/CommonTypes.h"
|
||||
#include "Common/IOFile.h"
|
||||
#include "Core/IOS/Starlet/ARMCore.h"
|
||||
#include "Core/IOS/Starlet/NANDJournal.h"
|
||||
#include "Core/IOS/USB/Bluetooth/WiimoteDevice.h"
|
||||
|
||||
class PointerWrap;
|
||||
@@ -29,8 +30,8 @@ class System;
|
||||
namespace IOS::LLE
|
||||
{
|
||||
// Starlet's physical address space and the first hardware devices required by the immutable Wii
|
||||
// boot ROM. NAND is deliberately opened read-only; program/erase commands use a copy-on-write
|
||||
// overlay so an experimental LLE session can never modify the user's console backup.
|
||||
// boot ROM. The source NAND stays read-only; program/erase commands are persisted in a
|
||||
// checksummed copy-on-write journal under the active user directory.
|
||||
class StarletMemory final : public ARMBus, public IOS::HLE::WiimoteDeviceHost
|
||||
{
|
||||
public:
|
||||
@@ -168,6 +169,7 @@ private:
|
||||
void HandleNANDCommand(u32 command);
|
||||
void CompleteNANDCommand(u32 command);
|
||||
bool ReadRawNANDPage(u32 page, NANDPage* raw);
|
||||
bool CommitNANDPages(const NANDJournal::Pages& pages);
|
||||
bool InstallEmulatedWiimotePairings();
|
||||
bool ReadNANDPage(u32 command);
|
||||
bool ReadNANDID(u32 command);
|
||||
@@ -303,5 +305,7 @@ private:
|
||||
bool m_initialized = false;
|
||||
bool m_boot0_mapped = true;
|
||||
bool m_sram_split_mode = false;
|
||||
// Host persistence is not serialized; savestates contain the complete overlay above.
|
||||
std::unique_ptr<NANDJournal> m_nand_journal;
|
||||
};
|
||||
} // namespace IOS::LLE
|
||||
|
||||
@@ -15,6 +15,7 @@ add_dolphin_test(DSPAssemblyTest
|
||||
add_dolphin_test(ESFormatsTest IOS/ES/FormatsTest.cpp)
|
||||
|
||||
add_dolphin_test(StarletARMCoreTest IOS/Starlet/ARMCoreTest.cpp)
|
||||
add_dolphin_test(StarletNANDJournalTest IOS/Starlet/NANDJournalTest.cpp)
|
||||
|
||||
add_dolphin_test(FileSystemTest IOS/FS/FileSystemTest.cpp)
|
||||
|
||||
|
||||
@@ -394,6 +394,38 @@ TEST(StarletTimer, ZeroDelayAlarmMatchesImmediatelyAndUsesIRQW1C)
|
||||
EXPECT_EQ(system.GetWiiIPC().ReadStarletRegister(0x38) & INT_CAUSE_TIMER, INT_CAUSE_TIMER);
|
||||
}
|
||||
|
||||
TEST(StarletNAND, HardwareResetPreservesProgrammedFlash)
|
||||
{
|
||||
Core::DeclareAsCPUThread();
|
||||
auto& system = Core::System::GetInstance();
|
||||
StarletMemory memory(system);
|
||||
memory.Reset();
|
||||
constexpr u32 control = 0x0d010000;
|
||||
constexpr u32 addr2 = 0x0d01000c;
|
||||
constexpr u32 data = 0x0d010010;
|
||||
constexpr u32 sram = StarletMemory::SRAM_BASE;
|
||||
memory.Write32(addr2, 64);
|
||||
memory.Write32(control, 0x80600000); // Erase setup.
|
||||
memory.Write32(control, 0x80d00000);
|
||||
memory.AdvanceCycles(400000);
|
||||
ASSERT_EQ(memory.Read32(control) & (1u << 29), 0u);
|
||||
memory.Write32(sram, 0x12345678);
|
||||
memory.Write32(data, sram);
|
||||
memory.Write32(control, 0x80804004); // Stage four bytes.
|
||||
memory.Write32(control, 0x80100000);
|
||||
memory.AdvanceCycles(100000);
|
||||
ASSERT_EQ(memory.Read32(control) & (1u << 29), 0u);
|
||||
|
||||
memory.Reset();
|
||||
memory.Write32(addr2, 64);
|
||||
memory.Write32(data, sram);
|
||||
memory.Write32(control, 0x80000000);
|
||||
memory.Write32(control, 0x80302004);
|
||||
memory.AdvanceCycles(10000);
|
||||
EXPECT_EQ(memory.Read32(control) & (1u << 29), 0u);
|
||||
EXPECT_EQ(memory.Read32(sram), 0x12345678u);
|
||||
}
|
||||
|
||||
TEST(StarletGPIO, InterruptFlagIsWriteOneToClear)
|
||||
{
|
||||
constexpr u32 hardware_base = 0x0d800000;
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// Copyright 2026 Dolphin Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <array>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "Common/FileUtil.h"
|
||||
#include "Common/IOFile.h"
|
||||
#include "Core/IOS/Starlet/NANDJournal.h"
|
||||
|
||||
using IOS::LLE::NANDJournal;
|
||||
|
||||
class StarletNANDJournal : public testing::Test
|
||||
{
|
||||
protected:
|
||||
void SetUp() override
|
||||
{
|
||||
m_directory = File::CreateTempDir();
|
||||
ASSERT_FALSE(m_directory.empty());
|
||||
m_path = m_directory + "/nand.journal";
|
||||
}
|
||||
void TearDown() override
|
||||
{
|
||||
// Only the unique directory created by this fixture is removed.
|
||||
if (!m_directory.empty())
|
||||
File::DeleteDirRecursively(m_directory);
|
||||
}
|
||||
static NANDJournal::Page Page(u8 fill)
|
||||
{
|
||||
NANDJournal::Page page;
|
||||
page.fill(fill);
|
||||
return page;
|
||||
}
|
||||
std::string m_directory;
|
||||
std::string m_path;
|
||||
const Common::SHA1::Digest m_digest = Common::SHA1::CalculateDigest("test source NAND");
|
||||
};
|
||||
|
||||
TEST_F(StarletNANDJournal, ProgramAndEraseSurviveReopen)
|
||||
{
|
||||
NANDJournal::Pages expected{{7, Page(0xa5)}, {9, Page(0xff)}};
|
||||
NANDJournal::Pages loaded;
|
||||
std::string error;
|
||||
{
|
||||
NANDJournal journal;
|
||||
ASSERT_TRUE(journal.Open(m_path, m_digest, &loaded, &error)) << error;
|
||||
EXPECT_TRUE(loaded.empty());
|
||||
ASSERT_TRUE(journal.Append({{7, Page(0xfe)}, {9, Page(0x55)}}));
|
||||
ASSERT_TRUE(journal.Append(expected));
|
||||
}
|
||||
NANDJournal reopened;
|
||||
ASSERT_TRUE(reopened.Open(m_path, m_digest, &loaded, &error)) << error;
|
||||
EXPECT_EQ(loaded, expected);
|
||||
}
|
||||
|
||||
TEST_F(StarletNANDJournal, SpareBytesAndHighestPagePersist)
|
||||
{
|
||||
auto raw = Page(0xff);
|
||||
raw[0x800] = 0x13;
|
||||
raw.back() = 0x42;
|
||||
NANDJournal::Pages loaded;
|
||||
std::string error;
|
||||
{
|
||||
NANDJournal journal;
|
||||
ASSERT_TRUE(journal.Open(m_path, m_digest, &loaded, &error));
|
||||
ASSERT_TRUE(journal.Append({{NANDJournal::PAGE_COUNT - 1, raw}}));
|
||||
}
|
||||
NANDJournal reopened;
|
||||
ASSERT_TRUE(reopened.Open(m_path, m_digest, &loaded, &error)) << error;
|
||||
ASSERT_EQ(loaded.size(), 1u);
|
||||
EXPECT_EQ(loaded.at(NANDJournal::PAGE_COUNT - 1), raw);
|
||||
}
|
||||
|
||||
TEST_F(StarletNANDJournal, SnapshotReplacesRatherThanMergesLaterWrites)
|
||||
{
|
||||
NANDJournal::Pages snapshot{{8, Page(0x12)}};
|
||||
NANDJournal::Pages loaded;
|
||||
std::string error;
|
||||
{
|
||||
NANDJournal journal;
|
||||
ASSERT_TRUE(journal.Open(m_path, m_digest, &loaded, &error));
|
||||
ASSERT_TRUE(journal.Append({{8, Page(0x77)}, {10, Page(0x88)}}));
|
||||
ASSERT_TRUE(journal.Append(snapshot, true));
|
||||
}
|
||||
NANDJournal reopened;
|
||||
ASSERT_TRUE(reopened.Open(m_path, m_digest, &loaded, &error));
|
||||
EXPECT_EQ(loaded, snapshot);
|
||||
}
|
||||
|
||||
TEST_F(StarletNANDJournal, EmptySnapshotRestoresSourceView)
|
||||
{
|
||||
NANDJournal::Pages loaded;
|
||||
std::string error;
|
||||
{
|
||||
NANDJournal journal;
|
||||
ASSERT_TRUE(journal.Open(m_path, m_digest, &loaded, &error));
|
||||
ASSERT_TRUE(journal.Append({{0, Page(0)}}));
|
||||
ASSERT_TRUE(journal.Append({}, true));
|
||||
}
|
||||
NANDJournal reopened;
|
||||
ASSERT_TRUE(reopened.Open(m_path, m_digest, &loaded, &error));
|
||||
EXPECT_TRUE(loaded.empty());
|
||||
}
|
||||
|
||||
TEST_F(StarletNANDJournal, RejectsDifferentSourceWithoutChangingData)
|
||||
{
|
||||
NANDJournal::Pages loaded;
|
||||
std::string error;
|
||||
{
|
||||
NANDJournal journal;
|
||||
ASSERT_TRUE(journal.Open(m_path, m_digest, &loaded, &error));
|
||||
ASSERT_TRUE(journal.Append({{0, Page(0x12)}}));
|
||||
}
|
||||
const auto size = File::GetSize(m_path);
|
||||
NANDJournal wrong_source;
|
||||
EXPECT_FALSE(wrong_source.Open(m_path, {}, &loaded, &error));
|
||||
EXPECT_FALSE(error.empty());
|
||||
EXPECT_EQ(File::GetSize(m_path), size);
|
||||
NANDJournal correct_source;
|
||||
ASSERT_TRUE(correct_source.Open(m_path, m_digest, &loaded, &error));
|
||||
EXPECT_EQ(loaded.at(0), Page(0x12));
|
||||
}
|
||||
|
||||
TEST_F(StarletNANDJournal, RejectsInvalidPageWithoutAppending)
|
||||
{
|
||||
NANDJournal journal;
|
||||
NANDJournal::Pages loaded;
|
||||
std::string error;
|
||||
ASSERT_TRUE(journal.Open(m_path, m_digest, &loaded, &error));
|
||||
const auto size = File::GetSize(m_path);
|
||||
EXPECT_FALSE(journal.Append({{NANDJournal::PAGE_COUNT, Page(0)}}));
|
||||
EXPECT_EQ(File::GetSize(m_path), size);
|
||||
EXPECT_TRUE(journal.Append({{1, Page(0)}}));
|
||||
}
|
||||
|
||||
TEST_F(StarletNANDJournal, RejectsSimultaneousWriter)
|
||||
{
|
||||
NANDJournal first;
|
||||
NANDJournal second;
|
||||
NANDJournal::Pages loaded;
|
||||
std::string error;
|
||||
ASSERT_TRUE(first.Open(m_path, m_digest, &loaded, &error));
|
||||
EXPECT_FALSE(second.Open(m_path, m_digest, &loaded, &error));
|
||||
EXPECT_TRUE(first.Append({{1, Page(0)}}));
|
||||
}
|
||||
|
||||
TEST_F(StarletNANDJournal, PreservesInterruptedTailAndReplaysOnlyCommittedPages)
|
||||
{
|
||||
NANDJournal::Pages loaded;
|
||||
std::string error;
|
||||
u64 committed_size;
|
||||
{
|
||||
NANDJournal journal;
|
||||
ASSERT_TRUE(journal.Open(m_path, m_digest, &loaded, &error));
|
||||
ASSERT_TRUE(journal.Append({{1, Page(0x11)}}));
|
||||
committed_size = File::GetSize(m_path);
|
||||
ASSERT_TRUE(journal.Append({{2, Page(0x22)}, {3, Page(0x33)}}));
|
||||
}
|
||||
{
|
||||
File::IOFile interrupted(m_path, "r+b");
|
||||
ASSERT_TRUE(interrupted.Resize(interrupted.GetSize() - 5));
|
||||
}
|
||||
const auto interrupted_size = File::GetSize(m_path);
|
||||
{
|
||||
NANDJournal recovered;
|
||||
ASSERT_TRUE(recovered.Open(m_path, m_digest, &loaded, &error)) << error;
|
||||
EXPECT_EQ(loaded, (NANDJournal::Pages{{1, Page(0x11)}}));
|
||||
EXPECT_EQ(File::GetSize(m_path), committed_size);
|
||||
EXPECT_EQ(File::GetSize(m_path + ".interrupted"), interrupted_size);
|
||||
ASSERT_TRUE(recovered.Append({{4, Page(0x44)}}));
|
||||
}
|
||||
NANDJournal reopened;
|
||||
ASSERT_TRUE(reopened.Open(m_path, m_digest, &loaded, &error));
|
||||
EXPECT_EQ(loaded, (NANDJournal::Pages{{1, Page(0x11)}, {4, Page(0x44)}}));
|
||||
}
|
||||
|
||||
TEST_F(StarletNANDJournal, RejectsCorruptionWithoutReplacingExistingView)
|
||||
{
|
||||
NANDJournal::Pages loaded;
|
||||
std::string error;
|
||||
{
|
||||
NANDJournal journal;
|
||||
ASSERT_TRUE(journal.Open(m_path, m_digest, &loaded, &error));
|
||||
ASSERT_TRUE(journal.Append({{1, Page(0x11)}}));
|
||||
}
|
||||
{
|
||||
File::IOFile corrupt(m_path, "r+b");
|
||||
ASSERT_TRUE(corrupt.Seek(32 + 12 + 4, File::SeekOrigin::Begin));
|
||||
const u8 wrong_byte = 0x99;
|
||||
ASSERT_TRUE(corrupt.WriteArray(&wrong_byte, 1));
|
||||
}
|
||||
loaded = {{99, Page(0x55)}};
|
||||
const auto size = File::GetSize(m_path);
|
||||
NANDJournal rejected;
|
||||
EXPECT_FALSE(rejected.Open(m_path, m_digest, &loaded, &error));
|
||||
EXPECT_EQ(loaded, (NANDJournal::Pages{{99, Page(0x55)}}));
|
||||
EXPECT_EQ(File::GetSize(m_path), size);
|
||||
}
|
||||
|
||||
TEST_F(StarletNANDJournal, UnopenedJournalDoesNotAcceptVolatileWrites)
|
||||
{
|
||||
NANDJournal journal;
|
||||
EXPECT_FALSE(journal.Append({{0, Page(0)}}));
|
||||
}
|
||||
Reference in New Issue
Block a user