From 5f0325e2b4e72c3d10c44c7445bf73bc63e0ea32 Mon Sep 17 00:00:00 2001 From: duck912 <350528965+Duck912@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:45:56 -0400 Subject: nx2:gpu: create graphics devices and report an empty gsv partition Fire Emblem: Fortune's Weave creates a graphics device with gpu 41 and uses the returned id in later NvRm calls; auto-stubbed, it got id 0 and NVN setup failed. Hand out ids from 1 and free them with 42. gpu 202 answered (0, 0) for an empty partition, which the SDK reads as a one-byte partition at address 0. An empty one is min ~0, max 0. Assisted-by: Claude Opus 5.5 (debugging, porting and drafting) Signed-off-by: duck912 <350528965+Duck912@users.noreply.github.com> --- src/core/hle/service/nx2_gsv.h | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) (limited to 'src/core') diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index 628bdeda5b..72ef2d39d4 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #include "common/alignment.h" @@ -48,6 +49,7 @@ #include "core/hle/kernel/k_readable_event.h" #include "core/hle/kernel/k_session.h" #include "core/hle/kernel/k_transfer_memory.h" +#include "core/hle/kernel/svc_results.h" #include "core/memory.h" #include "video_core/host1x/host1x.h" #include "video_core/host1x/nx2_video_decoder.h" @@ -304,6 +306,8 @@ public: {21, &INx2GpuSession::Start, "Start (guessed)"}, {22, &INx2GpuSession::OpenSubSession, "OpenSubSession (guessed)"}, {33, &INx2GpuSession::GetCount33, "Unknown33 (u32 count)"}, + {41, &INx2GpuSession::CreateGraphicsDevice, "CreateGraphicsDevice"}, + {42, &INx2GpuSession::DestroyGraphicsDevice, "DestroyGraphicsDevice"}, {201, &INx2GpuSession::GetUserPartition, "GetUserPartition (newer SDKs)"}, {202, &INx2GpuSession::GetGsvPartition, "GetGsvPartition (newer SDKs)"}, {203, &INx2GpuSession::GetVaRange, "GetVaRange (guessed)"}, @@ -395,9 +399,39 @@ private: static_cast(second), static_cast(second >> 32)}); } + // An empty partition is min = ~0, max = 0 (min > max). Fire Emblem: Fortune's Weave's SDK + // reads (0, 0) as a one-byte partition at address 0 and aborts in nn::gpu::sdk. void GetGsvPartition(HLERequestContext& ctx) { LOG_WARNING(Service, "(STUBBED) nx2:gpu TIPC 202 [2]={:#x}, empty", ctx.CommandBuffer()[2]); - TipcReplyWords(ctx, {0, 0, 0, 0}); + TipcReplyWords(ctx, {~0U, ~0U, 0, 0}); + } + + // Fire Emblem: Fortune's Weave: 41(name u64, flags u64, debug label u64) returns a device id + // that later NvRm calls name; 0 is not a valid id. Only flags 0 has been seen. + void CreateGraphicsDevice(HLERequestContext& ctx) { + const auto* b = ctx.CommandBuffer(); + const u64 name = u64{b[2]} | (u64{b[3]} << 32); + const u64 flags = u64{b[4]} | (u64{b[5]} << 32); + u32 id = 0; + for (u32 candidate = 1; candidate < m_devices.size(); ++candidate) { + if (!m_devices[candidate]) { + m_devices[candidate] = true; + id = candidate; + break; + } + } + LOG_INFO(Service, "nx2:gpu 41 create graphics device name={:#x} flags={:#x} -> id {}", name, + flags, id); + IPC::ResponseBuilder rb{ctx, 3}; + rb.Push(id != 0 ? ResultSuccess : Kernel::ResultOutOfResource); + rb.Push(id); + } + + void DestroyGraphicsDevice(HLERequestContext& ctx) { + const u32 id = ctx.CommandBuffer()[2] & 0xff; + const bool existed = id < m_devices.size() && std::exchange(m_devices[id], false); + IPC::ResponseBuilder rb{ctx, 2}; + rb.Push(existed ? ResultSuccess : Kernel::ResultNotFound); } void OpenSubSession(HLERequestContext& ctx) { @@ -1246,6 +1280,7 @@ private: std::array m_events{}; std::map m_memory; u32 m_next_memory_handle{1}; + std::array m_devices{}; // gpu 41 graphics device ids (0 unused) }; // Native memory and images handed out by the Switch 2 graphics server (gsv 111/114/423/429/315), -- cgit v1.2.3 From 210fb0cb690318e8ff65a042cfecb8dc345a66ab Mon Sep 17 00:00:00 2001 From: duck912 <350528965+Duck912@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:46:15 -0400 Subject: nvhost_ctrl_gpu: implement VSMS_MAPPING The Switch 2 SDK asks for the {gpc, tpc} pair of every virtual SM and treats a pair left at its 0xc0c0 sentinel as failure; with the ioctl unimplemented, Fire Emblem: Fortune's Weave tore NVN down at boot (2162-0001). Fill every pair: one GPC, two SMs per TPC. Assisted-by: Claude Opus 5.5 (debugging, porting and drafting) Signed-off-by: duck912 <350528965+Duck912@users.noreply.github.com> --- .../hle/service/nvdrv/devices/nvhost_ctrl_gpu.cpp | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) (limited to 'src/core') diff --git a/src/core/hle/service/nvdrv/devices/nvhost_ctrl_gpu.cpp b/src/core/hle/service/nvdrv/devices/nvhost_ctrl_gpu.cpp index 5998940afa..c13ad4fbff 100644 --- a/src/core/hle/service/nvdrv/devices/nvhost_ctrl_gpu.cpp +++ b/src/core/hle/service/nvdrv/devices/nvhost_ctrl_gpu.cpp @@ -113,12 +113,22 @@ NvResult nvhost_ctrl_gpu::Ioctl3(DeviceFD fd, Ioctl command, std::span case 0x6: return WrapFixedInlOut(this, &nvhost_ctrl_gpu::GetTPCMasks3, input, output, inline_output); - case 0x13: - LOG_DEBUG(Service_NVDRV, "(STUBBED) called."); - - // TODO (jarrodnorwell) - - return NvResult::NotImplemented; + case 0x13: { + // NVGPU_GPU_IOCTL_VSMS_MAPPING: an 8-byte argument struct plus a buffer of + // {gpc_index, tpc_index} byte pairs, one per virtual SM. The SDK treats a pair left at + // its 0xc0c0 sentinel as failure, so fill every pair: one GPC, two SMs per TPC. + if (command.raw != 0xc0084713 || input.size() != 8 || output.size() != 8 || + inline_output.size() < 2) { + return NvResult::BadParameter; + } + std::memcpy(output.data(), input.data(), output.size()); + for (size_t i = 0; i + 1 < inline_output.size(); i += 2) { + inline_output[i] = 0; // gpc_index + inline_output[i + 1] = static_cast(i / 4); // tpc_index + } + LOG_DEBUG(Service_NVDRV, "VSMS mapping for {} SMs", inline_output.size() / 2); + return NvResult::Success; + } default: break; } -- cgit v1.2.3 From 681d50cd9cadf0b2205311dc20c84a9e774898d9 Mon Sep 17 00:00:00 2001 From: duck912 <350528965+Duck912@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:42:26 -0400 Subject: nx2: report the class family flags in the GPU characteristics The Switch 2 SDK filters its class enumeration by flags bits 40..43 (2D, 3D, compute and inline-to-memory) before it picks its context classes. Without them Fire Emblem: Fortune's Weave tears NVN down at boot (2162-0001). Bisected against the Eden NS2 tree's characteristics block; its other differences are not needed. Assisted-by: Claude Opus 5.5 (bisecting and drafting) Signed-off-by: duck912 <350528965+Duck912@users.noreply.github.com> --- src/core/hle/service/nvdrv/devices/nvhost_ctrl_gpu.cpp | 3 +++ 1 file changed, 3 insertions(+) (limited to 'src/core') diff --git a/src/core/hle/service/nvdrv/devices/nvhost_ctrl_gpu.cpp b/src/core/hle/service/nvdrv/devices/nvhost_ctrl_gpu.cpp index c13ad4fbff..cf4a473496 100644 --- a/src/core/hle/service/nvdrv/devices/nvhost_ctrl_gpu.cpp +++ b/src/core/hle/service/nvdrv/devices/nvhost_ctrl_gpu.cpp @@ -228,6 +228,9 @@ void ApplyNx2Characteristics(GC& gc) { if (remap) { gc.flags |= 1ULL << 35; } + // Bits 40..43: the 2D, 3D, compute and inline-to-memory class families. The SDK filters its + // class enumeration by them; without them Fire Emblem: Fortune's Weave aborts at boot. + gc.flags |= 0xfULL << 40; // The Switch 2 layout keeps nine s16 interface versions at 0x70..0x80 (like nvgpu's // *_ioctl_nr_last); its NvRm requires e.g. [0x78] >= 6 and enables features above 0xa, 0x1f -- cgit v1.2.3 From cce19772ce434e462223663b88294bfd8a0f203e Mon Sep 17 00:00:00 2001 From: duck912 <350528965+Duck912@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:45:57 -0400 Subject: nx2:vi2: crop presents to their frame size and honour RGB10A2 images Present word 7 holds the size of the frame drawn into the image's top-left corner, and image preference word 6 its format (1 RGBA8, 4 RGB10A2). Fire Emblem: Fortune's Weave draws 1280x720 RGB10A2 frames into 3840x2160 images; showing the whole image as RGBA8 gave a corner of wrongly decoded pixels. Assisted-by: Claude Opus 5.5 (debugging, porting and drafting) Signed-off-by: duck912 <350528965+Duck912@users.noreply.github.com> --- src/core/hle/service/nvnflinger/pixel_format.h | 1 + src/core/hle/service/nx2_gsv.h | 17 ++++++++++++++--- src/video_core/renderer_opengl/present/layer.cpp | 5 +++++ src/video_core/renderer_vulkan/present/layer.cpp | 2 ++ src/video_core/surface.cpp | 2 ++ 5 files changed, 24 insertions(+), 3 deletions(-) (limited to 'src/core') diff --git a/src/core/hle/service/nvnflinger/pixel_format.h b/src/core/hle/service/nvnflinger/pixel_format.h index b6e8b5f8a0..a38c8be777 100644 --- a/src/core/hle/service/nvnflinger/pixel_format.h +++ b/src/core/hle/service/nvnflinger/pixel_format.h @@ -19,6 +19,7 @@ enum class PixelFormat : u32 { Bgra8888 = 5, Rgba5551 = 6, Rgba4444 = 7, + Rgba1010102 = 43, }; } // namespace Service::android diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index 72ef2d39d4..cb92bc107b 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -1499,7 +1499,7 @@ private: WaitForFrame(~w[22], w[24]); } const auto waited = std::chrono::steady_clock::now(); - Composite(w[5], ~w[22], w[24]); + Composite(w[5], ~w[22], w[24], w[7]); const auto composited = std::chrono::steady_clock::now(); PaceToVblank(); const auto paced = std::chrono::steady_clock::now(); @@ -1621,9 +1621,12 @@ private: // Shows a presented native image: word 5 is the image id, ~word 22 / word 24 the GPU fence // (syncpoint, value) to wait for (layout from changes.patch). The image is a block-linear // RGBA8 surface in the console's native GOB layout at GPU address == CPU address. - void Composite(u32 image_id, u32 fence_id, u32 fence_value) { + // present_size: word 7 of the present, (height - 1) << 16 | (width - 1) of the frame drawn + // into the image's top-left corner (Fire Emblem: Fortune's Weave: 1280x720 in 3840x2160). + void Composite(u32 image_id, u32 fence_id, u32 fence_value, u32 present_size = 0) { u64 address{}; Nx2ImageLayout layout{}; + u32 image_format{}; { std::scoped_lock lk{m_objects->mutex}; const auto image = m_objects->images.find(image_id); @@ -1636,6 +1639,7 @@ private: } address = memory->second.address; layout = Nx2CalculateImageLayout(image->second.preference.data()); + image_format = image->second.preference[6]; } const auto device = Nvidia::Devices::Nx2GpuMemoryRegistry::Get().DeviceAddressOf(address); if (!device) { @@ -1645,6 +1649,11 @@ private: } u32 crop_width = layout.width; u32 crop_height = layout.height; + // Present word 7 holds the size of the frame drawn into the image's top-left corner. + if (present_size != 0) { + crop_width = (std::min)(crop_width, (present_size & 0xffffU) + 1); + crop_height = (std::min)(crop_height, (present_size >> 16) + 1); + } // Handheld titles keep their docked present images (up to 3840x2160) and draw the frame // into the top-left 1920x1080, the handheld panel's resolution; the console's display // scans out only that region. Showing whole images put the frame in a corner inside black @@ -1683,7 +1692,9 @@ private: .width = layout.width, .height = layout.height, .stride = static_cast(layout.pitch / 4), - .pixel_format = android::PixelFormat::Rgba8888, + // Preference word 6: 1 = RGBA8, 4 = RGB10A2. + .pixel_format = image_format == 4 ? android::PixelFormat::Rgba1010102 + : android::PixelFormat::Rgba8888, .transform_flags = android::BufferTransformFlags::Unset, .crop_rect = {0, 0, static_cast(crop_width), static_cast(crop_height)}, .blending = Tegra::BlendMode::Opaque, diff --git a/src/video_core/renderer_opengl/present/layer.cpp b/src/video_core/renderer_opengl/present/layer.cpp index 9e4a0f854a..2656892f4a 100644 --- a/src/video_core/renderer_opengl/present/layer.cpp +++ b/src/video_core/renderer_opengl/present/layer.cpp @@ -183,6 +183,11 @@ void Layer::ConfigureFramebufferTexture(const Tegra::FramebufferConfig& framebuf framebuffer_texture.gl_format = GL_RGBA; framebuffer_texture.gl_type = GL_UNSIGNED_INT_8_8_8_8_REV; break; + case Service::android::PixelFormat::Rgba1010102: + internal_format = GL_RGB10_A2; + framebuffer_texture.gl_format = GL_RGBA; + framebuffer_texture.gl_type = GL_UNSIGNED_INT_2_10_10_10_REV; + break; case Service::android::PixelFormat::Rgb565: internal_format = GL_RGB565; framebuffer_texture.gl_format = GL_RGB; diff --git a/src/video_core/renderer_vulkan/present/layer.cpp b/src/video_core/renderer_vulkan/present/layer.cpp index fa24c02f6d..5652090bd7 100644 --- a/src/video_core/renderer_vulkan/present/layer.cpp +++ b/src/video_core/renderer_vulkan/present/layer.cpp @@ -52,6 +52,8 @@ VkFormat GetFormat(const Tegra::FramebufferConfig& framebuffer) { return VK_FORMAT_R5G6B5_UNORM_PACK16; case Service::android::PixelFormat::Bgra8888: return VK_FORMAT_B8G8R8A8_UNORM; + case Service::android::PixelFormat::Rgba1010102: + return VK_FORMAT_A2B10G10R10_UNORM_PACK32; default: UNIMPLEMENTED_MSG("Unknown framebuffer pixel format: {}", static_cast(framebuffer.pixel_format)); diff --git a/src/video_core/surface.cpp b/src/video_core/surface.cpp index 66b9577cb5..ef4859ea3e 100644 --- a/src/video_core/surface.cpp +++ b/src/video_core/surface.cpp @@ -214,6 +214,8 @@ PixelFormat PixelFormatFromGPUPixelFormat(Service::android::PixelFormat format) return PixelFormat::R5G6B5_UNORM; case Service::android::PixelFormat::Bgra8888: return PixelFormat::B8G8R8A8_UNORM; + case Service::android::PixelFormat::Rgba1010102: + return PixelFormat::A2B10G10R10_UNORM; default: UNIMPLEMENTED_MSG("Unimplemented format={}", format); return PixelFormat::A8B8G8R8_UNORM; -- cgit v1.2.3 From 9cf6a499c84af39be013c0a358044cc66e31117a Mon Sep 17 00:00:00 2001 From: duck912 <350528965+Duck912@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:42:26 -0400 Subject: nx2:vi2: reply to 104 with the display's output format GetHdrStatus returns a u64 output format after the result. The bare success reply left it to stale reply words, and Fire Emblem: Fortune's Weave set up HDR output from them: its tone map constants came out NaN and every frame was black. Report format 1 (SDR) for the "default" display. Assisted-by: Claude Opus 5.5 (debugging and drafting) Signed-off-by: duck912 <350528965+Duck912@users.noreply.github.com> --- src/core/hle/service/nx2_gsv.h | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) (limited to 'src/core') diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index cb92bc107b..7ef4c2e0e3 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -1442,10 +1442,10 @@ public: {11, &INx2Vi2Session::ReturnEvent, "Unknown11"}, {31, &INx2Vi2Session::GetVsyncEvent, "GetVsyncEvent (guessed)"}, {32, &INx2Vi2Session::GetVsyncEvent, "GetDisplayVsyncEvent (guessed)"}, - // Title 1 calls these ~75 times a second with the display name "default"; the + {104, &INx2Vi2Session::GetHdrStatus, "GetHdrStatus"}, + // Title 1 calls this ~75 times a second with the display name "default"; the // auto stub's empty success reply works, but logged two lines per call (a 50 MB log // in an 18 minute session). - {104, &INx2Vi2Session::SucceedQuietly, "Unknown104 (display \"default\")"}, {105, &INx2Vi2Session::SucceedQuietly, "Unknown105 (display \"default\")"}, {228, &INx2Vi2Session::CancelClientBuffer, "CancelClientBuffer"}, {229, &INx2Vi2Session::PresentClientBuffer, "PresentClientBuffer"}, @@ -1798,6 +1798,27 @@ private: TipcReplyWords(ctx, {1}); } + // 104(u64 display name): result, u64 output format, u32 0. Format 1 is SDR. A bare success + // left the format to stale reply words and a game could set up HDR output from them. + void GetHdrStatus(HLERequestContext& ctx) { + const auto* packet = ctx.CommandBuffer(); + const u64 name = u64{packet[2]} | (u64{packet[3]} << 32); + constexpr u64 DefaultDisplay = 0x00746c7561666564ULL; // "default" + Result result = Kernel::ResultInvalidCombination; + u64 format{}; + if (packet[0] == 0x78 && packet[1] == 2) { + result = Result{ErrorModule::VI, 7}; + if (name == DefaultDisplay) { + format = 1; + result = ResultSuccess; + } + } + IPC::ResponseBuilder rb{ctx, 5}; + rb.Push(result.raw); + rb.Push(format); + rb.Push(0); + } + // The same reply as the auto stub (success, no data), without its log lines. void SucceedQuietly(HLERequestContext& ctx) { IPC::ResponseBuilder rb{ctx, 2}; -- cgit v1.2.3 From 68f8d96368505a6146d5cf545463e06469b6e7b8 Mon Sep 17 00:00:00 2001 From: duck912 <350528965+Duck912@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:42:26 -0400 Subject: nvhost_as_gpu: share Switch 2 device ranges between a process's address spaces Each address space allocated its own SMMU range for the same memory outside the heap. The caches key memory by device address, so these were separate copies, and presentation used whichever range was recorded last: Fire Emblem: Fortune's Weave drew into one alias while VI showed another, and the screen stayed black. Key these ranges by (ASID, CPU address, size) and count references. The first address space allocates the range, the last release frees it, and a closed session's entries are dropped so a reused ASID can't inherit them. EDEN_NX2_PRIVATE_DEVICE_RANGES=1 restores a range per address space. Assisted-by: Claude Opus 5.5 (debugging, implementation and drafting) Signed-off-by: duck912 <350528965+Duck912@users.noreply.github.com> --- src/core/hle/service/nvdrv/core/container.cpp | 2 + .../hle/service/nvdrv/devices/nvhost_as_gpu.cpp | 46 ++++++++++++++----- src/core/hle/service/nvdrv/devices/nvhost_as_gpu.h | 2 + .../hle/service/nvdrv/devices/nx2_stub_device.h | 51 ++++++++++++++++++++++ 4 files changed, 90 insertions(+), 11 deletions(-) (limited to 'src/core') diff --git a/src/core/hle/service/nvdrv/core/container.cpp b/src/core/hle/service/nvdrv/core/container.cpp index 9edce03f64..22db243e3f 100644 --- a/src/core/hle/service/nvdrv/core/container.cpp +++ b/src/core/hle/service/nvdrv/core/container.cpp @@ -11,6 +11,7 @@ #include "core/hle/service/nvdrv/core/heap_mapper.h" #include "core/hle/service/nvdrv/core/nvmap.h" #include "core/hle/service/nvdrv/core/syncpoint_manager.h" +#include "core/hle/service/nvdrv/devices/nx2_stub_device.h" #include "core/memory.h" #include "video_core/host1x/host1x.h" @@ -130,6 +131,7 @@ void Container::CloseSession(SessionId session_id) { session.has_preallocated_area = false; } session.is_active = false; + Devices::Nx2GpuMemoryRegistry::Get().ForgetDeviceRanges(session.asid.id); smmu.UnregisterProcess(impl->sessions[session_id.id].asid); impl->id_pool.emplace_front(session_id.id); } diff --git a/src/core/hle/service/nvdrv/devices/nvhost_as_gpu.cpp b/src/core/hle/service/nvdrv/devices/nvhost_as_gpu.cpp index e66c72cd26..9d696c027e 100644 --- a/src/core/hle/service/nvdrv/devices/nvhost_as_gpu.cpp +++ b/src/core/hle/service/nvdrv/devices/nvhost_as_gpu.cpp @@ -5,6 +5,7 @@ // SPDX-FileCopyrightText: 2021 Skyline Team and Contributors // SPDX-License-Identifier: GPL-3.0-or-later +#include #include #include @@ -35,6 +36,13 @@ nvhost_as_gpu::nvhost_as_gpu(Core::System& system_, Module& module_, NvCore::Con nvhost_as_gpu::~nvhost_as_gpu() { Nx2GpuMemoryRegistry::Get().RemoveListener(this); + // Leave shared ranges to the process's other address spaces; like this address space's own + // ranges, one nobody else maps stays allocated. + for (const auto& [key, range] : nx2_device_ranges) { + if (range.shared) { + Nx2GpuMemoryRegistry::Get().ReleaseDeviceRange(range.asid, key.first, key.second); + } + } } NvResult nvhost_as_gpu::Ioctl1(DeviceFD fd, Ioctl command, std::span input, @@ -134,25 +142,37 @@ void nvhost_as_gpu::Nx2Map(u64 gpu_address, u64 cpu_address, u64 size) { } // The same CPU range is often mapped twice (at its own address and in a sparse region), so // reuse its SMMU mapping. - DAddr device_address{}; - bool heap = false; + Nx2DeviceRange range{}; if (const auto it = nx2_device_ranges.find({cpu_address, size}); it != nx2_device_ranges.end()) { - device_address = it->second.address; - heap = it->second.heap; + range = it->second; } else if (session->has_preallocated_area && session->mapper->IsInBounds(cpu_address, size)) { - device_address = session->mapper->Map(cpu_address, size); - heap = true; + range = {session->mapper->Map(cpu_address, size), true}; } else { - auto& smmu = system.Host1x().MemoryManager(); - device_address = smmu.Allocate(Common::AlignUp(size, 0x20000)); - if (device_address == 0) { + const auto allocate = [&]() -> u64 { + auto& smmu = system.Host1x().MemoryManager(); + const DAddr address = smmu.Allocate(Common::AlignUp(size, 0x20000)); + if (address != 0) { + smmu.Map(address, cpu_address, size, session->asid, true); + } + return address; + }; + // EDEN_NX2_PRIVATE_DEVICE_RANGES=1 (A/B aid): a range per address space. + static const bool private_ranges = std::getenv("EDEN_NX2_PRIVATE_DEVICE_RANGES") != nullptr; + if (private_ranges) { + range = {allocate(), false}; + } else { + range = {Nx2GpuMemoryRegistry::Get().AcquireDeviceRange(session->asid.id, cpu_address, + size, allocate), + false, true, session->asid.id}; + } + if (range.address == 0) { LOG_CRITICAL(Service_NVDRV, "Switch 2 GPU memory {:#x}+{:#x}: out of SMMU space", cpu_address, size); return; } - smmu.Map(device_address, cpu_address, size, session->asid, true); } - nx2_device_ranges[{cpu_address, size}] = {device_address, heap}; + const DAddr device_address = range.address; + nx2_device_ranges[{cpu_address, size}] = range; Nx2GpuMemoryRegistry::Get().RecordDeviceRange(cpu_address, size, device_address); gmmu->Map(gpu_address, device_address, size, Tegra::PTEKind::PITCH, false); LOG_DEBUG(Service_NVDRV, "Switch 2 GPU mapping {:#x}+{:#x} -> CPU {:#x}", gpu_address, size, @@ -177,6 +197,10 @@ void nvhost_as_gpu::Nx2Release(u64 cpu_address, u64 size) { } const Nx2DeviceRange range = it->second; nx2_device_ranges.erase(it); + if (range.shared && + !Nx2GpuMemoryRegistry::Get().ReleaseDeviceRange(range.asid, cpu_address, size)) { + return; // another address space of the process still maps it + } auto* session = container.GetSession(nx2_session); auto* const mapper = range.heap && session != nullptr ? session->mapper.get() : nullptr; auto& smmu = system.Host1x().MemoryManager(); diff --git a/src/core/hle/service/nvdrv/devices/nvhost_as_gpu.h b/src/core/hle/service/nvdrv/devices/nvhost_as_gpu.h index 39b63ea369..37b6d0d4c4 100644 --- a/src/core/hle/service/nvdrv/devices/nvhost_as_gpu.h +++ b/src/core/hle/service/nvdrv/devices/nvhost_as_gpu.h @@ -238,6 +238,8 @@ private: struct Nx2DeviceRange { DAddr address; bool heap; ///< Mapped through the session's heap mapper (else an SMMU allocation) + bool shared{}; ///< A reference to the process's range in Nx2GpuMemoryRegistry + size_t asid{}; }; std::map, Nx2DeviceRange> nx2_device_ranges; void Nx2Release(u64 cpu_address, u64 size); diff --git a/src/core/hle/service/nvdrv/devices/nx2_stub_device.h b/src/core/hle/service/nvdrv/devices/nx2_stub_device.h index 869e17caf6..8dee9ccfb7 100644 --- a/src/core/hle/service/nvdrv/devices/nx2_stub_device.h +++ b/src/core/hle/service/nvdrv/devices/nx2_stub_device.h @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -166,12 +167,50 @@ public: { std::scoped_lock lk{device_mutex}; device_ranges.clear(); + shared_ranges.clear(); } std::scoped_lock lk{mutex}; mappings.clear(); sparse_regions.clear(); } + // Device (SMMU) range of a mapping outside the heap, shared by every address space of one + // process: the caches key memory by device address, so a range per address space made separate + // copies of the same memory, and presentation could read one the game never drew into. The + // first address space to map the range allocates it with `allocate` (0: out of space); every + // call takes a reference. + u64 AcquireDeviceRange(size_t asid, u64 cpu_address, u64 size, + const std::function& allocate) { + std::scoped_lock lk{device_mutex}; + const SharedRangeKey key{asid, cpu_address, size}; + if (const auto it = shared_ranges.find(key); it != shared_ranges.end()) { + ++it->second.references; + return it->second.device_address; + } + const u64 device_address = allocate(); + if (device_address != 0) { + shared_ranges.emplace(key, SharedRange{device_address, 1}); + } + return device_address; + } + + // Drops a reference. True for the last one: the caller frees the range. + bool ReleaseDeviceRange(size_t asid, u64 cpu_address, u64 size) { + std::scoped_lock lk{device_mutex}; + const auto it = shared_ranges.find(SharedRangeKey{asid, cpu_address, size}); + if (it == shared_ranges.end() || --it->second.references != 0) { + return false; + } + shared_ranges.erase(it); + return true; + } + + // The process's session closed and its ASID can be reused: stop sharing its ranges. + void ForgetDeviceRanges(size_t asid) { + std::scoped_lock lk{device_mutex}; + std::erase_if(shared_ranges, [asid](const auto& entry) { return entry.first.asid == asid; }); + } + // Device (SMMU) address of a CPU range an address space mapped; used to present images. void RecordDeviceRange(u64 cpu_address, u64 size, u64 device_address) { std::scoped_lock lk{device_mutex}; @@ -197,6 +236,18 @@ private: std::mutex device_mutex; std::map> device_ranges; // cpu start -> (size, device address) + struct SharedRangeKey { + size_t asid; + u64 cpu_address; + u64 size; + auto operator<=>(const SharedRangeKey&) const = default; + }; + struct SharedRange { + u64 device_address; + u32 references; + }; + std::map shared_ranges; + struct Mapping { u64 gpu_address; u64 cpu_address; -- cgit v1.2.3 From f8d38607b6cf6e35ee3daad7e34e590e19421c79 Mon Sep 17 00:00:00 2001 From: Duck912 <350528965+Duck912@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:42:12 -0500 Subject: nx2:vi2: pace presents by their swap interval Present word 12 holds (swap interval << 16) | refresh rate. Presents were always held to the next 60 Hz vblank, so Fire Emblem: Fortune's Weave, which sends 0x2003c (interval 2) after its boot logos, ran at 60 fps and double speed instead of 30. Pace each present by its interval and rate (interval clamped to 1-4, rate 60 if outside 30-240 Hz) and log when the word changes. EDEN_NX2_SWAP_INTERVAL= forces the interval. Assisted-by: Claude Opus 5.5 (debugging, implementation and drafting) Signed-off-by: duck912 <350528965+Duck912@users.noreply.github.com> --- src/core/hle/service/nx2_gsv.h | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) (limited to 'src/core') diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index 7ef4c2e0e3..1760ee0573 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -1501,7 +1501,7 @@ private: const auto waited = std::chrono::steady_clock::now(); Composite(w[5], ~w[22], w[24], w[7]); const auto composited = std::chrono::steady_clock::now(); - PaceToVblank(); + PaceToVblank(w[12]); const auto paced = std::chrono::steady_clock::now(); // Where each frame's time goes on the present thread, averaged per 600 presents: the // game is blocked here while Eden's GPU thread catches up (fence) and composites. @@ -1557,15 +1557,31 @@ private: // present faster than that. Every slot is reported free here: hold each present until the // next vblank instead (title 4 ran at up to 160 fps, its timing and audio with it). std::chrono::steady_clock::time_point m_next_vblank{}; + u32 m_swap_word{}; - void PaceToVblank() { + // timing: present word 12, (swap interval << 16) | refresh rate. Fire Emblem: Fortune's Weave + // sends 0x1003c during its boot logos and 0x2003c (two vblanks per frame, 30 fps) after; paced + // at every vblank it ran at double speed. EDEN_NX2_SWAP_INTERVAL= forces the interval. + void PaceToVblank(u32 timing) { if (!Settings::values.use_speed_limit.GetValue() || Settings::SpeedLimit() == 0) { return; } - const auto interval = std::chrono::nanoseconds{100'000'000'000LL / 60 / Settings::SpeedLimit()}; + static const u32 forced_interval = [] { + const char* value = std::getenv("EDEN_NX2_SWAP_INTERVAL"); + return value ? static_cast(std::strtoul(value, nullptr, 10)) : 0U; + }(); + const u32 swap_interval = + forced_interval != 0 ? forced_interval : std::clamp(timing >> 16, 1, 4); + const u32 refresh_rate = (timing & 0xffff) >= 30 && (timing & 0xffff) <= 240 ? timing & 0xffff : 60; + if (timing != std::exchange(m_swap_word, timing)) { + LOG_INFO(Service, "nx2:vi2 present #{} swap interval {} at {} Hz (word 12 = {:#x})", + m_presents, swap_interval, refresh_rate, timing); + } + const auto interval = std::chrono::nanoseconds{ + 100'000'000'000LL * swap_interval / refresh_rate / Settings::SpeedLimit()}; const auto now = std::chrono::steady_clock::now(); if (now > m_next_vblank + interval) { - // Running slower than 60 fps: show at once and don't bank the lost time. + // Running slower than the swap interval: show at once and don't bank the lost time. m_next_vblank = now; } else if (now < m_next_vblank) { std::this_thread::sleep_until(m_next_vblank); -- cgit v1.2.3 From 9f22054c6360fcc1bafa673451a5764f5f2ca7d1 Mon Sep 17 00:00:00 2001 From: Duck912 <350528965+Duck912@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:42:12 -0500 Subject: nx2:gsv: map decoder images the client reads on the GPU Fire Emblem: Fortune's Weave's movie player queries (428) and maps (426) each decoder output image, then blits it (RGBA, format 0xd5, block-linear with 16-GOB blocks) into a texture of its own. The pool memory is process memory (114) that no GPU mapping covered, so the blit read zeros and the movies were black. For decoder images, 426 now maps the image at its own address for the GPU unless a mapping already covers it, and replies with the address, the last byte's address and the image descriptor as our Eden tree does. 427 drops the mapping after the last unmap, as do 430 and 112. Images the client reads on the GPU are written as block-linear RGBA, and the GPU caches are invalidated after each frame. NV12 images and images read with 961 keep the pitch-linear layout. Assisted-by: Claude Opus 5.5 (debugging, implementation and drafting) Signed-off-by: duck912 <350528965+Duck912@users.noreply.github.com> --- src/core/hle/service/nx2_gsv.h | 158 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 153 insertions(+), 5 deletions(-) (limited to 'src/core') diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index 1760ee0573..b8268b91a0 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -54,6 +54,7 @@ #include "video_core/host1x/host1x.h" #include "video_core/host1x/nx2_video_decoder.h" #include "video_core/nx2.h" +#include "video_core/textures/decoders.h" #include "core/hle/service/hle_ipc.h" #include "core/hle/service/ipc_helpers.h" #include "core/hle/service/kernel_helpers.h" @@ -1299,6 +1300,10 @@ struct Nx2NativeObjects { u64 offset{}; // into its memory (images of a decoder pool share one) bool window_pool{}; bool decoder{}; // a video decoder output image (gsv 911) + // A decoder image the client reads on the GPU (gsv 428/426): written block-linear. + bool gpu_read{}; + u32 gpu_maps{}; // outstanding 426 maps + bool mapped_here{}; // 426 added its GPU mapping (no other mapping covered it) }; std::mutex mutex; std::map memories; @@ -2304,6 +2309,7 @@ private: const Decoded& frame, std::vector& bytes) { u64 address{}; Nx2DecoderImageLayout layout{}; + bool block_linear{}; { std::scoped_lock lk{m_objects->mutex}; const auto image = m_objects->images.find(image_id); @@ -2311,6 +2317,8 @@ private: return {m_width, m_height}; } layout = Nx2CalculateDecoderImageLayout(image->second.preference.data()); + block_linear = image->second.gpu_read && + layout.format != Tegra::Host1x::Nx2PictureFormat::Nv12; const auto memory = m_objects->memories.find(image->second.memory); if (memory != m_objects->memories.end() && memory->second.address != 0) { address = memory->second.address + image->second.offset; @@ -2324,10 +2332,25 @@ private: const u32 width = std::min(frame.picture->width, layout.width); const u32 height = std::min(frame.picture->height, layout.height); bytes.resize(layout.size); + auto& memory = system.ApplicationMemory(); + if (block_linear) { + // Read on the GPU: RGBA (the blit's format 0xd5) in the native block-linear layout, + // 16-GOB blocks, as our Eden tree writes these images. + decoder->Render(*frame.picture, Tegra::Host1x::Nx2PictureFormat::Rgba8, width, height, + layout.pitch, 0, bytes); + m_tiled.resize(layout.size); + Tegra::Texture::SwizzleTexture(m_tiled, bytes, 4, static_cast(layout.pitch / 4), + layout.height, 1, 4, 0, 1, true); + memory.WriteBlock(address, m_tiled.data(), m_tiled.size()); + if (const auto device = + Nvidia::Devices::Nx2GpuMemoryRegistry::Get().DeviceAddressOf(address)) { + system.GPU().InvalidateRegion(*device, m_tiled.size()); + } + return {width, height}; + } decoder->Render(*frame.picture, layout.format, width, height, layout.pitch, layout.chroma_offset, bytes); DumpFrame(bytes, layout, width, height); - auto& memory = system.ApplicationMemory(); memory.WriteBlock(address, bytes.data(), layout.pitch * height); if (layout.format == Tegra::Host1x::Nx2PictureFormat::Nv12) { memory.WriteBlock(address + layout.chroma_offset, bytes.data() + layout.chroma_offset, @@ -2373,6 +2396,7 @@ private: u64 m_submitted{}; u32 m_syncpoint{NoSyncpoint}; ///< Counts finished frames (allocated at the first register) u32 m_sync_base{}; ///< Its value before the first frame + std::vector m_tiled; ///< Decode thread: block-linear copy of a frame (WriteImage) // Shared with the decode thread. std::mutex m_mutex; @@ -2416,6 +2440,7 @@ public: {429, &INx2GraphicsSystemSession::CreateNativeImage, "CreateNativeImage"}, {430, &INx2GraphicsSystemSession::DestroyNativeImage, "DestroyNativeImage"}, {426, &INx2GraphicsSystemSession::QueryNativeImage, "MapNativeImage"}, + {427, &INx2GraphicsSystemSession::UnmapNativeImage, "UnmapNativeImage"}, {428, &INx2GraphicsSystemSession::QueryNativeImage, "QueryNativeImage"}, {435, &INx2GraphicsSystemSession::CalculateNativeImageSize, "CalculateNativeImageSize"}, {1402, &INx2GraphicsSystemSession::OpenNvsrvSession, "OpenNvsrvSession (guessed)"}, @@ -2680,8 +2705,19 @@ private: void DestroyNativeMemory(HLERequestContext& ctx) { const u32 id = ctx.CommandBuffer()[2]; - std::scoped_lock lk{m_objects->mutex}; - m_objects->memories.erase(id); + std::vector>> unmaps; + { + std::scoped_lock lk{m_objects->mutex}; + for (auto& [image_id, image] : m_objects->images) { + if (image.memory == id && image.mapped_here) { + unmaps.push_back(TakeDecoderMapping(image)); + } + } + m_objects->memories.erase(id); + } + for (const auto& range : unmaps) { + DropDecoderMapping(range); + } LOG_DEBUG(Service, "nx2:gsv 112 DestroyNativeMemory id {}", id); TipcReplyWords(ctx, {}); } @@ -2756,6 +2792,9 @@ private: // the 429 preference words (0x10, 0x14), a guess. void QueryNativeImage(HLERequestContext& ctx) { const u32 handle = ctx.CommandBuffer()[2]; + if (QueryDecoderImage(ctx, handle)) { + return; + } std::array preference{}; u64 image_offset = 0; u64 memory_address = 0; @@ -2800,6 +2839,108 @@ private: } } + // 428/426 for a decoder output image (Fire Emblem: Fortune's Weave's movie player blits each + // one, block-linear RGBA, format 0xd5, block height 16 GOBs, into a texture of its own). The + // reply follows our Eden tree, where the movies play: words 3-4 the GPU address, 5-6 the last + // byte's address, then the descriptor {1, 0, dimensions, .., 0x01000001, 1, offset, size} + // (offset 0 once mapped). 426 maps the image at its own address for the GPU: decoder pool + // memory is process memory (114) that no GPU mapping covers, and the blit read zeros. + bool QueryDecoderImage(HLERequestContext& ctx, u32 handle) { + const bool map = ctx.GetCommand() == 426; + u64 address{}; + u64 size{}; + u64 offset{}; + u32 dimensions{}; + bool register_mapping = false; + { + std::scoped_lock lk{m_objects->mutex}; + const auto image = m_objects->images.find(handle); + if (image == m_objects->images.end() || !image->second.decoder) { + return false; + } + const auto memory = m_objects->memories.find(image->second.memory); + if (memory == m_objects->memories.end() || memory->second.address == 0) { + return false; + } + image->second.gpu_read = true; + offset = image->second.offset; + size = image->second.size; + dimensions = image->second.preference[2]; + if (map) { + address = memory->second.address + offset; + register_mapping = image->second.gpu_maps++ == 0 && + !Nvidia::Devices::Nx2GpuMemoryRegistry::Get().DeviceAddressOf(address); + image->second.mapped_here |= register_mapping; + } + } + if (register_mapping) { + Nvidia::Devices::Nx2GpuMemoryRegistry::Get().Register(address, size); + } + LOG_INFO(Service, "nx2:gsv {} decoder image {} at {:#x}+{:#x}{}", ctx.GetCommand(), handle, + address, size, register_mapping ? ", mapped for the GPU" : ""); + std::array words{}; + if (map) { + const u64 last = address + size - 1; + words[0] = static_cast(address); + words[1] = static_cast(address >> 32); + words[2] = static_cast(last); + words[3] = static_cast(last >> 32); + } + u32* const desc = words.data() + 4; + desc[0] = 1; + desc[2] = dimensions; + desc[8] = 0x01000001; + desc[9] = 1; + desc[10] = map ? 0 : static_cast(offset); + desc[11] = static_cast(size); + IPC::ResponseBuilder rb{ctx, static_cast(2 + words.size())}; + rb.PushRaw(ResultSuccess.raw); + for (const u32 w : words) { + rb.Push(w); + } + return true; + } + + // 427(u32 handle, u32 address space, u64 address, u64 last address): undoes a 426. The last + // unmap of a decoder image drops the GPU mapping 426 made. + void UnmapNativeImage(HLERequestContext& ctx) { + const auto* w = ctx.CommandBuffer(); + const u32 handle = w[2]; + std::optional> unmap; + { + std::scoped_lock lk{m_objects->mutex}; + if (const auto image = m_objects->images.find(handle); + image != m_objects->images.end() && image->second.gpu_maps != 0 && + --image->second.gpu_maps == 0) { + unmap = TakeDecoderMapping(image->second); + } + } + LOG_DEBUG(Service, "nx2:gsv 427 image {} [{:#x}, {:#x}]", handle, Pair(w, 4), Pair(w, 6)); + DropDecoderMapping(unmap); + TipcReplyWords(ctx, {}); + } + + // The range 426 mapped for an image, if it did; called with m_objects->mutex held. + std::optional> TakeDecoderMapping(Nx2NativeObjects::Image& image) { + image.gpu_maps = 0; + if (!std::exchange(image.mapped_here, false)) { + return std::nullopt; + } + const auto memory = m_objects->memories.find(image.memory); + if (memory == m_objects->memories.end() || memory->second.address == 0) { + return std::nullopt; + } + return std::pair{memory->second.address + image.offset, image.size}; + } + + static void DropDecoderMapping(const std::optional>& range) { + if (range) { + auto& registry = Nvidia::Devices::Nx2GpuMemoryRegistry::Get(); + registry.Unmap(range->first, range->second); + registry.ReleaseBacking(range->first, range->second); + } + } + // 911(preference[16], u32 count, u32 memory id) (nn::codec::InitializeDecoderImagePool): // result, the id of the first of `count` decoder output images; the client names image i of // the pool id + i. They share the pool's memory, one image size apart. @@ -2858,8 +2999,15 @@ private: void DestroyNativeImage(HLERequestContext& ctx) { const u32 id = ctx.CommandBuffer()[2]; - std::scoped_lock lk{m_objects->mutex}; - m_objects->images.erase(id); + std::optional> unmap; + { + std::scoped_lock lk{m_objects->mutex}; + if (const auto image = m_objects->images.find(id); image != m_objects->images.end()) { + unmap = TakeDecoderMapping(image->second); + m_objects->images.erase(image); + } + } + DropDecoderMapping(unmap); TipcReplyWords(ctx, {}); } -- cgit v1.2.3 From b8d2c08d6efd312620b066cdab2f0238ebc94a26 Mon Sep 17 00:00:00 2001 From: a55uka <59313695+a55uka@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:41:05 +0000 Subject: nup_container: update record layouts to aligned NUP v1 fields --- src/core/file_sys/nup_container.cpp | 66 ++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 23 deletions(-) (limited to 'src/core') diff --git a/src/core/file_sys/nup_container.cpp b/src/core/file_sys/nup_container.cpp index efd08390a0..0e485b44ec 100644 --- a/src/core/file_sys/nup_container.cpp +++ b/src/core/file_sys/nup_container.cpp @@ -4,6 +4,7 @@ #include "core/file_sys/nup_container.h" #include +#include #include #include #include @@ -60,20 +61,31 @@ struct TocHeader { static_assert(sizeof(TocHeader) == TocHeaderSize); struct PackageRecord { - u64_le title_id; - u32_le root_dir; - u32_le exefs_dir; - u32_le romfs_dir; - u32_le version; - u8 kind; - u8 flags; - u16_le reserved; - u64_le meta_offset; - u32_le meta_size; - u32_le reserved2; - std::array reserved3; + u64_le title_id; // 0x00 + u32_le root_dir; // 0x08 + u32_le exefs_dir; // 0x0C + u32_le romfs_dir; // 0x10 + u32_le version; // 0x14 + u8 kind; // 0x18 + u8 flags; // 0x19 + u16_le reserved; // 0x1A + u32_le reserved2; // 0x1C + u64_le meta_offset; // 0x20 + u32_le meta_size; // 0x28 + std::array reserved3; // 0x2C }; static_assert(sizeof(PackageRecord) == PackageRecordSize); +static_assert(offsetof(PackageRecord, title_id) == 0x00); +static_assert(offsetof(PackageRecord, root_dir) == 0x08); +static_assert(offsetof(PackageRecord, exefs_dir) == 0x0C); +static_assert(offsetof(PackageRecord, romfs_dir) == 0x10); +static_assert(offsetof(PackageRecord, version) == 0x14); +static_assert(offsetof(PackageRecord, kind) == 0x18); +static_assert(offsetof(PackageRecord, flags) == 0x19); +static_assert(offsetof(PackageRecord, reserved) == 0x1A); +static_assert(offsetof(PackageRecord, reserved2) == 0x1C); +static_assert(offsetof(PackageRecord, meta_offset) == 0x20); +static_assert(offsetof(PackageRecord, meta_size) == 0x28); struct DirRecord { u32_le name_offset; @@ -85,19 +97,27 @@ struct DirRecord { static_assert(sizeof(DirRecord) == DirRecordSize); struct FileRecord { - u32_le name_offset; - u32_le name_length; - u32_le reserved; - u64_le size; - std::array sha256; - u64_le extent_offset; - u64_le extent_size; - u8 codec; - u8 codec_flags; - u16_le reserved2; - std::array reserved3; + u32_le name_offset; // 0x00 + u32_le name_length; // 0x04 + u64_le size; // 0x08 + u64_le extent_offset; // 0x10 + u64_le extent_size; // 0x18 + std::array sha256; // 0x20 + u8 codec; // 0x40 + u8 codec_flags; // 0x41 + u16_le reserved2; // 0x42 + std::array reserved3; // 0x44 }; static_assert(sizeof(FileRecord) == FileRecordSize); +static_assert(offsetof(FileRecord, name_offset) == 0x00); +static_assert(offsetof(FileRecord, name_length) == 0x04); +static_assert(offsetof(FileRecord, size) == 0x08); +static_assert(offsetof(FileRecord, extent_offset) == 0x10); +static_assert(offsetof(FileRecord, extent_size) == 0x18); +static_assert(offsetof(FileRecord, sha256) == 0x20); +static_assert(offsetof(FileRecord, codec) == 0x40); +static_assert(offsetof(FileRecord, codec_flags) == 0x41); +static_assert(offsetof(FileRecord, reserved2) == 0x42); struct TlvHeader { u16_le type; -- cgit v1.2.3 From e7973dae7e2ea44665ff4ec83d7214a5d2daa366 Mon Sep 17 00:00:00 2001 From: Duck912 <350528965+Duck912@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:39:54 -0500 Subject: nx2:vi2: sleep in the guest kernel while pacing presents The vi2 session is served by AM's thread on guest core 3, so the host sleep in PaceToVblank held that core for most of every frame. Game threads scheduled there only ran between presents: Fire Emblem: Fortune's Weave's loader got one RomFS read per frame (33 ms apart instead of about 4 ms), which doubled its load times. Assisted-by: Claude Opus 5.5 (debugging, implementation and drafting) Signed-off-by: Duck912 <350528965+Duck912@users.noreply.github.com> --- src/core/hle/service/nx2_gsv.h | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) (limited to 'src/core') diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index b8268b91a0..397e2aacb8 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -46,8 +46,10 @@ #include "core/hle/kernel/k_resource_limit.h" #include "core/hle/kernel/k_server_session.h" #include "core/hle/kernel/k_event.h" +#include "core/hle/kernel/k_hardware_timer.h" #include "core/hle/kernel/k_readable_event.h" #include "core/hle/kernel/k_session.h" +#include "core/hle/kernel/k_thread.h" #include "core/hle/kernel/k_transfer_memory.h" #include "core/hle/kernel/svc_results.h" #include "core/memory.h" @@ -1589,7 +1591,12 @@ private: // Running slower than the swap interval: show at once and don't bank the lost time. m_next_vblank = now; } else if (now < m_next_vblank) { - std::this_thread::sleep_until(m_next_vblank); + // Sleep this service thread in the guest kernel. It runs on guest core 3, and a host + // sleep held that core for the whole wait: game threads scheduled there ran only + // between presents (Fortune's Weave's loader read one RomFS block per frame). + const auto wait = std::chrono::duration_cast(m_next_vblank - now); + static_cast(Kernel::GetCurrentThread(kernel).Sleep( + kernel, kernel.HardwareTimer().GetTick() + wait.count() + 2)); } m_next_vblank += interval; } -- cgit v1.2.3 From 9acc324228b7028f947e053a08c18543f631e5ce Mon Sep 17 00:00:00 2001 From: Duck912 <350528965+Duck912@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:41:49 -0500 Subject: nx2:vi2: report the display's maximum buffer resolution Command 106 was auto-stubbed, and Fire Emblem: Fortune's Weave treats its empty reply as mode 0 and picks its 720p tier (rendering at 360p). Report mode 4 (1440p) docked and 2 (1080p, the screen) handheld; the game then renders at 720p and 540p and upscales to those. EDEN_VI2_MAX_RESOLUTION_MODE overrides the mode. Assisted-by: Claude Opus 5.5 (debugging, implementation and drafting) Signed-off-by: Duck912 <350528965+Duck912@users.noreply.github.com> --- src/core/hle/service/nx2_gsv.h | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) (limited to 'src/core') diff --git a/src/core/hle/service/nx2_gsv.h b/src/core/hle/service/nx2_gsv.h index 397e2aacb8..5a2963f179 100644 --- a/src/core/hle/service/nx2_gsv.h +++ b/src/core/hle/service/nx2_gsv.h @@ -1454,6 +1454,7 @@ public: // auto stub's empty success reply works, but logged two lines per call (a 50 MB log // in an 18 minute session). {105, &INx2Vi2Session::SucceedQuietly, "Unknown105 (display \"default\")"}, + {106, &INx2Vi2Session::GetBufferMaxResolution, "GetBufferMaxResolution"}, {228, &INx2Vi2Session::CancelClientBuffer, "CancelClientBuffer"}, {229, &INx2Vi2Session::PresentClientBuffer, "PresentClientBuffer"}, }); @@ -1847,6 +1848,29 @@ private: rb.Push(0); } + // The highest buffer resolution mode of the "default" display. Fire Emblem: Fortune's Weave picks + // its output from it: 1 -> 1280x720, 2 -> 1920x1080, 4 and 8 -> 2560x1440; 0 (the auto stub's + // reply), 3 and 5 fall back to 720p. Docked reports 4, handheld 2 (the screen is 1080p); + // EDEN_VI2_MAX_RESOLUTION_MODE= overrides. + void GetBufferMaxResolution(HLERequestContext& ctx) { + const auto* packet = ctx.CommandBuffer(); + const u64 name = u64{packet[2]} | (u64{packet[3]} << 32); + constexpr u64 DefaultDisplay = 0x00746c7561666564ULL; // "default" + Result result = Kernel::ResultInvalidCombination; + u64 mode{}; + if (packet[0] == 0x7a && packet[1] == 2) { + result = Result{ErrorModule::VI, 7}; + if (name == DefaultDisplay) { + static const char* forced = std::getenv("EDEN_VI2_MAX_RESOLUTION_MODE"); + mode = forced ? std::strtoull(forced, nullptr, 0) : Settings::IsDockedMode() ? 4 : 2; + result = ResultSuccess; + } + } + IPC::ResponseBuilder rb{ctx, 4}; + rb.Push(result.raw); + rb.Push(mode); + } + // The same reply as the auto stub (success, no data), without its log lines. void SucceedQuietly(HLERequestContext& ctx) { IPC::ResponseBuilder rb{ctx, 2}; -- cgit v1.2.3 From 06e0541e18e5bb8ab8d3d03660d3d82cb112f62b Mon Sep 17 00:00:00 2001 From: Duck912 <350528965+Duck912@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:43:51 -0500 Subject: am: decode the native swkbd config for library version 0x160012 Fire Emblem: Fortune's Weave opens the keyboard with library version 0x160012 and a 0x530-byte config. Read with the Switch 1 layout, the byte taken as use_utf8 belonged to initial_string_offset, so the name entry returned UTF-16 and the game kept only its first letter. The text prefix of the common part keeps its layout; the flags, lengths, string offsets and dictionary fields move (checked against the game's nn::swkbd::keyboardConfig2kbdConfig). Version 0x140010 is unchanged. Assisted-by: Claude Opus 5.5 (debugging, implementation and drafting) Signed-off-by: Duck912 <350528965+Duck912@users.noreply.github.com> --- .../am/frontend/applet_software_keyboard.cpp | 8 +++ .../am/frontend/applet_software_keyboard_types.h | 60 ++++++++++++++++++++++ 2 files changed, 68 insertions(+) (limited to 'src/core') diff --git a/src/core/hle/service/am/frontend/applet_software_keyboard.cpp b/src/core/hle/service/am/frontend/applet_software_keyboard.cpp index db42781e18..061394bff8 100644 --- a/src/core/hle/service/am/frontend/applet_software_keyboard.cpp +++ b/src/core/hle/service/am/frontend/applet_software_keyboard.cpp @@ -222,6 +222,14 @@ void SoftwareKeyboard::InitializeForeground() { sizeof(SwkbdConfigNew)); swkbd_config_common.use_utf8 = true; break; + case SwkbdAppletVersion::Version1441810: { + // Fire Emblem: Fortune's Weave asks for UTF-8 and keeps the reply up to the first NUL, so the + // Switch 1 layout's use_utf8 (a byte of the native initial_string_offset) left one letter. + const bool decoded = + DecodeNativeSwkbdConfig(swkbd_config_data, swkbd_config_common, swkbd_config_new); + ASSERT(decoded); + break; + } default: UNIMPLEMENTED_MSG("Unknown SwkbdConfig revision={} with size={}", swkbd_applet_version, swkbd_config_data.size()); diff --git a/src/core/hle/service/am/frontend/applet_software_keyboard_types.h b/src/core/hle/service/am/frontend/applet_software_keyboard_types.h index f263a704cd..8ff8391aa1 100644 --- a/src/core/hle/service/am/frontend/applet_software_keyboard_types.h +++ b/src/core/hle/service/am/frontend/applet_software_keyboard_types.h @@ -7,6 +7,9 @@ #pragma once #include +#include +#include +#include #include "common/bit_field.h" #include "common/common_funcs.h" @@ -31,6 +34,7 @@ enum class SwkbdAppletVersion : u32_le { Version393227 = 0x6000B, // 6.0.0 - 7.0.1 Version524301 = 0x8000D, // 8.0.0+ Version1310736 = 0x140010, // Switch 2 (Pokemon Pokopia): 0x530-byte config, UTF-8 text + Version1441810 = 0x160012, // Switch 2 (Fire Emblem: Fortune's Weave): native 0x530-byte config }; enum class SwkbdType : u32 { @@ -184,8 +188,64 @@ struct SwkbdConfigNew { }; static_assert(sizeof(SwkbdConfigNew) == 0x4C8 - sizeof(SwkbdConfigCommon), "SwkbdConfigNew has incorrect size."); + +// SwkbdAppletVersion 0x160012: the text prefix of the common part keeps its layout, the flags, +// lengths, string offsets and dictionary fields move (checked against +// nn::swkbd::keyboardConfig2kbdConfig in Fire Emblem: Fortune's Weave's sdk). +struct SwkbdConfigNative { + std::array common_prefix{}; + bool use_utf8{}; + bool use_blur_background{}; + u32 max_text_length{}; + u32 min_text_length{}; + SwkbdPasswordMode password_mode{}; + SwkbdTextDrawType text_draw_type{}; + u32 initial_string_offset{}; + u32 initial_string_length{}; + u32 user_dictionary_offset{}; + u32 user_dictionary_entries{}; + std::array customized_dictionary_set_entries{}; + u8 total_customized_dictionary_set_entries{}; + bool use_text_check{}; + bool disable_cancel_button{}; + u8 trigger{}; + std::array text_grouping{}; + INSERT_PADDING_BYTES(0x80); +}; +static_assert(sizeof(SwkbdConfigNative) == 0x530); +static_assert(offsetof(SwkbdConfigNative, use_text_check) == 0x48D); +static_assert(offsetof(SwkbdConfigNative, text_grouping) == 0x490); #pragma pack(pop) +inline bool DecodeNativeSwkbdConfig(std::span data, SwkbdConfigCommon& common, + SwkbdConfigNew& config) { + if (data.size() != sizeof(SwkbdConfigNative)) { + return false; + } + SwkbdConfigNative native{}; + std::memcpy(&native, data.data(), sizeof(native)); + common = {}; + config = {}; + std::memcpy(static_cast(&common), native.common_prefix.data(), native.common_prefix.size()); + common.enable_return_button = native.common_prefix[0x1B] != 0; + common.use_utf8 = native.use_utf8; + common.use_blur_background = native.use_blur_background; + common.max_text_length = native.max_text_length; + common.min_text_length = native.min_text_length; + common.password_mode = native.password_mode; + common.text_draw_type = native.text_draw_type; + common.initial_string_offset = native.initial_string_offset; + common.initial_string_length = native.initial_string_length; + common.user_dictionary_offset = native.user_dictionary_offset; + common.user_dictionary_entries = native.user_dictionary_entries; + common.use_text_check = native.use_text_check; + config.text_grouping = native.text_grouping; + config.customized_dictionary_set_entries = native.customized_dictionary_set_entries; + config.total_customized_dictionary_set_entries = native.total_customized_dictionary_set_entries; + config.disable_cancel_button = native.disable_cancel_button; + return true; +} + struct SwkbdTextCheck { SwkbdTextCheckResult text_check_result{}; std::array text_check_message{}; -- cgit v1.2.3 From 926e7ac6af102956b08cc053d81527ec3be855a2 Mon Sep 17 00:00:00 2001 From: Jonas <104419015+jojo-chaechae@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:39:49 +0600 Subject: file_sys: add a deterministic NUP packer, Convert to NUP in Qt and eden-cli --pack-nup (#271) * file_sys: add a NUP packer for loose dumps Packs a loose dump (exefs/romfs, base/, updates// and dlc///) into a NUP v1 container with the aligned record layout. Every package is kept, files are deduplicated by SHA-256 and compressed with zstd unless that does not shrink them, and files over 256 MiB are stored raw. Output is byte-identical on every platform: compression is single-threaded with every frame parameter set explicitly, and zstd is always bundled so all builds use the same version. A low-storage mode deletes each source file once its extent is synced and journaled, and an interrupted run resumes from the journal. Assisted-by: Claude Opus 5.5 (wrote the packer, its EdenPack-free dump detection, the aligned record layout and the deterministic zstd setup; ran the Raiders and Donkey Kong Bananza packs and boot checks) * tests: cover the NUP packer Round-trips a dump through NupContainer, checks standalone updates, a bare exefs/romfs dump, low-storage resume and overwrite rejection, and pins the hash of a packed dump so changes to the output are deliberate. Assisted-by: Claude Opus 5.5 (wrote the tests, including the pinned output hash) * qt: add Convert to NUP to the game list Shown for loose dumps. Asks for confirmation with the estimated space, offers low-storage mode, runs in the background with progress and cancel, and resumes an interrupted low-storage conversion. Assisted-by: Claude Opus 5.5 (wrote the Convert to NUP action and its progress and resume handling) * cli: add --pack-nup [out.nup] Assisted-by: Claude Opus 5.5 (wrote the --pack-nup option) --- CMakeLists.txt | 3 + src/core/CMakeLists.txt | 2 + src/core/file_sys/nup_writer.cpp | 1459 ++++++++++++++++++++++++++++++++++++++ src/core/file_sys/nup_writer.h | 60 ++ src/core/launch_params.cpp | 8 +- src/core/launch_params.h | 2 + src/tests/CMakeLists.txt | 1 + src/tests/core/nup_writer.cpp | 392 ++++++++++ src/yuzu/game/game_list.cpp | 14 + src/yuzu/game/game_list.h | 3 + src/yuzu/main_window.cpp | 158 +++++ src/yuzu/main_window.h | 8 + src/yuzu_cmd/yuzu.cpp | 50 ++ 13 files changed, 2159 insertions(+), 1 deletion(-) create mode 100644 src/core/file_sys/nup_writer.cpp create mode 100644 src/core/file_sys/nup_writer.h create mode 100644 src/tests/core/nup_writer.cpp (limited to 'src/core') diff --git a/CMakeLists.txt b/CMakeLists.txt index ba44aa9d47..04d0f17d74 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -463,6 +463,9 @@ if (lz4_ADDED) endif() # zstd +# Always bundled: NUP archives are only byte-identical across platforms when every build +# compresses with the same zstd version. +set(zstd_FORCE_BUNDLED ON) AddJsonPackage(zstd) if (zstd_ADDED) diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 846ea7a25d..fd45642876 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -134,6 +134,8 @@ add_library(core STATIC file_sys/registered_cache.h file_sys/nup_container.cpp file_sys/nup_container.h + file_sys/nup_writer.cpp + file_sys/nup_writer.h file_sys/romfs.cpp file_sys/romfs.h file_sys/romfs_factory.cpp diff --git a/src/core/file_sys/nup_writer.cpp b/src/core/file_sys/nup_writer.cpp new file mode 100644 index 0000000000..8c7728ed01 --- /dev/null +++ b/src/core/file_sys/nup_writer.cpp @@ -0,0 +1,1459 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "core/file_sys/nup_writer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "common/fs/file.h" +#include "common/fs/path_util.h" +#include "core/file_sys/control_metadata.h" +#include "core/file_sys/vfs/vfs_real.h" +#include "core/loader/deconstructed_rom_directory.h" + +namespace FileSys::Nup { +namespace { + +using Json = nlohmann::json; +namespace fs = std::filesystem; + +constexpr std::array Magic{'N', 'U', 'P', 0x01}; +constexpr std::array TocMagic{'T', 'O', 'C', 0x01}; +constexpr u64 SuperblockSize = 0x80; +constexpr u64 TocHeaderSize = 0x30; +constexpr u64 PackageRecordSize = 0x40; +constexpr u64 DirRecordSize = 0x18; +constexpr u64 FileRecordSize = 0x50; +constexpr u64 ExtentAlignment = 16; +constexpr u32 NoDir = 0xFFFFFFFF; + +constexpr u8 KindBase = 0; +constexpr u8 KindUpdate = 1; +constexpr u8 KindAddon = 2; +constexpr u8 FlagStandaloneUpdate = 0x01; +constexpr u8 CodecRaw = 0; +constexpr u8 CodecZstd = 1; + +constexpr u16 TlvNpdm = 0x0001; +constexpr u16 TlvNacp = 0x0002; +constexpr u16 TlvIcon = 0x0003; +constexpr u16 TlvDisplayVersion = 0x0004; +constexpr u16 TlvTitleName = 0x0005; +constexpr u16 TlvPublisher = 0x0006; +constexpr u16 TlvSourceDump = 0x0008; + +constexpr int JournalVersion = 1; +constexpr u64 ProgressStep = 64ULL * 1024 * 1024; +constexpr size_t MaxChunkFiles = 4096; +constexpr size_t ReadBufferSize = 4 * 1024 * 1024; + +class PackError : public std::runtime_error { +public: + PackError(PackResult result_, const std::string& message) + : std::runtime_error(message), result{result_} {} + PackResult result; +}; + +u64 AlignUp(u64 value, u64 alignment) { + return (value + alignment - 1) / alignment * alignment; +} + +std::string ToUtf8(const fs::path& path) { + const auto u8 = path.generic_u8string(); + return {u8.begin(), u8.end()}; +} + +fs::path FromUtf8(const std::string& value) { + return fs::path{std::u8string{value.begin(), value.end()}}; +} + +std::string Lower(std::string value) { + std::ranges::transform(value, value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; +} + +fs::path WithSuffix(fs::path path, std::string_view suffix) { + path += suffix; + return path; +} + +bool IsFile(const fs::path& path) { + std::error_code ec; + return fs::is_regular_file(path, ec); +} + +fs::path FindDirectory(const fs::path& parent, std::string_view name) { + std::error_code ec; + if (parent.empty() || !fs::is_directory(parent, ec)) { + return {}; + } + const auto wanted = Lower(std::string{name}); + for (const auto& entry : fs::directory_iterator(parent, ec)) { + if (entry.is_directory(ec) && Lower(ToUtf8(entry.path().filename())) == wanted) { + return entry.path(); + } + } + return {}; +} + +std::optional ParseDecimal(const std::string& name) { + u64 value{}; + const auto* end = name.data() + name.size(); + const auto [ptr, ec] = std::from_chars(name.data(), end, value); + if (name.empty() || ec != std::errc{} || ptr != end) { + return std::nullopt; + } + return value; +} + +// The exefs folder a dump boots from: the newest updates/, else base, else the root. +// Each must hold main, main.npdm and a romfs folder next to it or under base. +bool HasBootableExeFS(const fs::path& root) { + const auto bootable = [](const fs::path& exefs) { + return !exefs.empty() && IsFile(exefs / "main") && IsFile(exefs / "main.npdm"); + }; + fs::path exefs; + std::optional best_version; + if (const auto updates = FindDirectory(root, "updates"); !updates.empty()) { + std::error_code ec; + for (const auto& entry : fs::directory_iterator(updates, ec)) { + const auto version = ParseDecimal(ToUtf8(entry.path().filename())); + const auto candidate = FindDirectory(entry.path(), "exefs"); + if (version && bootable(candidate) && (!best_version || *version > *best_version)) { + best_version = version; + exefs = candidate; + } + } + } + const auto base = FindDirectory(root, "base"); + if (exefs.empty()) { + exefs = FindDirectory(base, "exefs"); + } + if (exefs.empty()) { + exefs = FindDirectory(root, "exefs"); + } + if (!bootable(exefs)) { + return false; + } + return !FindDirectory(exefs.parent_path(), "romfs").empty() || + !FindDirectory(base, "romfs").empty(); +} + +std::optional ParseTitleId(const std::string& name) { + u64 value{}; + const auto* end = name.data() + name.size(); + const auto [ptr, ec] = std::from_chars(name.data(), end, value, 16); + if (name.size() != 16 || ec != std::errc{} || ptr != end) { + return std::nullopt; + } + return value; +} + +std::string ToHex(std::span data) { + static constexpr char digits[] = "0123456789abcdef"; + std::string out; + out.reserve(data.size() * 2); + for (const u8 byte : data) { + out += digits[byte >> 4]; + out += digits[byte & 0xF]; + } + return out; +} + +std::vector FromHex(const std::string& hex) { + if (hex.size() % 2 != 0) { + throw PackError(PackResult::IoError, "malformed journal"); + } + std::vector out(hex.size() / 2); + for (size_t i = 0; i < out.size(); ++i) { + const auto [ptr, ec] = std::from_chars(hex.data() + i * 2, hex.data() + i * 2 + 2, out[i], 16); + if (ec != std::errc{} || ptr != hex.data() + i * 2 + 2) { + throw PackError(PackResult::IoError, "malformed journal"); + } + } + return out; +} + +std::vector ReadWholeFile(const fs::path& path) { + Common::FS::IOFile in{path, Common::FS::FileAccessMode::Read}; + if (!in.IsOpen()) { + return {}; + } + std::vector data(in.GetSize()); + if (in.ReadSpan(std::span{data}) != data.size()) { + return {}; + } + return data; +} + +// Same as pack.py: the program ID at ACI0 + 0x10, whatever the NPDM header revision. +std::optional NpdmProgramId(std::span npdm) { + static constexpr std::array aci0{'A', 'C', 'I', '0'}; + const auto it = std::search(npdm.begin(), npdm.end(), aci0.begin(), aci0.end()); + const auto offset = static_cast(it - npdm.begin()); + if (it == npdm.end() || offset + 0x18 > npdm.size()) { + return std::nullopt; + } + u64 id = 0; + for (size_t i = 0; i < 8; ++i) { + id |= static_cast(npdm[offset + 0x10 + i]) << (8 * i); + } + return id; +} + +void PutLE(std::vector& buffer, size_t offset, u64 value, size_t bytes) { + for (size_t i = 0; i < bytes; ++i) { + buffer[offset + i] = static_cast(value >> (8 * i)); + } +} + +void PutBytes(std::vector& buffer, size_t offset, std::span bytes) { + std::memcpy(buffer.data() + offset, bytes.data(), bytes.size()); +} + +using Digest = std::array; + +class Sha256 { +public: + Sha256() : ctx{EVP_MD_CTX_new()} { + if (!ctx) { + throw PackError(PackResult::IoError, "cannot initialise SHA-256"); + } + Reset(); + } + ~Sha256() { + EVP_MD_CTX_free(ctx); + } + Sha256(const Sha256&) = delete; + Sha256& operator=(const Sha256&) = delete; + + void Reset() { + if (EVP_DigestInit_ex(ctx, EVP_sha256(), nullptr) != 1) { + throw PackError(PackResult::IoError, "cannot initialise SHA-256"); + } + } + + void Update(const void* data, size_t size) { + EVP_DigestUpdate(ctx, data, size); + } + + Digest Final() { + Digest digest{}; + unsigned int length{}; + EVP_DigestFinal_ex(ctx, digest.data(), &length); + return digest; + } + +private: + EVP_MD_CTX* ctx; +}; + +class ZstdContext { +public: + // Archives must be byte-identical across platforms, so compression is always + // single-threaded (zstd's multithreaded frames differ) and every frame parameter is set + // explicitly instead of relying on library defaults. + explicit ZstdContext(int level) : cctx{ZSTD_createCCtx()} { + if (!cctx || ZSTD_isError(ZSTD_CCtx_setParameter(cctx, ZSTD_c_compressionLevel, level)) || + ZSTD_isError(ZSTD_CCtx_setParameter(cctx, ZSTD_c_nbWorkers, 0)) || + ZSTD_isError(ZSTD_CCtx_setParameter(cctx, ZSTD_c_contentSizeFlag, 1)) || + ZSTD_isError(ZSTD_CCtx_setParameter(cctx, ZSTD_c_checksumFlag, 0)) || + ZSTD_isError(ZSTD_CCtx_setParameter(cctx, ZSTD_c_dictIDFlag, 0))) { + throw PackError(PackResult::IoError, "cannot initialise zstd"); + } + } + ~ZstdContext() { + ZSTD_freeCCtx(cctx); + } + ZstdContext(const ZstdContext&) = delete; + ZstdContext& operator=(const ZstdContext&) = delete; + + // The pledged size makes zstd record the content size in the frame header, which the + // format requires. + void Begin(u64 size) { + if (ZSTD_isError(ZSTD_CCtx_reset(cctx, ZSTD_reset_session_only)) || + ZSTD_isError(ZSTD_CCtx_setPledgedSrcSize(cctx, size))) { + throw PackError(PackResult::IoError, "cannot start zstd frame"); + } + } + + ZSTD_CCtx* get() const { + return cctx; + } + +private: + ZSTD_CCtx* cctx; +}; + +// One package and the metadata it is written with. The plan is everything that must survive a +// low-storage run deleting its sources, so it is what the journal stores. +struct PlanPackage { + u8 kind{}; + u8 flags{}; + u64 title_id{}; + u32 version{}; + std::vector meta; +}; + +struct PlanEntry { + u32 package{}; + std::string path; // inside the package tree, e.g. "romfs/Data/file.bin" + std::string source; // relative to the dump root + bool directory{}; +}; + +struct Plan { + std::vector packages; + std::vector entries; +}; + +struct Extent { + u64 size{}; + Digest sha256{}; + u64 offset{}; + u64 stored_size{}; + u8 codec{}; +}; + +struct Layout { + struct Dir { + std::string name; + u32 first_child{}; + u32 child_count{}; + }; + struct File { + std::string name; + size_t entry{}; + }; + struct Package { + u32 root_dir{}; + u32 exefs_dir = NoDir; + u32 romfs_dir = NoDir; + }; + std::vector dirs; + std::vector files; + std::vector children; // FileTag | file index, or dir index + std::vector packages; + std::string pool; + std::unordered_map pool_index; + u64 toc_size{}; + u64 blob_offset{}; + + static constexpr u64 FileTag = 1ULL << 32; + + u32 Intern(const std::string& name) { + const auto [it, inserted] = pool_index.try_emplace(name, static_cast(pool.size())); + if (inserted) { + pool += name; + } + return it->second; + } +}; + +// Children sorted by the bytes of their names: std::string compares as unsigned char. +struct TreeDir { + struct Child { + std::unique_ptr dir; + size_t file_entry{}; + }; + std::string name; + std::map children; + + TreeDir* Descend(const std::string& component) { + auto& child = children[component]; + if (!child.dir) { + child.dir = std::make_unique(); + child.dir->name = component; + } + return child.dir.get(); + } +}; + +// Lays out nodes as pack.py does: directories in pre-order, each directory's child slots +// contiguous, its files numbered in slot order before its subdirectories are visited. +u32 AssignDir(TreeDir& dir, Layout& layout) { + const auto id = static_cast(layout.dirs.size()); + layout.dirs.push_back({dir.name, static_cast(layout.children.size()), + static_cast(dir.children.size())}); + const size_t slot_base = layout.children.size(); + layout.children.resize(slot_base + dir.children.size()); + size_t slot = slot_base; + for (auto& [name, child] : dir.children) { + if (!child.dir) { + layout.children[slot] = Layout::FileTag | layout.files.size(); + layout.files.push_back({name, child.file_entry}); + } + ++slot; + } + slot = slot_base; + for (auto& [name, child] : dir.children) { + if (child.dir) { + layout.children[slot] = AssignDir(*child.dir, layout); + } + ++slot; + } + return id; +} + +Layout BuildLayout(const Plan& plan) { + std::vector roots(plan.packages.size()); + for (size_t i = 0; i < plan.entries.size(); ++i) { + const auto& entry = plan.entries[i]; + TreeDir* dir = &roots.at(entry.package); + size_t start = 0; + while (true) { + const auto slash = entry.path.find('/', start); + const auto component = entry.path.substr(start, slash - start); + if (slash == std::string::npos) { + if (entry.directory) { + dir->Descend(component); + } else { + auto& child = dir->children[component]; + if (child.dir) { + throw PackError(PackResult::InvalidSource, + "a file and a folder share the name " + entry.path); + } + child.file_entry = i; + } + break; + } + dir = dir->Descend(component); + start = slash + 1; + } + } + + Layout layout; + for (auto& root : roots) { + Layout::Package package; + package.root_dir = AssignDir(root, layout); + const auto& root_dir = layout.dirs[package.root_dir]; + for (u32 slot = root_dir.first_child; slot < root_dir.first_child + root_dir.child_count; + ++slot) { + const u64 child = layout.children[slot]; + if (child & Layout::FileTag) { + continue; + } + const auto id = static_cast(child); + if (layout.dirs[id].name == "exefs") { + package.exefs_dir = id; + } else if (layout.dirs[id].name == "romfs") { + package.romfs_dir = id; + } + } + layout.packages.push_back(package); + } + + if (layout.dirs.size() + layout.files.size() >= NoDir) { + throw PackError(PackResult::InvalidSource, "the dump has too many files"); + } + for (const auto& dir : layout.dirs) { + layout.Intern(dir.name); + } + for (const auto& file : layout.files) { + layout.Intern(file.name); + } + if (layout.pool.size() > std::numeric_limits::max()) { + throw PackError(PackResult::InvalidSource, "the dump's file names are too long"); + } + + const u64 unpadded = TocHeaderSize + plan.packages.size() * PackageRecordSize + + layout.dirs.size() * DirRecordSize + layout.files.size() * FileRecordSize + + layout.children.size() * sizeof(u32) + layout.pool.size(); + layout.toc_size = AlignUp(unpadded, ExtentAlignment); + layout.blob_offset = AlignUp(SuperblockSize + layout.toc_size, ExtentAlignment); + return layout; +} + +std::vector SerializeTlvs(std::vector>> records) { + std::ranges::stable_sort(records, {}, &std::pair>::first); + std::vector out; + for (const auto& [type, payload] : records) { + const size_t at = out.size(); + out.resize(at + 8 + payload.size()); + PutLE(out, at, type, 2); + PutLE(out, at + 4, payload.size(), 4); + PutBytes(out, at + 8, payload); + } + return out; +} + +std::vector StringBytes(const std::string& value) { + return {value.begin(), value.end()}; +} + +// The dump's own loader supplies control data and the display version, the same way a loose +// dump reports them when it boots (control.nacp beside it, else the RomFS's version tag). +std::unique_ptr OpenLoader( + RealVfsFilesystem& vfs, const fs::path& package_dir) { + const auto exefs = FindDirectory(package_dir, "exefs"); + if (exefs.empty() || !IsFile(exefs / "main")) { + return nullptr; + } + auto main = vfs.OpenFile(Common::FS::PathToUTF8String(exefs / "main"), OpenMode::Read); + if (!main) { + return nullptr; + } + return std::make_unique(std::move(main)); +} + +class Discovery { +public: + Discovery(const fs::path& dump_root_, std::vector excluded_) + : dump_root{dump_root_}, excluded{std::move(excluded_)} {} + + Plan Run() { + const auto base = FindDirectory(dump_root, "base"); + const auto updates = FindDirectory(dump_root, "updates"); + const auto dlc = FindDirectory(dump_root, "dlc"); + + std::optional base_title_id; + if (!base.empty() && HasContent(base)) { + base_title_id = AddPackage(KindBase, base, 0, {}); + } else if (updates.empty() && HasContent(dump_root)) { + base_title_id = AddPackage(KindBase, dump_root, 0, {"base", "updates", "dlc"}); + } + + std::vector> versions; + std::error_code ec; + if (!updates.empty()) { + for (const auto& entry : fs::directory_iterator(updates, ec)) { + const auto version = ParseDecimal(ToUtf8(entry.path().filename())); + if (version && entry.is_directory(ec) && HasContent(entry.path())) { + if (*version > std::numeric_limits::max()) { + throw PackError(PackResult::InvalidSource, + "update version out of range: " + ToUtf8(entry.path())); + } + versions.emplace_back(*version, entry.path()); + } + } + } + std::ranges::sort(versions); + const u8 update_flags = base_title_id ? 0 : FlagStandaloneUpdate; + for (const auto& [version, path] : versions) { + AddPackage(KindUpdate, path, static_cast(version), {}, update_flags, + base_title_id.value_or(0)); + } + + if (!dlc.empty()) { + std::vector> addons; + for (const auto& title : fs::directory_iterator(dlc, ec)) { + const auto title_name = ToUtf8(title.path().filename()); + if (!ParseTitleId(title_name) || !title.is_directory(ec)) { + continue; + } + for (const auto& content : fs::directory_iterator(title.path(), ec)) { + if (content.is_directory(ec) && HasContent(content.path())) { + addons.emplace_back(title_name + "/" + ToUtf8(content.path().filename()), + content.path()); + } + } + } + std::ranges::sort(addons); + for (const auto& [name, path] : addons) { + AddPackage(KindAddon, path, 0, {}, 0, *ParseTitleId(name.substr(0, 16))); + } + } + + if (plan.packages.empty()) { + throw PackError(PackResult::InvalidSource, "no packages found in the dump"); + } + AddApplicationMetadata(); + for (size_t i = 0; i < plan.packages.size(); ++i) { + auto& tlvs = package_tlvs[i]; + tlvs.emplace_back(TlvSourceDump, StringBytes(ToUtf8(dump_root.filename()))); + plan.packages[i].meta = SerializeTlvs(std::move(tlvs)); + } + return std::move(plan); + } + +private: + static bool HasContent(const fs::path& dir) { + return !FindDirectory(dir, "exefs").empty() || !FindDirectory(dir, "romfs").empty(); + } + + // Returns the package's title ID: the NPDM program ID, else the folder's or base's. + u64 AddPackage(u8 kind, const fs::path& dir, u32 version, + const std::vector& skip, u8 flags = 0, u64 fallback_id = 0) { + const auto index = static_cast(plan.packages.size()); + PlanPackage package{kind, flags, fallback_id, version, {}}; + auto& tlvs = package_tlvs.emplace_back(); + + const auto exefs = FindDirectory(dir, "exefs"); + const auto romfs = FindDirectory(dir, "romfs"); + fs::path npdm_path = exefs.empty() ? fs::path{} : exefs / "main.npdm"; + if (!IsFile(npdm_path)) { + npdm_path = dir / "main.npdm"; + } + if (IsFile(npdm_path)) { + auto npdm = ReadWholeFile(npdm_path); + if (const auto id = NpdmProgramId(npdm); kind != KindAddon && id && *id != 0) { + package.title_id = *id; + } + tlvs.emplace_back(TlvNpdm, std::move(npdm)); + } + + if (kind != KindAddon) { + if (auto loader = OpenLoader(vfs, dir)) { + std::string display_version; + if (loader->ReadDisplayVersion(display_version) == Loader::ResultStatus::Success && + !display_version.empty()) { + tlvs.emplace_back(TlvDisplayVersion, StringBytes(display_version)); + } + loaders.emplace_back(index, std::move(loader)); + } + } + plan.packages.push_back(std::move(package)); + + if (!exefs.empty()) { + CollectTree(index, exefs, "exefs"); + } + if (!romfs.empty()) { + CollectTree(index, romfs, "romfs"); + } + std::error_code ec; + for (const auto& entry : fs::directory_iterator(dir, ec)) { + const auto name = ToUtf8(entry.path().filename()); + const auto lower = Lower(name); + if (entry.path() == exefs || entry.path() == romfs || + std::ranges::find(skip, lower) != skip.end()) { + continue; + } + if (entry.is_directory(ec)) { + CollectTree(index, entry.path(), "data/" + name); + } else { + AddFile(index, entry, "data/" + name); + } + } + return plan.packages.back().title_id; + } + + // Names, icon and control data belong to the base package, or the first one without a base. + // Our dumps rarely carry a control folder; when one does, the base's own wins, then updates. + void AddApplicationMetadata() { + const auto app = static_cast(std::distance( + plan.packages.begin(), std::ranges::find(plan.packages, KindBase, &PlanPackage::kind))); + const u32 target = app < plan.packages.size() ? app : 0; + std::ranges::stable_sort(loaders, [target](const auto& a, const auto& b) { + return (a.first == target) > (b.first == target); + }); + + auto& tlvs = package_tlvs[target]; + bool have_control = false; + bool have_icon = false; + for (const auto& [index, loader] : loaders) { + NACP control; + if (!have_control && + loader->ReadControlData(control) == Loader::ResultStatus::Success) { + have_control = true; + tlvs.emplace_back(TlvNacp, control.GetRawBytes()); + if (const auto name = control.GetApplicationName(); !name.empty()) { + tlvs.emplace_back(TlvTitleName, StringBytes(name)); + } + if (const auto publisher = control.GetDeveloperName(); !publisher.empty()) { + tlvs.emplace_back(TlvPublisher, StringBytes(publisher)); + } + } + std::vector icon; + if (!have_icon && loader->ReadIcon(icon) == Loader::ResultStatus::Success && + !icon.empty()) { + have_icon = true; + tlvs.emplace_back(TlvIcon, std::move(icon)); + } + } + } + + bool IsExcluded(const fs::path& path) const { + const auto normal = fs::absolute(path).lexically_normal(); + return std::ranges::find(excluded, normal) != excluded.end(); + } + + void AddFile(u32 package, const fs::directory_entry& entry, const std::string& path) { + std::error_code ec; + if (entry.is_symlink(ec)) { + throw PackError(PackResult::InvalidSource, + "symlinks are not supported: " + ToUtf8(entry.path())); + } + if (!entry.is_regular_file(ec)) { + throw PackError(PackResult::InvalidSource, + "unsupported file type: " + ToUtf8(entry.path())); + } + // Dump-tool listings are excluded by the spec. + if (entry.path().filename() == ".listing" || IsExcluded(entry.path())) { + return; + } + plan.entries.push_back({package, path, Relative(entry.path()), false}); + } + + void CollectTree(u32 package, const fs::path& root, const std::string& prefix) { + plan.entries.push_back({package, prefix, Relative(root), true}); + for (auto it = fs::recursive_directory_iterator(root); + it != fs::recursive_directory_iterator(); ++it) { + const auto& entry = *it; + const auto path = prefix + "/" + ToUtf8(fs::relative(entry.path(), root)); + if (!entry.is_symlink() && entry.is_directory()) { + plan.entries.push_back({package, path, Relative(entry.path()), true}); + } else { + AddFile(package, entry, path); + } + } + } + + std::string Relative(const fs::path& path) const { + return ToUtf8(fs::relative(path, dump_root)); + } + + fs::path dump_root; + std::vector excluded; + RealVfsFilesystem vfs; + Plan plan; + std::vector>>> package_tlvs; + std::vector>> + loaders; +}; + +Json PlanToJson(const Plan& plan) { + Json packages = Json::array(); + for (const auto& package : plan.packages) { + packages.push_back(Json{{"kind", package.kind}, + {"flags", package.flags}, + {"title_id", package.title_id}, + {"version", package.version}, + {"meta", ToHex(package.meta)}}); + } + Json entries = Json::array(); + for (const auto& entry : plan.entries) { + entries.push_back(Json{{"package", entry.package}, + {"path", entry.path}, + {"source", entry.source}, + {"directory", entry.directory}}); + } + return Json{{"format", "nup-journal"}, + {"version", JournalVersion}, + {"packages", packages}, + {"entries", entries}}; +} + +Plan PlanFromJson(const Json& json) { + if (json.at("format") != "nup-journal" || json.at("version") != JournalVersion) { + throw PackError(PackResult::IoError, "unsupported journal"); + } + Plan plan; + for (const auto& package : json.at("packages")) { + plan.packages.push_back({package.at("kind").get(), package.at("flags").get(), + package.at("title_id").get(), + package.at("version").get(), + FromHex(package.at("meta").get())}); + } + for (const auto& entry : json.at("entries")) { + plan.entries.push_back({entry.at("package").get(), entry.at("path").get(), + entry.at("source").get(), + entry.at("directory").get()}); + } + return plan; +} + +void RemoveFile(const fs::path& path) { + std::error_code ec; + fs::remove(path, ec); +} + +bool IsEmptyDirectory(const fs::path& path) { + std::error_code ec; + return fs::is_directory(path, ec) && fs::is_empty(path, ec) && !ec; +} + +void RemoveEmptyTree(const fs::path& root) { + std::error_code ec; + std::vector directories; + for (auto it = fs::recursive_directory_iterator(root, ec); + !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) { + if (it->is_directory(ec)) { + directories.push_back(it->path()); + } + } + for (auto it = directories.rbegin(); it != directories.rend(); ++it) { + if (IsEmptyDirectory(*it)) { + fs::remove(*it, ec); + } + } + if (IsEmptyDirectory(root)) { + fs::remove(root, ec); + } +} + +void RemoveEmptyParents(fs::path path, const fs::path& dump_root) { + while (!path.empty() && path.has_relative_path()) { + std::error_code ec; + if (fs::equivalent(path, dump_root, ec) || ec || !IsEmptyDirectory(path)) { + break; + } + fs::remove(path, ec); + path = path.parent_path(); + } +} + +class Packer { +public: + Packer(const fs::path& dump_root_, const fs::path& archive_, const PackOptions& options_, + const std::function& progress_, + const std::atomic_bool* cancel_) + : dump_root{dump_root_}, archive{archive_}, part{WithSuffix(archive_, ".part")}, + journal{WithSuffix(archive_, ".journal")}, options{options_}, progress_cb{progress_}, + cancel{cancel_} {} + + PackResult Run() { + std::error_code ec; + if (fs::exists(archive, ec)) { + throw PackError(PackResult::IoError, "archive already exists: " + ToUtf8(archive)); + } + // A journal without a complete plan line was cut short before anything was deleted. + const bool resume = + options.delete_sources && HasResumableJournal(archive) && LoadJournal(); + if (resume) { + OpenForResume(); + } else { + if (!IsLooseDump(dump_root)) { + throw PackError(PackResult::InvalidSource, + "expected exefs/main, exefs/main.npdm and a romfs folder"); + } + std::vector excluded; + for (const auto& path : {archive, part, journal}) { + excluded.push_back(fs::absolute(path).lexically_normal()); + } + plan = Discovery{dump_root, std::move(excluded)}.Run(); + layout = BuildLayout(plan); + OpenNew(); + } + extents.resize(layout.files.size()); + done.resize(layout.files.size()); + for (const auto& [index, extent] : journaled) { + extents[index] = extent; + done[index] = true; + Remember(extent); + } + CountTotals(); + Report(); + + for (size_t i = 0; i < layout.files.size(); ++i) { + if (done[i]) { + continue; + } + PackFile(i); + if (options.delete_sources && + (chunk_bytes >= options.chunk_bytes || chunk.size() >= MaxChunkFiles)) { + Checkpoint(); + } + } + if (options.delete_sources) { + Checkpoint(); + } + + Finish(); + out.Close(); + fs::rename(part, archive, ec); + if (ec) { + throw PackError(PackResult::IoError, "cannot rename archive: " + ec.message()); + } + + if (options.delete_sources) { + RemoveFile(journal); + RemoveSourceFolders(); + } + return PackResult::Success; + } + + void Abort() { + out.Close(); + if (!options.delete_sources) { + RemoveFile(part); + } + } + +private: + void OpenNew() { + RemoveFile(journal); + out.Open(part, Common::FS::FileAccessMode::Write); + if (!out.IsOpen()) { + throw PackError(PackResult::IoError, "cannot create " + ToUtf8(part)); + } + WriteZeros(layout.blob_offset); + cursor = layout.blob_offset; + if (options.delete_sources) { + AppendJournal(PlanToJson(plan).dump() + '\n'); + } + } + + bool LoadJournal() { + const auto contents = + Common::FS::ReadStringFromFile(journal, Common::FS::FileType::BinaryFile); + size_t start = 0; + bool have_plan = false; + while (start < contents.size()) { + const auto line_end = contents.find('\n', start); + if (line_end == std::string::npos) { + break; + } + const auto line = std::string_view{contents}.substr(start, line_end - start); + start = line_end + 1; + Json json; + try { + json = Json::parse(line); + } catch (const Json::exception&) { + // Only the tail can be torn: the journal is synced before sources are deleted. + break; + } + if (!have_plan) { + plan = PlanFromJson(json); + layout = BuildLayout(plan); + resume_end = layout.blob_offset; + have_plan = true; + continue; + } + const auto index = json.at("file").get(); + const auto sha = FromHex(json.at("sha256").get()); + if (index >= layout.files.size() || sha.size() != 32) { + throw PackError(PackResult::IoError, "malformed journal"); + } + Extent extent{json.at("size").get(), {}, json.at("offset").get(), + json.at("stored_size").get(), json.at("codec").get()}; + std::ranges::copy(sha, extent.sha256.begin()); + resume_end = std::max(resume_end, extent.offset + extent.stored_size); + journaled.emplace_back(index, extent); + } + return have_plan; + } + + void OpenForResume() { + const u64 end = resume_end; + out.Open(part, Common::FS::FileAccessMode::ReadWrite); + if (!out.IsOpen()) { + throw PackError(PackResult::IoError, "cannot open " + ToUtf8(part)); + } + if (out.GetSize() < end) { + throw PackError(PackResult::IoError, "partial archive is shorter than its journal; " + "delete " + + ToUtf8(part) + " and " + ToUtf8(journal) + + " to start over"); + } + if (!out.SetSize(end) || !out.Seek(static_cast(end))) { + throw PackError(PackResult::IoError, "cannot truncate " + ToUtf8(part)); + } + cursor = end; + + // Journaled before an interruption, but the delete never happened. + for (const auto& [index, extent] : journaled) { + RemoveFile(SourceOf(index)); + } + } + + fs::path SourceOf(size_t file_index) const { + return dump_root / FromUtf8(plan.entries[layout.files[file_index].entry].source); + } + + void CountTotals() { + for (size_t i = 0; i < layout.files.size(); ++i) { + ++progress.files_total; + if (done[i]) { + progress.bytes_total += extents[i].size; + progress.bytes_done += extents[i].size; + ++progress.files_done; + } else { + std::error_code ec; + const auto size = fs::file_size(SourceOf(i), ec); + if (ec) { + throw PackError(PackResult::InvalidSource, + "missing source file: " + ToUtf8(SourceOf(i))); + } + progress.bytes_total += size; + } + } + } + + void Remember(const Extent& extent) { + if (extent.size == 0) { + return; + } + sizes_seen.insert(extent.size); + by_hash.try_emplace(extent.sha256, extent); + } + + bool Cancelled() const { + return cancel && cancel->load(); + } + + void WriteZeros(u64 count) { + static constexpr std::array zeros{}; + while (count > 0) { + const auto n = static_cast(std::min(count, zeros.size())); + Write(zeros.data(), n); + count -= n; + } + } + + void Write(const u8* data, size_t size) { + if (out.WriteSpan(std::span{data, size}) != size) { + throw PackError(PackResult::IoError, "write failed, the disk may be full"); + } + } + + void AddProgress(u64 bytes) { + progress.bytes_done += bytes; + if (progress.bytes_done - last_report >= ProgressStep) { + Report(); + } + } + + // Reads the whole source through `sink`, checking that it still has the expected size. A file + // read more than once (hashed for dedup, or compressed and then stored raw) counts once. + template + void ReadSource(Common::FS::IOFile& in, u64 size, Sink&& sink) { + if (!in.Seek(0)) { + throw PackError(PackResult::IoError, "cannot seek source"); + } + buffer.resize(ReadBufferSize); + u64 total = 0; + while (true) { + if (Cancelled()) { + throw PackError(PackResult::Cancelled, "cancelled"); + } + const auto read = in.ReadSpan(std::span{buffer}); + if (read == 0) { + break; + } + total += read; + if (total > size) { + break; + } + if (total > file_counted) { + AddProgress(total - file_counted); + file_counted = total; + } + if (!sink(buffer.data(), read)) { + return; + } + } + if (total != size) { + throw PackError(PackResult::IoError, "a source file changed while packing"); + } + } + + Digest HashSource(Common::FS::IOFile& in, u64 size) { + hash.Reset(); + ReadSource(in, size, [this](const u8* data, size_t n) { + hash.Update(data, n); + return true; + }); + return hash.Final(); + } + + ZstdContext& Compressor() { + if (!compressor) { + compressor = std::make_unique(options.zstd_level); + } + return *compressor; + } + + // Streams one zstd frame into the archive. Gives up (returns false) as soon as the output is + // no smaller than the input. + bool WriteCompressed(Common::FS::IOFile& in, u64 size, u64& stored) { + auto& zstd = Compressor(); + zstd.Begin(size); + out_buffer.resize(ZSTD_CStreamOutSize()); + stored = 0; + bool fits = true; + const auto drain = [&](ZSTD_inBuffer& input, ZSTD_EndDirective mode) { + while (true) { + ZSTD_outBuffer output{out_buffer.data(), out_buffer.size(), 0}; + const size_t remaining = ZSTD_compressStream2(zstd.get(), &output, &input, mode); + if (ZSTD_isError(remaining)) { + throw PackError(PackResult::IoError, + std::string{"zstd: "} + ZSTD_getErrorName(remaining)); + } + stored += output.pos; + if (stored >= size) { + return false; + } + Write(out_buffer.data(), output.pos); + const bool finished = mode == ZSTD_e_end ? remaining == 0 + : input.pos == input.size; + if (finished) { + return true; + } + } + }; + hash.Reset(); + ReadSource(in, size, [&](const u8* data, size_t n) { + hash.Update(data, n); + ZSTD_inBuffer input{data, n, 0}; + fits = drain(input, ZSTD_e_continue); + return fits; + }); + if (!fits) { + return false; + } + ZSTD_inBuffer empty{nullptr, 0, 0}; + return drain(empty, ZSTD_e_end); + } + + void PackFile(size_t index) { + const auto source = SourceOf(index); + Common::FS::IOFile in{source, Common::FS::FileAccessMode::Read}; + if (!in.IsOpen()) { + throw PackError(PackResult::IoError, "cannot read " + ToUtf8(source)); + } + Extent extent; + extent.size = in.GetSize(); + file_counted = 0; + + std::optional known_hash; + if (extent.size == 0) { + known_hash = HashSource(in, 0); + } else if (sizes_seen.contains(extent.size)) { + // Only a file whose size matches an earlier extent can be a duplicate; hash it before + // writing anything so the shared extent costs no output. + known_hash = HashSource(in, extent.size); + if (const auto it = by_hash.find(*known_hash); + it != by_hash.end() && it->second.size == extent.size) { + extent = it->second; + Complete(index, extent); + return; + } + } + + if (extent.size == 0) { + extent.sha256 = *known_hash; + Complete(index, extent); + return; + } + + const u64 start = AlignUp(cursor, ExtentAlignment); + Seek(cursor); + WriteZeros(start - cursor); + extent.offset = start; + + bool compressed = false; + if (extent.size <= options.raw_threshold && options.zstd_level > 0) { + u64 stored = 0; + compressed = WriteCompressed(in, extent.size, stored); + if (compressed) { + extent.codec = CodecZstd; + extent.stored_size = stored; + extent.sha256 = hash.Final(); + } + } + if (!compressed) { + Seek(start); + hash.Reset(); + ReadSource(in, extent.size, [this](const u8* data, size_t n) { + hash.Update(data, n); + Write(data, n); + return true; + }); + extent.codec = CodecRaw; + extent.stored_size = extent.size; + extent.sha256 = hash.Final(); + } + cursor = start + extent.stored_size; + Remember(extent); + Complete(index, extent); + } + + void Seek(u64 offset) { + if (!out.Seek(static_cast(offset))) { + throw PackError(PackResult::IoError, "cannot seek " + ToUtf8(part)); + } + } + + void Complete(size_t index, const Extent& extent) { + extents[index] = extent; + done[index] = true; + if (options.delete_sources) { + chunk.push_back(index); + chunk_bytes += extent.size; + } + ++progress.files_done; + Report(); + } + + void AppendJournal(const std::string& lines) { + Common::FS::IOFile log{journal, Common::FS::FileAccessMode::Append}; + if (!log.IsOpen() || log.WriteString(lines) != lines.size() || !log.Commit()) { + throw PackError(PackResult::IoError, "cannot write " + ToUtf8(journal)); + } + } + + // Archive data first, then the journal, then the deletes, so an interruption loses nothing. + void Checkpoint() { + if (chunk.empty()) { + return; + } + if (!out.Commit()) { + throw PackError(PackResult::IoError, "cannot sync " + ToUtf8(part)); + } + std::string lines; + for (const auto index : chunk) { + const auto& extent = extents[index]; + lines += Json{{"file", index}, + {"size", extent.size}, + {"sha256", ToHex(extent.sha256)}, + {"offset", extent.offset}, + {"stored_size", extent.stored_size}, + {"codec", extent.codec}} + .dump(); + lines += '\n'; + } + AppendJournal(lines); + for (const auto index : chunk) { + RemoveFile(SourceOf(index)); + } + chunk.clear(); + chunk_bytes = 0; + } + + void Finish() { + // Meta blocks follow the last extent, as pack.py writes them. + Seek(cursor); + std::vector> meta; + for (const auto& package : plan.packages) { + if (package.meta.size() > std::numeric_limits::max()) { + throw PackError(PackResult::IoError, "package metadata is too large"); + } + meta.emplace_back(package.meta.empty() ? 0 : cursor, + static_cast(package.meta.size())); + Write(package.meta.data(), package.meta.size()); + cursor += package.meta.size(); + } + const u64 file_size = cursor; + + const auto toc = BuildToc(meta); + std::array toc_hash{}; + unsigned int hash_length{}; + EVP_Digest(toc.data(), toc.size(), toc_hash.data(), &hash_length, EVP_sha256(), nullptr); + + // UUID: SHA-256 over title_id (u64), version (u32) and kind (u8) of each package. + Sha256 uuid_hash; + for (const auto& package : plan.packages) { + std::vector identity(13); + PutLE(identity, 0, package.title_id, 8); + PutLE(identity, 8, package.version, 4); + identity[12] = package.kind; + uuid_hash.Update(identity.data(), identity.size()); + } + const auto uuid = uuid_hash.Final(); + + std::vector super(SuperblockSize); + PutBytes(super, 0x00, Magic); + PutLE(super, 0x04, SuperblockSize, 2); + PutLE(super, 0x06, 1, 2); + PutLE(super, 0x08, SuperblockSize, 8); + PutLE(super, 0x10, toc.size(), 8); + PutBytes(super, 0x18, toc_hash); + PutLE(super, 0x38, layout.blob_offset, 8); + PutLE(super, 0x40, file_size, 8); + PutLE(super, 0x48, plan.packages.size(), 4); + PutBytes(super, 0x50, std::span{uuid}.first(16)); + + Seek(0); + Write(super.data(), super.size()); + Write(toc.data(), toc.size()); + if (!out.SetSize(file_size) || !out.Commit()) { + throw PackError(PackResult::IoError, "cannot finish " + ToUtf8(part)); + } + } + + // Every field is written little-endian at its documented offset. The package record is + // 0x40 bytes with 20 reserved bytes at 0x2C, as NupContainer reads it. + std::vector BuildToc(const std::vector>& meta) const { + std::vector toc(layout.toc_size); + const u64 dir_count = layout.dirs.size(); + PutBytes(toc, 0x00, TocMagic); + PutLE(toc, 0x04, plan.packages.size(), 4); + PutLE(toc, 0x08, dir_count, 8); + PutLE(toc, 0x10, layout.files.size(), 8); + + size_t pos = TocHeaderSize; + for (size_t i = 0; i < plan.packages.size(); ++i) { + const auto& package = plan.packages[i]; + const auto& record = layout.packages[i]; + PutLE(toc, pos + 0x00, package.title_id, 8); + PutLE(toc, pos + 0x08, record.root_dir, 4); + PutLE(toc, pos + 0x0C, record.exefs_dir, 4); + PutLE(toc, pos + 0x10, record.romfs_dir, 4); + PutLE(toc, pos + 0x14, package.version, 4); + toc[pos + 0x18] = package.kind; + toc[pos + 0x19] = package.flags; + PutLE(toc, pos + 0x20, meta[i].first, 8); + PutLE(toc, pos + 0x28, meta[i].second, 4); + pos += PackageRecordSize; + } + for (const auto& dir : layout.dirs) { + PutLE(toc, pos + 0x00, layout.pool_index.at(dir.name), 4); + PutLE(toc, pos + 0x04, dir.name.size(), 4); + PutLE(toc, pos + 0x08, dir.first_child, 4); + PutLE(toc, pos + 0x0C, dir.child_count, 4); + pos += DirRecordSize; + } + for (size_t i = 0; i < layout.files.size(); ++i) { + const auto& file = layout.files[i]; + const auto& extent = extents[i]; + PutLE(toc, pos + 0x00, layout.pool_index.at(file.name), 4); + PutLE(toc, pos + 0x04, file.name.size(), 4); + PutLE(toc, pos + 0x08, extent.size, 8); + // Empty files have no extent: offset, size and codec are all zero. + if (extent.size != 0) { + PutLE(toc, pos + 0x10, extent.offset, 8); + PutLE(toc, pos + 0x18, extent.stored_size, 8); + toc[pos + 0x40] = extent.codec; + } + PutBytes(toc, pos + 0x20, extent.sha256); + pos += FileRecordSize; + } + for (const u64 child : layout.children) { + const u64 id = (child & Layout::FileTag) ? dir_count + static_cast(child) : child; + PutLE(toc, pos, id, 4); + pos += sizeof(u32); + } + PutLE(toc, 0x18, pos, 8); + PutLE(toc, 0x20, layout.pool.size(), 8); + std::memcpy(toc.data() + pos, layout.pool.data(), layout.pool.size()); + return toc; + } + + void RemoveSourceFolders() { + for (const auto& entry : plan.entries) { + const auto depth = std::ranges::count(entry.path, '/'); + const bool top = entry.directory && + (depth == 0 || (depth == 1 && entry.path.starts_with("data/"))); + if (top) { + const auto source = dump_root / FromUtf8(entry.source); + RemoveEmptyTree(source); + RemoveEmptyParents(source.parent_path(), dump_root); + } + } + } + + void Report() { + last_report = progress.bytes_done; + if (progress_cb) { + progress_cb(progress); + } + } + + fs::path dump_root; + fs::path archive; + fs::path part; + fs::path journal; + PackOptions options; + std::function progress_cb; + const std::atomic_bool* cancel; + + Plan plan; + Layout layout; + std::vector extents; + std::vector done; + std::vector> journaled; + + struct DigestHash { + size_t operator()(const Digest& digest) const { + size_t value; + std::memcpy(&value, digest.data(), sizeof(value)); + return value; + } + }; + std::unordered_map by_hash; + std::unordered_set sizes_seen; + + Common::FS::IOFile out; + u64 cursor = 0; + u64 resume_end = 0; + std::vector chunk; + u64 chunk_bytes = 0; + std::vector buffer; + std::vector out_buffer; + Sha256 hash; + std::unique_ptr compressor; + PackProgress progress{}; + u64 last_report = 0; + u64 file_counted = 0; +}; + +} // namespace + +bool IsLooseDump(const fs::path& dir) { + return HasBootableExeFS(dir); +} + +std::optional DumpRootFromMain(const fs::path& main) { + // The Qt game list stores the exefs folder rather than exefs/main. + std::error_code ec; + const auto exefs = fs::is_directory(main, ec) ? main : main.parent_path(); + if (Lower(ToUtf8(exefs.filename())) != "exefs") { + return std::nullopt; + } + auto root = exefs.parent_path(); + const auto name = Lower(ToUtf8(root.filename())); + if (name == "base") { + root = root.parent_path(); + } else if (ParseDecimal(name) && Lower(ToUtf8(root.parent_path().filename())) == "updates") { + root = root.parent_path().parent_path(); + } + if (!IsLooseDump(root)) { + return std::nullopt; + } + return root; +} + +fs::path DefaultArchivePath(const fs::path& dump_root) { + auto root = dump_root; + if (!root.has_filename()) { + root = root.parent_path(); + } + return root.parent_path() / (root.filename().u8string() + u8".nup"); +} + +bool HasResumableJournal(const fs::path& archive) { + std::error_code ec; + return fs::exists(WithSuffix(archive, ".part"), ec) && + fs::exists(WithSuffix(archive, ".journal"), ec); +} + +PackResult PackDump(const fs::path& dump_root, const fs::path& archive, const PackOptions& options, + const std::function& progress, + const std::atomic_bool* cancel, std::string* error_out) { + Packer packer{dump_root, archive, options, progress, cancel}; + try { + return packer.Run(); + } catch (const PackError& e) { + packer.Abort(); + if (error_out) { + *error_out = e.what(); + } + return e.result; + } catch (const std::exception& e) { + packer.Abort(); + if (error_out) { + *error_out = e.what(); + } + return PackResult::IoError; + } +} + +} // namespace FileSys::Nup diff --git a/src/core/file_sys/nup_writer.h b/src/core/file_sys/nup_writer.h new file mode 100644 index 0000000000..231387b156 --- /dev/null +++ b/src/core/file_sys/nup_writer.h @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +#include "common/common_types.h" + +// Packs a loose dump into a NUP container. Every package is kept: base/, each +// updates// and each dlc///. +namespace FileSys::Nup { + +struct PackProgress { + u64 bytes_done{}; + u64 bytes_total{}; + u64 files_done{}; + u64 files_total{}; +}; + +enum class PackResult { Success, Cancelled, InvalidSource, IoError }; + +// NupContainer keeps a zstd file in memory only while it fits its 512 MiB decompression cache; +// a larger one is decompressed again in full on every read, which makes big compressed RomFS +// files unusable. Files above this size are therefore stored raw, with headroom below the cap so +// that one file does not evict everything else from the cache. +constexpr u64 DefaultRawThreshold = 256ULL * 1024 * 1024; + +struct PackOptions { + // Low-storage mode: delete each source file once its extent is synced and journaled. + bool delete_sources = false; + u64 chunk_bytes = 256ULL * 1024 * 1024; + int zstd_level = 3; + // Files larger than this are always stored raw. + u64 raw_threshold = DefaultRawThreshold; +}; + +// A loose dump holds exefs/main and main.npdm, directly or under base/ or updates//, +// and a romfs folder. Folder names are matched case-insensitively. +bool IsLooseDump(const std::filesystem::path& dir); +// The dump folder a game list entry's exefs/main (or exefs folder) belongs to, if it is a loose +// dump. +std::optional DumpRootFromMain(const std::filesystem::path& main); +std::filesystem::path DefaultArchivePath(const std::filesystem::path& dump_root); + +// Writes .part and renames it when done; an existing archive is never overwritten. A +// cancelled or failed low-storage run keeps its .part and .journal, and calling this again with +// the same arguments resumes it. +PackResult PackDump(const std::filesystem::path& dump_root, const std::filesystem::path& archive, + const PackOptions& options = {}, + const std::function& progress = {}, + const std::atomic_bool* cancel = nullptr, std::string* error_out = nullptr); + +bool HasResumableJournal(const std::filesystem::path& archive); + +} // namespace FileSys::Nup diff --git a/src/core/launch_params.cpp b/src/core/launch_params.cpp index 25fdd45fdb..6c436d0346 100644 --- a/src/core/launch_params.cpp +++ b/src/core/launch_params.cpp @@ -39,6 +39,7 @@ LaunchParams ParseLaunchParams(Core::System& system, int argc, char *argv[], wch {"null-render", no_argument, 0, 'n'}, {"singlecore", no_argument, 0, 's'}, {"filter", no_argument, 0, 'x'}, + {"pack-nup", required_argument, 0, 'N'}, {0, 0, 0, 0}, // clang-format on }; @@ -62,6 +63,9 @@ LaunchParams ParseLaunchParams(Core::System& system, int argc, char *argv[], wch case 'g': p.filepath = std::string(optarg); break; + case 'N': + p.pack_nup_source = std::string(optarg); + break; case 'i': { p.input_profile = std::string(optarg); break; @@ -196,7 +200,9 @@ void ApplyLaunchParams(LaunchParams const& lp) noexcept { "-p, --program Pass following string as arguments to executable\n" "-u, --user Select a specific user profile from 0 to 7\n" "-d, --debug Run the GDB stub on a port from 1 to 65535\n" - "-v, --version Output version information and exit\n", + "-v, --version Output version information and exit\n" + "--pack-nup []" + " Pack a loose dump with all its updates and DLC into a NUP file and exit\n", lp.argv0 ); } diff --git a/src/core/launch_params.h b/src/core/launch_params.h index 4da30fc1c2..8d503770d8 100644 --- a/src/core/launch_params.h +++ b/src/core/launch_params.h @@ -23,6 +23,8 @@ struct LaunchParams { std::optional selected_user{}; std::optional override_gdb_port{}; std::optional log_filter{}; + // --pack-nup []: packs a loose dump instead of booting a game (eden-cli). + std::optional pack_nup_source{}; u16 port = Network::DefaultRoomPort; bool use_multiplayer = false; bool fullscreen = false; diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 194e90a243..a9be2ec7a8 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -17,6 +17,7 @@ add_executable(tests common/undefined_fix.cpp common/unique_function.cpp core/core_timing.cpp + core/nup_writer.cpp core/internal_network/network.cpp video_core/memory_tracker.cpp video_core/vertex_repack.cpp diff --git a/src/tests/core/nup_writer.cpp b/src/tests/core/nup_writer.cpp new file mode 100644 index 0000000000..ce1a0717ed --- /dev/null +++ b/src/tests/core/nup_writer.cpp @@ -0,0 +1,392 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/file_sys/nup_container.h" +#include "core/file_sys/nup_writer.h" +#include "core/file_sys/vfs/vfs_real.h" + +namespace { + +namespace fs = std::filesystem; +using FileSys::NupContainer; + +struct TempDir { + fs::path path = fs::temp_directory_path() / + ("nup-test-" + + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + TempDir() { + fs::create_directories(path); + } + ~TempDir() { + std::error_code ec; + fs::remove_all(path, ec); + } +}; + +void Put(const fs::path& p, std::string_view value) { + fs::create_directories(p.parent_path()); + std::ofstream(p, std::ios::binary) << value; +} + +std::string ReadAll(const fs::path& p) { + std::ifstream in(p, std::ios::binary); + return {std::istreambuf_iterator(in), {}}; +} + +std::string Read(const FileSys::VirtualFile& f) { + std::string out(f->GetSize(), '\0'); + f->Read(reinterpret_cast(out.data()), out.size(), 0); + return out; +} + +// Enough of an NPDM for the packer: the program ID at ACI0 + 0x10. +std::string Npdm(u64 program_id) { + std::string npdm(0x100, '\0'); + npdm.replace(0x80, 4, "ACI0"); + for (int i = 0; i < 8; ++i) { + npdm[0x90 + i] = static_cast(program_id >> (8 * i)); + } + return npdm; +} + +std::array Sha256(std::string_view data) { + std::array out{}; + unsigned int length{}; + EVP_Digest(data.data(), data.size(), out.data(), &length, EVP_sha256(), nullptr); + return out; +} + +u64 Le(const std::string& buf, size_t at, size_t bytes) { + u64 value = 0; + for (size_t i = 0; i < bytes; ++i) { + value |= static_cast(static_cast(buf[at + i])) << (8 * i); + } + return value; +} + +struct RawFile { + std::string name; + u64 size; + std::array sha256; + u64 extent_offset; + u64 extent_size; + u8 codec; +}; + +// Reads the file records straight from the TOC, which NupContainer does not expose. +std::vector RawFiles(const std::string& nup) { + const u64 toc = Le(nup, 0x08, 8); + const u64 packages = Le(nup, toc + 0x04, 4); + const u64 dirs = Le(nup, toc + 0x08, 8); + const u64 files = Le(nup, toc + 0x10, 8); + const u64 pool = toc + Le(nup, toc + 0x18, 8); + std::vector out; + u64 pos = toc + 0x30 + packages * 0x40 + dirs * 0x18; + for (u64 i = 0; i < files; ++i, pos += 0x50) { + RawFile file{nup.substr(pool + Le(nup, pos, 4), Le(nup, pos + 4, 4)), + Le(nup, pos + 0x08, 8), + {}, + Le(nup, pos + 0x10, 8), + Le(nup, pos + 0x18, 8), + static_cast(nup[pos + 0x40])}; + std::copy_n(nup.begin() + static_cast(pos + 0x20), 32, + file.sha256.begin()); + out.push_back(file); + } + return out; +} + +// Every file under `dir` with its contents, keyed by its path relative to `dir`. +std::map SourceTree(const fs::path& dir) { + std::map out; + for (const auto& entry : fs::recursive_directory_iterator(dir)) { + if (entry.is_regular_file() && entry.path().filename() != ".listing") { + out[fs::relative(entry.path(), dir).generic_string()] = ReadAll(entry.path()); + } + } + return out; +} + +void PackedTree(const FileSys::VirtualDir& dir, const std::string& prefix, + std::map& out) { + if (dir == nullptr) { + return; + } + for (const auto& file : dir->GetFiles()) { + out[prefix + file->GetName()] = Read(file); + } + for (const auto& sub : dir->GetSubdirectories()) { + PackedTree(sub, prefix + sub->GetName() + "/", out); + } +} + +std::map PackedTree(const FileSys::VirtualDir& dir) { + std::map out; + PackedTree(dir, "", out); + return out; +} + +constexpr u64 TitleId = 0x0400ABCD00010000ULL; + +void MakeDump(const fs::path& root) { + Put(root / "Base/ExeFS/main", "base main"); + Put(root / "Base/ExeFS/main.npdm", Npdm(TitleId)); + Put(root / "Base/RomFS/shared.bin", std::string(5000, 's')); + Put(root / "Base/RomFS/copy/shared.bin", std::string(5000, 's')); + Put(root / "Base/RomFS/empty", ""); + Put(root / "Base/RomFS/.listing", "listing"); + Put(root / "Base/RomFS/System/Resource/ResourceSizeTable.Product.100.rsizetable", "rst"); + Put(root / "Base/RomFS/big.bin", std::string(64 * 1024, 'b')); + Put(root / "Base/RomFS/random.bin", [] { + std::string data(3000, '\0'); + u32 x = 12345; + for (auto& c : data) { + x = x * 1664525 + 1013904223; + c = static_cast(x >> 24); + } + return data; + }()); + Put(root / "updates/65536/exefs/main", "update main"); + Put(root / "updates/65536/exefs/main.npdm", Npdm(TitleId)); + Put(root / "updates/65536/romfs/shared.bin", std::string(5000, 's')); + Put(root / "updates/65536/romfs/System/Resource/ResourceSizeTable.Product.110.rsizetable", + "rst"); + Put(root / "updates/65536/romfs/Zeta/a", "a"); + Put(root / "updates/65536/romfs/alpha", "alpha"); + Put(root / "updates/131072/exefs/main", "update 2 main"); + Put(root / "updates/131072/exefs/main.npdm", Npdm(TitleId)); + Put(root / "dlc/0400ABCD00011001/0/romfs/addon.bin", "addon data"); +} + +} // namespace + +TEST_CASE("NUP packs base, updates and add-ons and reads back through NupContainer", "[nup]") { + TempDir t; + const auto root = t.path / "Game"; + MakeDump(root); + const auto archive = t.path / "Game.nup"; + FileSys::Nup::PackOptions options{.raw_threshold = 16 * 1024}; + std::string error; + REQUIRE(FileSys::Nup::PackDump(root, archive, options, {}, nullptr, &error) == + FileSys::Nup::PackResult::Success); + INFO(error); + REQUIRE_FALSE(fs::exists(archive.string() + ".part")); + + FileSys::RealVfsFilesystem real; + NupContainer nup{real.OpenFile(archive.string(), FileSys::OpenMode::Read)}; + REQUIRE(nup.GetStatus() == Loader::ResultStatus::Success); + REQUIRE(nup.VerifyIntegrity({}) == Loader::ResultStatus::Success); + + const auto& packages = nup.GetPackages(); + REQUIRE(packages.size() == 4); + REQUIRE(packages[0].kind == NupContainer::PackageKind::Base); + REQUIRE(packages[0].title_id == TitleId); + REQUIRE(packages[0].version == 0); + REQUIRE(packages[0].npdm.size() == 0x100); + REQUIRE(packages[1].kind == NupContainer::PackageKind::Update); + REQUIRE(packages[1].version == 65536); + REQUIRE(packages[1].title_id == TitleId); + REQUIRE_FALSE(packages[1].standalone_update); + REQUIRE(packages[2].kind == NupContainer::PackageKind::Update); + REQUIRE(packages[2].version == 131072); + REQUIRE(packages[2].romfs_dir == 0xFFFFFFFF); + REQUIRE(packages[3].kind == NupContainer::PackageKind::Addon); + REQUIRE(packages[3].title_id == 0x0400ABCD00011001ULL); + REQUIRE(packages[3].exefs_dir == 0xFFFFFFFF); + REQUIRE(nup.FindBestUpdatePackage() == 2); + + REQUIRE(PackedTree(nup.GetExeFSDir(0)) == SourceTree(root / "Base/ExeFS")); + REQUIRE(PackedTree(nup.GetRomFSDir(0)) == SourceTree(root / "Base/RomFS")); + REQUIRE(PackedTree(nup.GetExeFSDir(1)) == SourceTree(root / "updates/65536/exefs")); + REQUIRE(PackedTree(nup.GetRomFSDir(1)) == SourceTree(root / "updates/65536/romfs")); + REQUIRE(PackedTree(nup.GetExeFSDir(2)) == SourceTree(root / "updates/131072/exefs")); + REQUIRE(PackedTree(nup.GetRomFSDir(3)) == SourceTree(root / "dlc/0400ABCD00011001/0/romfs")); + REQUIRE_FALSE(nup.GetRomFSDir(0)->GetFile(".listing")); + + const auto bytes = ReadAll(archive); + const auto files = RawFiles(bytes); + std::map> by_name; + for (const auto& file : files) { + by_name[file.name].push_back(file); + if (file.size != 0) { + REQUIRE(file.extent_offset % 16 == 0); + } + } + const auto shared = by_name.at("shared.bin"); + REQUIRE(shared.size() == 3); + for (const auto& file : shared) { + REQUIRE(file.sha256 == Sha256(std::string(5000, 's'))); + REQUIRE(file.codec == 1); + REQUIRE(file.extent_offset == shared[0].extent_offset); + REQUIRE(file.extent_size == shared[0].extent_size); + } + const auto& empty = by_name.at("empty").at(0); + REQUIRE(empty.size == 0); + REQUIRE(empty.extent_offset == 0); + REQUIRE(empty.extent_size == 0); + REQUIRE(empty.codec == 0); + REQUIRE(empty.sha256 == Sha256("")); + // Highly compressible, but above the raw threshold. + const auto& big = by_name.at("big.bin").at(0); + REQUIRE(big.codec == 0); + REQUIRE(big.extent_size == 64 * 1024); + REQUIRE(big.sha256 == Sha256(std::string(64 * 1024, 'b'))); + // Incompressible, so stored raw. + const auto& random = by_name.at("random.bin").at(0); + REQUIRE(random.codec == 0); + REQUIRE(random.extent_size == 3000); + REQUIRE_FALSE(by_name.contains(".listing")); +} + +TEST_CASE("NUP marks updates standalone when the dump has no base", "[nup]") { + TempDir t; + const auto root = t.path / "Game"; + Put(root / "updates/65536/exefs/main", "main"); + Put(root / "updates/65536/exefs/main.npdm", Npdm(TitleId)); + Put(root / "updates/65536/romfs/file", "rom"); + const auto archive = t.path / "Game.nup"; + REQUIRE(FileSys::Nup::PackDump(root, archive) == FileSys::Nup::PackResult::Success); + + FileSys::RealVfsFilesystem real; + NupContainer nup{real.OpenFile(archive.string(), FileSys::OpenMode::Read)}; + REQUIRE(nup.GetStatus() == Loader::ResultStatus::Success); + REQUIRE(nup.GetPackages().size() == 1); + REQUIRE(nup.GetPackages()[0].kind == NupContainer::PackageKind::Update); + REQUIRE(nup.GetPackages()[0].standalone_update); + REQUIRE(nup.GetPackages()[0].title_id == TitleId); + REQUIRE(Read(nup.GetRomFSDir(0)->GetFile("file")) == "rom"); +} + +TEST_CASE("NUP packs a bare exefs/romfs dump as one base", "[nup]") { + TempDir t; + const auto root = t.path / "Game"; + Put(root / "ExeFS/main", "main"); + Put(root / "ExeFS/main.npdm", Npdm(TitleId)); + Put(root / "RomFS/file", "rom"); + Put(root / "notes.txt", "extra"); + const auto archive = t.path / "Game.nup"; + REQUIRE(FileSys::Nup::PackDump(root, archive) == FileSys::Nup::PackResult::Success); + + FileSys::RealVfsFilesystem real; + NupContainer nup{real.OpenFile(archive.string(), FileSys::OpenMode::Read)}; + REQUIRE(nup.GetStatus() == Loader::ResultStatus::Success); + REQUIRE(nup.GetPackages().size() == 1); + REQUIRE(nup.GetPackages()[0].kind == NupContainer::PackageKind::Base); + REQUIRE_FALSE(nup.GetPackages()[0].standalone_update); + REQUIRE(Read(nup.GetExeFSDir(0)->GetFile("main")) == "main"); + REQUIRE(Read(nup.GetRomFSDir(0)->GetFile("file")) == "rom"); + const auto files = RawFiles(ReadAll(archive)); + REQUIRE(std::ranges::count(files, std::string{"notes.txt"}, &RawFile::name) == 1); +} + +TEST_CASE("NUP low storage resumes and deletes the packed sources", "[nup]") { + TempDir t; + const auto root = t.path / "Game"; + MakeDump(root); + const auto copy = t.path / "copy" / "Game"; + fs::create_directories(copy); + fs::copy(root, copy, fs::copy_options::recursive); + const auto archive = t.path / "Game.nup"; + + std::atomic_bool cancel = false; + bool cancelled = false; + FileSys::Nup::PackOptions options{.delete_sources = true, .chunk_bytes = 1}; + auto result = FileSys::Nup::PackDump( + root, archive, options, + [&](const auto& p) { + if (p.files_done >= 4 && !cancelled) { + cancel = true; + cancelled = true; + } + }, + &cancel); + REQUIRE(result == FileSys::Nup::PackResult::Cancelled); + REQUIRE(FileSys::Nup::HasResumableJournal(archive)); + REQUIRE_FALSE(fs::exists(root / "Base/ExeFS/main")); + REQUIRE_FALSE(fs::exists(root / "Base/ExeFS/main.npdm")); + + cancel = false; + std::string error; + REQUIRE(FileSys::Nup::PackDump(root, archive, options, {}, &cancel, &error) == + FileSys::Nup::PackResult::Success); + INFO(error); + REQUIRE_FALSE(fs::exists(archive.string() + ".journal")); + REQUIRE_FALSE(fs::exists(archive.string() + ".part")); + REQUIRE_FALSE(fs::exists(root / "Base/ExeFS")); + REQUIRE_FALSE(fs::exists(root / "updates")); + REQUIRE_FALSE(fs::exists(root / "dlc")); + // The listing is not packed, so it is left behind with the folders holding it. + REQUIRE(ReadAll(root / "Base/RomFS/.listing") == "listing"); + REQUIRE_FALSE(fs::exists(root / "Base/RomFS/shared.bin")); + + FileSys::RealVfsFilesystem real; + NupContainer nup{real.OpenFile(archive.string(), FileSys::OpenMode::Read)}; + REQUIRE(nup.GetStatus() == Loader::ResultStatus::Success); + REQUIRE(nup.VerifyIntegrity({}) == Loader::ResultStatus::Success); + REQUIRE(nup.GetPackages().size() == 4); + const auto npdm = Npdm(TitleId); + REQUIRE(nup.GetPackages()[0].npdm == std::vector(npdm.begin(), npdm.end())); + REQUIRE(PackedTree(nup.GetRomFSDir(0)) == SourceTree(copy / "Base/RomFS")); + REQUIRE(PackedTree(nup.GetRomFSDir(1)) == SourceTree(copy / "updates/65536/romfs")); + + // The resumed archive matches one packed in a single pass. + const auto again = t.path / "again.nup"; + REQUIRE(FileSys::Nup::PackDump(copy, again) == FileSys::Nup::PackResult::Success); + REQUIRE(ReadAll(archive) == ReadAll(again)); +} + +TEST_CASE("NUP rejects overwrite and a dump without NPDM", "[nup]") { + TempDir t; + const auto root = t.path / "Game"; + Put(root / "exefs/main", "main"); + Put(root / "romfs/a", "rom"); + const auto archive = t.path / "out.nup"; + Put(archive, "existing"); + REQUIRE(FileSys::Nup::PackDump(root, archive) == FileSys::Nup::PackResult::IoError); + REQUIRE(ReadAll(archive) == "existing"); + fs::remove(archive); + REQUIRE(FileSys::Nup::PackDump(root, archive) == FileSys::Nup::PackResult::InvalidSource); + REQUIRE_FALSE(fs::exists(archive.string() + ".part")); +} + +// Archives are meant to be byte-identical on every platform. A change to this hash means +// packing a dump no longer reproduces archives made by earlier builds (for example after a zstd +// update), so make it deliberately. +TEST_CASE("NUP output is reproducible", "[nup]") { + TempDir t; + const auto root = t.path / "Game"; + MakeDump(root); + // Large and compressible, so it covers what used to be zstd's multithreaded path. + Put(root / "Base/RomFS/large.bin", [] { + std::string data(20 * 1024 * 1024, '\0'); + u32 x = 1; + for (auto& c : data) { + x = x * 1664525 + 1013904223; + c = static_cast('a' + (x >> 28)); + } + return data; + }()); + const auto archive = t.path / "Game.nup"; + REQUIRE(FileSys::Nup::PackDump(root, archive) == FileSys::Nup::PackResult::Success); + + std::string hex; + for (const u8 b : Sha256(ReadAll(archive))) { + hex += fmt::format("{:02x}", b); + } + REQUIRE(hex == "df8446903ab2c39a51af0304935eb4aa5d8ead105440976ceae7ab97133f6143"); +} diff --git a/src/yuzu/game/game_list.cpp b/src/yuzu/game/game_list.cpp index cd7fc19de4..8e3eb7bc52 100644 --- a/src/yuzu/game/game_list.cpp +++ b/src/yuzu/game/game_list.cpp @@ -24,6 +24,7 @@ #include "common/common_types.h" #include "core/core.h" +#include "core/file_sys/nup_writer.h" #include "core/file_sys/patch_manager.h" #include "core/file_sys/registered_cache.h" #include "qt_common/config/uisettings.h" @@ -99,6 +100,10 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid ResetViewMode(); } +void GameList::SetNupConversionRunning(bool running) { + nup_conversion_running = running; +} + GameList::~GameList() { UnloadController(); } @@ -477,6 +482,11 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri QAction* dump_romfs_sdmc = dump_romfs_menu->addAction(tr("Dump RomFS to SDMC")); QAction* verify_integrity = context_menu.addAction(tr("Verify Integrity")); QAction* copy_tid = context_menu.addAction(tr("Copy Title ID to Clipboard")); + QAction* convert_nup = nullptr; + if (FileSys::Nup::DumpRootFromMain(path)) { + convert_nup = context_menu.addAction(tr("Convert to NUP...")); + convert_nup->setEnabled(!nup_conversion_running); + } #if !defined(__APPLE__) QMenu* shortcut_menu = context_menu.addMenu(tr("Create Shortcut")); QAction* create_desktop_shortcut = shortcut_menu->addAction(tr("Add to Desktop")); @@ -557,6 +567,10 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri [this, path]() { emit VerifyIntegrityRequested(path); }); connect(copy_tid, &QAction::triggered, this, [this, program_id]() { emit CopyTIDRequested(program_id); }); + if (convert_nup) { + connect(convert_nup, &QAction::triggered, this, + [this, path]() { emit ConvertToNupRequested(path); }); + } #if !defined(__APPLE__) connect(create_desktop_shortcut, &QAction::triggered, this, [this, program_id, path]() { emit CreateShortcut(program_id, path, QtCommon::Game::ShortcutTarget::Desktop); diff --git a/src/yuzu/game/game_list.h b/src/yuzu/game/game_list.h index 1540dd9cf0..67713691fd 100644 --- a/src/yuzu/game/game_list.h +++ b/src/yuzu/game/game_list.h @@ -88,6 +88,7 @@ public: void ResetViewMode(); public slots: + void SetNupConversionRunning(bool running); void RefreshGameDirectory(); void RefreshExternalContent(); void UpdateIconSizes(); @@ -106,6 +107,7 @@ signals: void SetPlayTimeRequested(u64 program_id); void DumpRomFSRequested(u64 program_id, const std::string& game_path, DumpRomFSTarget target); void VerifyIntegrityRequested(const std::string& game_path); + void ConvertToNupRequested(const std::string& game_path); void CopyTIDRequested(u64 program_id); void CreateShortcut(u64 program_id, const std::string& game_path, const QtCommon::Game::ShortcutTarget target); @@ -165,6 +167,7 @@ private: Core::System& system; bool m_isTreeMode = true; + bool nup_conversion_running{}; QAbstractItemView* m_currentView = nullptr; }; diff --git a/src/yuzu/main_window.cpp b/src/yuzu/main_window.cpp index b0c0446add..2b43bcd2d0 100644 --- a/src/yuzu/main_window.cpp +++ b/src/yuzu/main_window.cpp @@ -149,6 +149,7 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual #include "core/file_sys/card_image.h" #include "core/file_sys/common_funcs.h" +#include "core/file_sys/nup_writer.h" #include "core/file_sys/romfs.h" #include "core/file_sys/savedata_factory.h" @@ -554,6 +555,10 @@ MainWindow::MainWindow(bool has_broken_vulkan) } MainWindow::~MainWindow() { + if (nup_cancel) { + nup_cancel->store(true); + } + nup_watcher.waitForFinished(); #ifdef __APPLE__ AllowOSSleep(); #endif @@ -1458,6 +1463,8 @@ void MainWindow::ConnectWidgetEvents() { connect(game_list, &GameList::DumpRomFSRequested, this, &MainWindow::OnGameListDumpRomFS); connect(game_list, &GameList::VerifyIntegrityRequested, this, &MainWindow::OnGameListVerifyIntegrity); + connect(game_list, &GameList::ConvertToNupRequested, this, + &MainWindow::OnGameListConvertToNup); connect(game_list, &GameList::CopyTIDRequested, this, &MainWindow::OnGameListCopyTID); connect(game_list, &GameList::CreateShortcut, this, &MainWindow::OnGameListCreateShortcut); connect(game_list, &GameList::AddDirectory, this, &MainWindow::OnGameListAddDirectory); @@ -1907,6 +1914,12 @@ bool MainWindow::SelectAndSetCurrentUser( void MainWindow::BootGame(const QString& filename, Service::AM::FrontendAppletParameters params, StartGameType type) { + if (nup_running && filename.toStdString() == nup_source_path) { + QMessageBox::information(this, tr("Conversion in Progress"), + tr("This game cannot be started while its dump is being " + "converted.")); + return; + } LOG_INFO(Frontend, "Eglantine starting..."); if (params.program_id == 0 || @@ -3967,6 +3980,151 @@ void MainWindow::OnGameListRefresh() { SetFirmwareVersion(); } +void MainWindow::OnGameListConvertToNup(const std::string& game_path) { + if (nup_running) + return; + + const auto root = FileSys::Nup::DumpRootFromMain(game_path); + if (!root) + return; + const QString convert_title = tr("Convert to NUP"); + const auto archive = FileSys::Nup::DefaultArchivePath(*root); + if (std::filesystem::exists(archive)) { + QMessageBox::information(this, convert_title, + tr("The target archive already exists:\n%1") + .arg(QString::fromStdString(Common::FS::PathToUTF8String(archive)))); + return; + } + + const bool resume = FileSys::Nup::HasResumableJournal(archive); + bool delete_sources = resume; + if (resume) { + if (QMessageBox::question(this, tr("Resume Conversion"), + tr("Resume the interrupted conversion?"), + QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) + return; + } else { + QMessageBox prompt(QMessageBox::Question, convert_title, + tr("Source folder:\n%1\n\nTarget archive:\n%2") + .arg(QString::fromStdString(Common::FS::PathToUTF8String(*root)), + QString::fromStdString(Common::FS::PathToUTF8String(archive))), + QMessageBox::Yes | QMessageBox::No, this); + // QMessageBox takes ownership of its check box. + auto* low_storage = new QCheckBox(tr("Low storage: delete the original files as they " + "are packed (cannot be undone)")); + prompt.setCheckBox(low_storage); + u64 needed{}; + { + std::error_code ec; + for (std::filesystem::recursive_directory_iterator it(*root, ec), end; + !ec && it != end; it.increment(ec)) { + if (it->is_regular_file(ec)) + needed += it->file_size(ec); + } + } + std::error_code space_error; + const auto space = std::filesystem::space(archive.parent_path(), space_error); + const auto to_mb = [](u64 bytes) { return bytes / (1024 * 1024); }; + const QString space_info = tr("\n\nApproximate space needed: %1 MB\nFree space on target " + "volume: %2 MB") + .arg(to_mb(needed)) + .arg(space_error ? 0 : to_mb(space.available)); + prompt.setInformativeText(space_info); + if (!space_error && space.available < needed) { + prompt.setText(prompt.text() + tr("\n\nWarning: free space is less than the " + "estimated space needed.")); + } + if (prompt.exec() != QMessageBox::Yes) + return; + delete_sources = low_storage->isChecked(); + } + + nup_running = true; + nup_source_path = game_path; + game_list->SetNupConversionRunning(true); + nup_cancel = std::make_shared(false); + nup_progress = + new QProgressDialog(tr("Preparing conversion..."), tr("Cancel"), 0, 1000, this); + nup_progress->setWindowTitle(tr("Converting to NUP")); + nup_progress->setMinimumDuration(0); + nup_progress->setAutoClose(false); + nup_progress->setAutoReset(false); + connect(nup_progress, &QProgressDialog::canceled, this, + [cancel = nup_cancel] { cancel->store(true); }); + nup_progress->show(); + + const auto cancel = nup_cancel; + auto last_update = std::make_shared(); + nup_watcher.setFuture(QtConcurrent::run([this, root = *root, archive, delete_sources, + cancel, last_update] { + std::string error; + const auto on_progress = [this, last_update]( + const FileSys::Nup::PackProgress& progress) { + const auto now = std::chrono::steady_clock::now(); + if (now - *last_update < std::chrono::milliseconds(200)) + return; + *last_update = now; + QMetaObject::invokeMethod( + this, + [this, progress] { + if (!nup_progress) + return; + const auto percent = progress.bytes_total == 0 + ? 0 + : static_cast(progress.bytes_done * 1000 / + progress.bytes_total); + nup_progress->setValue(percent); + nup_progress->setLabelText( + tr("%1 / %2 MB\n%3 / %4 files") + .arg(progress.bytes_done / (1024 * 1024)) + .arg(progress.bytes_total / (1024 * 1024)) + .arg(progress.files_done) + .arg(progress.files_total)); + }, + Qt::QueuedConnection); + }; + const auto result = FileSys::Nup::PackDump( + root, archive, {.delete_sources = delete_sources}, on_progress, cancel.get(), &error); + QMetaObject::invokeMethod( + this, + [this, result, delete_sources, error] { + nup_running = false; + nup_source_path.clear(); + game_list->SetNupConversionRunning(false); + if (nup_progress) { + nup_progress->close(); + nup_progress->deleteLater(); + nup_progress = nullptr; + } + nup_cancel.reset(); + switch (result) { + case FileSys::Nup::PackResult::Success: + QMessageBox::information( + this, tr("Conversion Complete"), + delete_sources ? tr("The NUP was created and the loose folder was " + "removed.") + : tr("The NUP was created. You can delete the loose " + "folder if you no longer need it.")); + OnGameListRefresh(); + break; + case FileSys::Nup::PackResult::Cancelled: + if (delete_sources) + QMessageBox::information(this, tr("Conversion Cancelled"), + tr("The conversion can be resumed by choosing " + "Convert to NUP again.")); + break; + case FileSys::Nup::PackResult::InvalidSource: + case FileSys::Nup::PackResult::IoError: + QMessageBox::critical(this, tr("Conversion Failed"), + QString::fromStdString(error)); + break; + } + }, + Qt::QueuedConnection); + return result; + })); +} + void MainWindow::LaunchFirmwareApplet(u64 raw_program_id, std::optional cabinet_mode) { auto const program_id = Service::AM::AppletProgramId(raw_program_id); diff --git a/src/yuzu/main_window.h b/src/yuzu/main_window.h index 57f585fef9..11732ff242 100644 --- a/src/yuzu/main_window.h +++ b/src/yuzu/main_window.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +20,7 @@ #include "common/common_types.h" #include "common/settings_enums.h" +#include "core/file_sys/nup_writer.h" #include "frontend_common/content_manager.h" #include "input_common/drivers/tas_input.h" #include "qt_common/config/qt_config.h" @@ -352,6 +354,7 @@ private slots: void OnGameListSetPlayTime(u64 program_id); void OnGameListDumpRomFS(u64 program_id, const std::string& game_path, DumpRomFSTarget target); void OnGameListVerifyIntegrity(const std::string& game_path); + void OnGameListConvertToNup(const std::string& game_path); void OnGameListCopyTID(u64 program_id); void OnGameListCreateShortcut(u64 program_id, const std::string& game_path, const QtCommon::Game::ShortcutTarget target); @@ -570,6 +573,11 @@ private: // Install progress dialog QProgressDialog* install_progress = nullptr; + QProgressDialog* nup_progress = nullptr; + QFutureWatcher nup_watcher; + std::shared_ptr nup_cancel; + bool nup_running{}; + std::string nup_source_path; // Last game booted, used for multi-process apps QString last_filename_booted; diff --git a/src/yuzu_cmd/yuzu.cpp b/src/yuzu_cmd/yuzu.cpp index 4d71a29993..bc4002d8af 100644 --- a/src/yuzu_cmd/yuzu.cpp +++ b/src/yuzu_cmd/yuzu.cpp @@ -12,7 +12,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -36,6 +38,7 @@ #include "core/core_timing.h" #include "core/cpu_manager.h" #include "core/crypto/key_manager.h" +#include "core/file_sys/nup_writer.h" #include "core/file_sys/registered_cache.h" #include "core/file_sys/vfs/vfs_real.h" #include "core/hle/service/am/applet_manager.h" @@ -248,6 +251,49 @@ struct SdlState { std::unique_ptr emu_window; }; +static std::atomic_bool pack_nup_cancel{false}; + +// eden-cli --pack-nup []; the output defaults to .nup next to the dump. +static bool PackNup(const std::string& source, const std::string& output) { + const auto to_path = [](const std::string& utf8) { + return std::filesystem::path{std::u8string{utf8.begin(), utf8.end()}}; + }; + const auto dump_root = to_path(source); + const auto archive = + output.empty() ? FileSys::Nup::DefaultArchivePath(dump_root) : to_path(output); + std::signal(SIGINT, [](int) { pack_nup_cancel = true; }); + + const auto start = std::chrono::steady_clock::now(); + auto last_report = start; + std::string error; + const auto result = FileSys::Nup::PackDump( + dump_root, archive, {}, + [&](const FileSys::Nup::PackProgress& p) { + const auto now = std::chrono::steady_clock::now(); + if (now - last_report < std::chrono::seconds(1) && p.files_done != p.files_total) { + return; + } + last_report = now; + fmt::print(stderr, "\r{} / {} MiB, {} / {} files", p.bytes_done >> 20, + p.bytes_total >> 20, p.files_done, p.files_total); + }, + &pack_nup_cancel, &error); + const auto seconds = + std::chrono::duration(std::chrono::steady_clock::now() - start).count(); + fmt::print(stderr, "\n"); + switch (result) { + case FileSys::Nup::PackResult::Success: + fmt::print(stderr, "Wrote {} in {:.1f} s\n", archive.string(), seconds); + return true; + case FileSys::Nup::PackResult::Cancelled: + fmt::print(stderr, "Cancelled\n"); + return false; + default: + fmt::print(stderr, "Packing failed: {}\n", error); + return false; + } +} + extern "C" SDL_AppResult SDL_AppInit(void **appstate, int argc, char **argv) { #ifdef _WIN32 SetUnhandledExceptionFilter(WriteCrashDump); @@ -298,6 +344,10 @@ extern "C" SDL_AppResult SDL_AppInit(void **appstate, int argc, char **argv) { Core::ApplyLaunchParams(lp); + if (lp.pack_nup_source) { + return PackNup(*lp.pack_nup_source, lp.filepath) ? SDL_APP_SUCCESS : SDL_APP_FAILURE; + } + if (lp.filepath.empty()) { LOG_CRITICAL(Frontend, "Failed to load ROM: No ROM specified"); return SDL_APP_FAILURE; -- cgit v1.2.3