Skip to content

loader: reject corrupt NSO modules that fail to decompress - #863

Open
Fernandess07 wants to merge 1 commit into
Eglantine-Community:nx2-experimentalfrom
Fernandess07:loader/nso-integrity-check
Open

Fernandess07 wants to merge 1 commit into
Eglantine-Community:nx2-experimentalfrom
Fernandess07:loader/nso-integrity-check

Conversation

@Fernandess07

@Fernandess07 Fernandess07 commented Nov 14, 2026 •

Copy link
Copy Markdown

Summary

Previously, when an NSO module failed to decompress, the emulator got
stuck in an endless loading loop, leaving the user unable to tell
whether the problem was the emulator or the dump.

The two ASSERTs were replaced by a check that returns a new error,
ErrorCorruptNSO, which is shown to the user and written to the log.

The NSO header stores the SHA-256 of each segment, but the emulator
never compared it. It now does, which helps identify the problem. A
mismatch only logs a warning, so games are not prevented from running
when the hash does not match.

Assisted-by: Claude Opus 5.5 (diagnosis of the corrupt dump, implementation and testing)

Type of change

  • Bug fix
  • Game compatibility / game-support
  • Performance
  • New feature
  • Refactor / cleanup
  • CI / build
  • Documentation

Related issues

Related to #70

Testing

  • Tested on Windows
  • Tested on Linux
  • Tested on macOS
  • Tested on NVIDIA
  • Tested on AMD
  • Tested on Intel

Games tested:

Corrupt Pokopia dump (0400A3A01EE4C000): previously stuck in an endless loading loop, now fails with an error (checked in both the Qt frontend and eden-cli).

Checklist

  • I built it locally and it compiles without new warnings
  • I did not add any copyrighted game data, keys or firmware
  • Docs updated if needed
  • I added AI Disclosure if one was used

Screenshots / logs

Before (UI stuck on "Launching...", only this in the log):
Debug <Critical> core/loader/nso.cpp:125: assert r == int(nso_header.segments[i].size)

After:
Loader <Error> core/loader/nso.cpp:111: NSO module 'main' is corrupt: .text failed LZ4 decompression
Core <Critical> core/core.cpp:406:Load: Failed to load ROM (Error 70)!

Bad segment hash only (loading continues):
Loader <Warning> core/loader/nso.cpp:152:LoadModule: NSO module 'main': .text does not match its SHA-256

Summary by CodeRabbit

  • Bug Fixes
    • Improved error reporting when a game module is corrupt or cannot be decompressed, replacing a generic loading error with a more specific message.
    • Improved validation of decompressed module data to help prevent invalid or incomplete content from being loaded.

@github-actions
github-actions Bot changed the base branch from nx2 to nx2-experimental November 14, 2026 00:21
@github-actions

Copy link
Copy Markdown

Retargeted to nx2-experimental for pre-build testing. It will be promoted to nx2 after validation.

@coderabbitai

coderabbitai Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: ea2ea35f-2f68-4f6a-8063-26cdabe0e3fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Previously, when an NSO module failed to decompress, the emulator got
stuck in an endless loading loop, leaving the user unable to tell
whether the problem was the emulator or the dump.

The two ASSERTs were replaced by a check that returns a new error,
ErrorCorruptNSO, which is shown to the user and written to the log.

The NSO header stores the SHA-256 of each segment, but the emulator
never compared it. It now does, which helps identify the problem. A
mismatch only logs a warning, so games are not prevented from running
when the hash does not match.

Assisted-by: Claude Opus 5.5 (diagnosis of the corrupt dump, implementation and testing)
Signed-off-by: Pedro Fernandes <pedrocufe05@gmail.com>
@Fernandess07
Fernandess07 force-pushed the loader/nso-integrity-check branch from 997d1c9 to 09e8ca1 Compare November 14, 2026 00:29
Comment thread src/core/loader/nso.cpp
std::span<const u8>{compressed_data}.first(nso_header.segments_compressed_size[i])
);
ASSERT(r > 0);
if (r <= 0) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only catches outright zstd errors. ZSTD_decompress can succeed and write fewer bytes than the segment size, and the rest of decompressed_size (left over from the previous segment) then gets copied in. Could this match the LZ4 branch?

if (r != int(nso_header.segments[i].size)) {

Also, DecompressDataZBIC returns -1 when dst is empty, so a compressed 0-byte segment (e.g. an empty .data) would now fail the load, where before the assert only logged. Probably worth skipping decompression when segments[i].size == 0.

Comment thread src/core/loader/nso.cpp
return std::nullopt;
};
for (std::size_t i = 0; i < nso_header.segments.size(); ++i) {
nso_file.Read(compressed_data.data(), nso_header.segments_compressed_size[i], nso_header.segments[i].offset);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return value of Read is ignored, so a truncated dump (probably the most common kind of corruption) shows up as a decompression failure or as stale bytes from the previous segment. Since this PR is about corrupt dumps, could this be checked as well?

if (nso_file.Read(compressed_data.data(), nso_header.segments_compressed_size[i],
                  nso_header.segments[i].offset) != nso_header.segments_compressed_size[i]) {
    return corrupt(i, "is truncated");
}

Comment thread src/core/loader/nso.cpp
if (nso_header.IsSegmentHashChecked(i)) {
NSOHeader::SHA256Hash hash{};
unsigned int hash_size = 0;
EVP_Digest(segment_data, nso_header.segments[i].size, hash.data(), &hash_size,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For an uncompressed segment segment_data points into compressed_data, which is sized to the largest compressed segment, but this hashes segments[i].size bytes. A corrupt header with size > segments_compressed_size[i] reads past the heap buffer here. The uncompressed memcpy below already has this bug, but this adds a second over-read. Could uncompressed segments where segments_compressed_size[i] != segments[i].size be rejected via corrupt()?

Minor: EVP_Digest's return value is ignored. That's harmless, since a failure only gives a false warning, but it's easy to check.

"Integrity verification failed.",
"The NUP container is malformed or its TOC hash does not match.",
"The NUP container does not contain a loadable base package.",
"An ExeFS module is corrupt and could not be decompressed. The game needs to be redumped.",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this is also shown when a standalone .nso is loaded through AppLoader_NSO::Load, which isn't ExeFS. Something like "An executable module (NSO) is corrupt…" would cover both.

Comment thread src/core/loader/nso.h
std::vector<Core::NCE::Patcher>* patches = nullptr,
s32 patch_index = -1);
s32 patch_index = -1,
ResultStatus* out_status = nullptr);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, not a blocker: this is a tenth parameter after several defaulted ones. Returning the status (e.g. std::expected<VAddr, ResultStatus>) would avoid the out-pointer and the ResultStatus module_status = ErrorLoadingNSO; setup at each call site.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants