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: 4708e6ae-60d0-464a-9fe2-482fa5103042

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

@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 Outdated
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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Changed the check to match the LZ4 branch and skipped size-0 segments

Comment thread src/core/loader/nso.cpp Outdated
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");
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Did what you suggested, tested with an rtld cut by 200 bytes and the log shows .data is truncated.

Comment thread src/core/loader/nso.cpp Outdated
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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Rejected uncompressed segments with mismatched sizes and now check EVP_Digest

Comment thread src/core/loader/loader.cpp Outdated
"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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Changed the message to the one you suggested, and "decompressed" to "loaded" since it now also covers truncated segments.

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.

@Fernandess07 Fernandess07 Nov 14, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Didn't do it in this PR to keep the diff small, but I can do it in a separate one.

rdodoa pushed a commit to rdodoa/Eglantine that referenced this pull request Nov 14, 2026
…ck (Ocarina of Time foliage shadows) (Eglantine-Community#863)

* vulkan, shader: draw points and lines through the mesh compute fallback

The compute fallback (Eglantine-Community#569) only took mesh programs that output
triangles, so programs with point or line topology were dropped on
devices without VK_EXT_mesh_shader. Ocarina of Time draws its foliage
shadow map (1600x1600 D32) with a point mesh program, so on those
devices the map stayed empty and grass and bushes cast no shadow.

The fallback now keeps PrimitiveVertices() indices per primitive (map
bytes 4 + k * p, as the native epilogue reads them), draws a point,
line or triangle list, and gives points the fixed state point size
unless the program writes its own.

A repeated index is not degenerate for points and lines, so slots past
the primitive count get MESH_EMULATION_UNUSED_VERTEX and the vertex
shader culls them with a negative clip distance. Moving them outside
the view volume was not enough on RADV (RX 5700 XT): some of them still
reached the shadow map, and the far field beyond it came out fully
shadowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDzM9hQaYmCiLmPrj79xs8

* vulkan, shader: document the functions the point and line fallback touches

Doc comments for ComputeMeshEpilogue, EmitMeshEmulationVertex, GraphicsPipeline::ConfigureDraw,
GraphicsPipeline::MakePipeline, RasterizerVulkan::ReadMeshEmulationProbe and PrepareDraw. No code
change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDzM9hQaYmCiLmPrj79xs8

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants