diff --git a/src/ac6recomp_app.h b/src/ac6recomp_app.h index 9005ac39..8eb265bb 100644 --- a/src/ac6recomp_app.h +++ b/src/ac6recomp_app.h @@ -36,6 +36,10 @@ class Ac6recompApp : public rex::ReXApp { } protected: + // Ace Combat 6 title id: selects the right .iso when several are present + // and rejects wrong/corrupt images with a clear message. + uint32_t OnGetExpectedTitleId() const override { return 0x4E4D07D1; } + void OnPreSetup(rex::RuntimeConfig& config) override { REXLOG_INFO("Ac6recompApp::OnPreSetup"); rex::ReXApp::OnPreSetup(config); diff --git a/thirdparty/rexglue-sdk/include/native/filesystem/device.h b/thirdparty/rexglue-sdk/include/native/filesystem/device.h index e8e16946..2aede71c 100644 --- a/thirdparty/rexglue-sdk/include/native/filesystem/device.h +++ b/thirdparty/rexglue-sdk/include/native/filesystem/device.h @@ -24,6 +24,13 @@ class Device { const std::string& mount_path() const { return mount_path_; } virtual bool is_read_only() const { return true; } + // Layered mounts: when true, a path that this device fails to resolve falls + // through to the next registered device whose mount path also matches + // (registration order = priority, so earlier devices win). Default false = + // this device is terminal for its mount, exactly the historical behaviour. + bool layered() const { return layered_; } + void set_layered(bool layered) { layered_ = layered; } + virtual void Dump(string::StringBuffer* string_buffer) = 0; virtual Entry* ResolvePath(const std::string_view path) = 0; @@ -39,6 +46,7 @@ class Device { protected: rex::thread::global_critical_region global_critical_region_; std::string mount_path_; + bool layered_ = false; }; } // namespace rex::filesystem diff --git a/thirdparty/rexglue-sdk/include/rex/filesystem/devices/disc_image_device.h b/thirdparty/rexglue-sdk/include/rex/filesystem/devices/disc_image_device.h new file mode 100644 index 00000000..ffd0ae94 --- /dev/null +++ b/thirdparty/rexglue-sdk/include/rex/filesystem/devices/disc_image_device.h @@ -0,0 +1,84 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2020 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + * + * @modified Tom Clay, 2026 - Adapted for ReXGlue runtime + */ + +#pragma once + +#include +#include +#include + +#include +#include + +namespace rex::filesystem { + +class DiscImageEntry; + +// Read-only XDVDFS (GDFX) disc image device. Mounts a user-supplied .iso +// directly so no extraction step is required. Accepts both full dumps (game +// partition at a known base offset) and rebuilt game-partition-only images. +class DiscImageDevice : public Device { + public: + DiscImageDevice(const std::string_view mount_path, const std::filesystem::path& host_path); + ~DiscImageDevice() override; + + bool Initialize() override; + void Dump(string::StringBuffer* string_buffer) override; + Entry* ResolvePath(const std::string_view path) override; + + const std::string& name() const override { return name_; } + uint32_t attributes() const override { return 0; } + uint32_t component_name_max_length() const override { return 255; } + + const std::filesystem::path& host_path() const { return host_path_; } + + uint32_t total_allocation_units() const override { + return uint32_t(mmap_->size() / sectors_per_allocation_unit() / bytes_per_sector()); + } + uint32_t available_allocation_units() const override { return 0; } + uint32_t sectors_per_allocation_unit() const override { return 1; } + uint32_t bytes_per_sector() const override { return 0x200; } + + private: + enum class Error { + kSuccess = 0, + kErrorOutOfMemory = -1, + kErrorReadError = -10, + kErrorFileMismatch = -30, + kErrorDamagedFile = -31, + }; + + struct ParseState { + uint8_t* ptr = nullptr; + size_t size = 0; // Size (bytes) of total image. + size_t game_offset = 0; // Offset (bytes) of game partition. + size_t root_sector = 0; // Offset (sector) of root. + size_t root_offset = 0; // Offset (bytes) of root. + size_t root_size = 0; // Size (bytes) of root. + // Directory table offsets already walked; a repeat means a cycle in a + // damaged/malicious image and aborts the parse instead of recursing + // forever. + std::set visited_tables; + }; + + Error Verify(ParseState* state); + bool VerifyMagic(ParseState* state, size_t offset); + Error ReadAllEntries(ParseState* state); + bool ReadDirectory(ParseState* state, size_t table_offset, size_t table_size, + DiscImageEntry* parent, uint32_t depth); + + std::string name_; + std::filesystem::path host_path_; + std::unique_ptr root_entry_; + std::unique_ptr mmap_; +}; + +} // namespace rex::filesystem diff --git a/thirdparty/rexglue-sdk/include/rex/filesystem/devices/disc_image_entry.h b/thirdparty/rexglue-sdk/include/rex/filesystem/devices/disc_image_entry.h new file mode 100644 index 00000000..58498d12 --- /dev/null +++ b/thirdparty/rexglue-sdk/include/rex/filesystem/devices/disc_image_entry.h @@ -0,0 +1,53 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2020 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + * + * @modified Tom Clay, 2026 - Adapted for ReXGlue runtime + */ + +#pragma once + +#include +#include + +#include +#include +#include + +namespace rex::filesystem { + +class DiscImageDevice; + +class DiscImageEntry : public Entry { + public: + DiscImageEntry(Device* device, Entry* parent, const std::string_view path, + memory::MappedMemory* mmap); + ~DiscImageEntry() override; + + static std::unique_ptr Create(Device* device, Entry* parent, + const std::string_view name, + memory::MappedMemory* mmap); + + memory::MappedMemory* mmap() const { return mmap_; } + size_t data_offset() const { return data_offset_; } + size_t data_size() const { return data_size_; } + + X_STATUS Open(uint32_t desired_access, File** out_file) override; + + bool can_map() const override { return true; } + std::unique_ptr OpenMapped(memory::MappedMemory::Mode mode, size_t offset, + size_t length) override; + + private: + friend class DiscImageDevice; + + memory::MappedMemory* mmap_; + size_t data_offset_; + size_t data_size_; +}; + +} // namespace rex::filesystem diff --git a/thirdparty/rexglue-sdk/include/rex/filesystem/devices/disc_image_file.h b/thirdparty/rexglue-sdk/include/rex/filesystem/devices/disc_image_file.h new file mode 100644 index 00000000..8421f3e2 --- /dev/null +++ b/thirdparty/rexglue-sdk/include/rex/filesystem/devices/disc_image_file.h @@ -0,0 +1,45 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2013 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + * + * @modified Tom Clay, 2026 - Adapted for ReXGlue runtime + */ + +#pragma once + +#include + +namespace rex::filesystem { + +class DiscImageEntry; + +class DiscImageFile : public File { + public: + DiscImageFile(uint32_t file_access, DiscImageEntry* entry); + ~DiscImageFile() override; + + void Destroy() override; + + X_STATUS ReadSync(std::span buffer, size_t byte_offset, + size_t* out_bytes_read) override; + X_STATUS WriteSync(std::span buffer, size_t byte_offset, + size_t* out_bytes_written) override { + (void)buffer; + (void)byte_offset; + (void)out_bytes_written; + return X_STATUS_ACCESS_DENIED; + } + X_STATUS SetLength(size_t length) override { + (void)length; + return X_STATUS_ACCESS_DENIED; + } + + private: + DiscImageEntry* entry_; +}; + +} // namespace rex::filesystem diff --git a/thirdparty/rexglue-sdk/include/rex/rex_app.h b/thirdparty/rexglue-sdk/include/rex/rex_app.h index 4aa041f3..e1f393cf 100644 --- a/thirdparty/rexglue-sdk/include/rex/rex_app.h +++ b/thirdparty/rexglue-sdk/include/rex/rex_app.h @@ -102,6 +102,11 @@ class ReXApp : public ui::WindowedApp, public ui::WindowListener, public ui::Win /// Override to adjust game/user/update data paths programmatically. virtual void OnConfigurePaths(PathConfig& paths) { (void)paths; } + /// Expected xex title id used to select a disc image when several are + /// present (and to reject wrong/corrupt images with a clear message). + /// Return 0 to accept any image that contains a readable default.xex. + virtual uint32_t OnGetExpectedTitleId() const { return 0; } + // --- Accessors for subclass use --- Runtime* runtime() const { return runtime_.get(); } ui::Window* window() const { return window_.get(); } @@ -120,6 +125,12 @@ class ReXApp : public ui::WindowedApp, public ui::WindowListener, public ui::Win bool OnInitialize() override; void OnDestroy() override; + // Resolves where the game's data comes from: the loose assets directory + // (always first), else a user-supplied disc image. On success out_image is + // the image to mount below the loose layer (empty = loose only). On failure + // a clear, actionable message has been shown and logged. + bool ResolveGameSource(const std::filesystem::path& exe_dir, std::filesystem::path& out_image); + // WindowListener overrides void OnClosing(ui::UIEvent& e) override; diff --git a/thirdparty/rexglue-sdk/include/rex/runtime.h b/thirdparty/rexglue-sdk/include/rex/runtime.h index d40068f9..b16b1e98 100644 --- a/thirdparty/rexglue-sdk/include/rex/runtime.h +++ b/thirdparty/rexglue-sdk/include/rex/runtime.h @@ -110,6 +110,13 @@ class Runtime { const std::filesystem::path& user_data_root() const { return user_data_root_; } const std::filesystem::path& update_data_root() const { return update_data_root_; } + // Optional disc image mounted as the game volume (call before Setup). + // When set together with an existing game_data_root, the loose directory is + // mounted as a layered top (loose files win, the image fills the gaps); + // when game_data_root is empty or missing, the image alone backs game:. + void set_game_image_path(const std::filesystem::path& path) { game_image_path_ = path; } + const std::filesystem::path& game_image_path() const { return game_image_path_; } + // Set the app context for presentation (call before Setup) void set_app_context(ui::WindowedAppContext* context) { app_context_ = context; @@ -161,6 +168,7 @@ class Runtime { std::filesystem::path game_data_root_; std::filesystem::path user_data_root_; std::filesystem::path update_data_root_; + std::filesystem::path game_image_path_; ui::WindowedAppContext* app_context_ = nullptr; ui::Window* display_window_ = nullptr; diff --git a/thirdparty/rexglue-sdk/include/rex/system/xam/content_manager.h b/thirdparty/rexglue-sdk/include/rex/system/xam/content_manager.h index 0890be5c..d6319711 100644 --- a/thirdparty/rexglue-sdk/include/rex/system/xam/content_manager.h +++ b/thirdparty/rexglue-sdk/include/rex/system/xam/content_manager.h @@ -119,6 +119,9 @@ static_assert_size(XCONTENT_AGGREGATE_DATA, 0x148); class ContentPackage { public: + // package_path may be an extracted folder (mounted as a host-path device, + // the historical behaviour) or a raw STFS/LIVE container file (mounted + // directly through the STFS device, no extraction required). ContentPackage(KernelState* kernel_state, const std::string_view root_name, const XCONTENT_AGGREGATE_DATA& data, const std::filesystem::path& package_path); ~ContentPackage(); @@ -129,7 +132,13 @@ class ContentPackage { const std::filesystem::path& package_path() const { return package_path_; } - uint32_t GetPackageLicense() const { return license_; } + // The .header sidecar wins when present; a directly-mounted container falls + // back to the license bits carried in its own STFS header. + uint32_t GetPackageLicense() const { return license_ ? license_ : container_license_; } + + bool is_container() const { return is_container_; } + // False when a container file failed to mount (corrupt/truncated package). + bool device_mounted() const { return device_mounted_; } private: KernelState* kernel_state_; @@ -138,6 +147,18 @@ class ContentPackage { std::filesystem::path package_path_; XCONTENT_AGGREGATE_DATA content_data_; uint32_t license_ = 0; + uint32_t container_license_ = 0; + bool is_container_ = false; + bool device_mounted_ = false; +}; + +// A raw content container discovered on disk, mountable without extraction. +struct DiscoveredContainer { + std::filesystem::path path; + XContentType content_type; + uint32_t title_id = 0; + std::u16string display_name; + std::string source; // short label for the startup report ("dlc", "content root") }; class ContentManager { @@ -183,6 +204,15 @@ class ContentManager { // and writes a .header file for XAM enumeration. X_RESULT InstallContent(const std::filesystem::path& package_path); + // Scans for raw content containers - a dlc folder (recursive, so a flat + // dump and Xenia's content-directory layout both work) plus container files + // dropped straight into the content root - and logs one line per package + // found. Only containers for the running title are indexed, so call after + // the module is loaded. Containers take priority over an ambient extracted + // install: the user placed them next to the exe, so they win - same rule as + // the assets folder (and Windows' local-DLL search order). + void DiscoverContainers(const std::filesystem::path& dlc_dir); + private: std::filesystem::path ResolvePackageRoot(uint64_t xuid, XContentType content_type, uint32_t title_id = -1); @@ -197,6 +227,15 @@ class ContentManager { ContentPackage* DetachPackage(std::unordered_map::iterator it); + // Where a package's data actually lives: a discovered container if one + // exists (containers take priority), else the extracted folder / container + // file at the canonical package path, else empty. + std::filesystem::path ResolvePackageDataPath(uint64_t xuid, const XCONTENT_AGGREGATE_DATA& data); + const DiscoveredContainer* FindContainer(const std::string_view file_name, + XContentType content_type) const; + void DiscoverContainersInDir(const std::filesystem::path& dir, const char* source, + bool recursive, uint32_t title_id); + KernelState* kernel_state_; std::filesystem::path root_path_; @@ -204,6 +243,10 @@ class ContentManager { rex::thread::global_critical_region global_critical_region_; std::unordered_map open_packages_; + // Discovered raw containers, keyed by the (42-char-truncated) file name the + // game sees in enumeration results. + std::unordered_map + containers_; }; } // namespace xam diff --git a/thirdparty/rexglue-sdk/src/native/filesystem/CMakeLists.txt b/thirdparty/rexglue-sdk/src/native/filesystem/CMakeLists.txt index 6055dea6..cb774e4d 100644 --- a/thirdparty/rexglue-sdk/src/native/filesystem/CMakeLists.txt +++ b/thirdparty/rexglue-sdk/src/native/filesystem/CMakeLists.txt @@ -5,6 +5,9 @@ add_library(rexfilesystem STATIC device.cpp entry.cpp virtual_file_system.cpp + devices/disc_image_device.cpp + devices/disc_image_entry.cpp + devices/disc_image_file.cpp devices/host_path_device.cpp devices/host_path_entry.cpp devices/host_path_file.cpp diff --git a/thirdparty/rexglue-sdk/src/native/filesystem/devices/disc_image_device.cpp b/thirdparty/rexglue-sdk/src/native/filesystem/devices/disc_image_device.cpp new file mode 100644 index 00000000..9d05e53e --- /dev/null +++ b/thirdparty/rexglue-sdk/src/native/filesystem/devices/disc_image_device.cpp @@ -0,0 +1,244 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2020 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + * + * @modified Tom Clay, 2026 - Adapted for ReXGlue runtime; parser hardened + * against rebuilt/truncated images (0xFFFF node terminators, + * per-entry bounds checks, directory-cycle detection). + */ + +#include +#include + +#include +#include + +#include +#include +#include + +namespace rex::filesystem { + +// XDVDFS sector size. Independent of the reported bytes_per_sector (0x200), +// which mirrors what the console reports for the mounted volume. +const size_t kXESectorSize = 2048; + +// Directory-entry ordinals are uint16 indexes of 4-byte words into the +// directory table. 0 and 0xFFFF both terminate a branch in the wild: 0 in +// original pressings, 0xFFFF in images rebuilt by common extraction tools. +const uint16_t kOrdinalTerminator = 0xFFFF; + +// Fixed part of a directory entry before the name bytes. +const size_t kEntryHeaderSize = 14; + +// Real discs nest a handful of levels; anything deeper is a damaged image. +const uint32_t kMaxDirectoryDepth = 64; + +DiscImageDevice::DiscImageDevice(const std::string_view mount_path, + const std::filesystem::path& host_path) + : Device(mount_path), name_("GDFX"), host_path_(host_path) {} + +DiscImageDevice::~DiscImageDevice() = default; + +bool DiscImageDevice::Initialize() { + mmap_ = memory::MappedMemory::Open(host_path_, memory::MappedMemory::Mode::kRead); + if (!mmap_) { + REXFS_ERROR("DiscImageDevice: could not map disc image: {}", rex::path_to_utf8(host_path_)); + return false; + } + + ParseState state; + state.ptr = mmap_->data(); + state.size = mmap_->size(); + auto result = Verify(&state); + if (result != Error::kSuccess) { + REXFS_ERROR("DiscImageDevice: failed to verify disc image header ({}): {}", int(result), + rex::path_to_utf8(host_path_)); + return false; + } + + result = ReadAllEntries(&state); + if (result != Error::kSuccess) { + REXFS_ERROR("DiscImageDevice: failed to read GDFX directory tree ({}): {}", int(result), + rex::path_to_utf8(host_path_)); + return false; + } + + return true; +} + +void DiscImageDevice::Dump(string::StringBuffer* string_buffer) { + auto global_lock = global_critical_region_.Acquire(); + root_entry_->Dump(string_buffer, 0); +} + +Entry* DiscImageDevice::ResolvePath(const std::string_view path) { + // The filesystem will have stripped our prefix off already, so the path will + // be in the form: + // some\PATH.foo + REXFS_TRACE("DiscImageDevice::ResolvePath({})", path); + return root_entry_->ResolvePath(path); +} + +DiscImageDevice::Error DiscImageDevice::Verify(ParseState* state) { + // Find sector 32 of the game partition - try at a few known base offsets: + // 0 (rebuilt game-partition-only image), the XSF variants, XGD1/XGD2/XGD3 + // full-dump bases. + static const size_t likely_offsets[] = { + 0x00000000, 0x0000FB20, 0x00020600, 0x02080000, 0x0FD90000, 0x18300000, + }; + bool magic_found = false; + for (size_t offset : likely_offsets) { + state->game_offset = offset; + if (VerifyMagic(state, state->game_offset + (32 * kXESectorSize))) { + magic_found = true; + break; + } + } + if (!magic_found) { + // File doesn't have the magic values - likely not a real GDFX source. + return Error::kErrorFileMismatch; + } + + // Read sector 32 to get FS state. + size_t vd_offset = state->game_offset + (32 * kXESectorSize); + if (state->size < vd_offset + kXESectorSize) { + return Error::kErrorReadError; + } + const uint8_t* fs_ptr = state->ptr + vd_offset; + // The volume descriptor carries the magic at both ends of the sector; a + // missing tail magic means a truncated or corrupt descriptor. + if (std::memcmp(fs_ptr + 0x7EC, "MICROSOFT*XBOX*MEDIA", 20) != 0) { + return Error::kErrorDamagedFile; + } + state->root_sector = memory::load(fs_ptr + 20); + state->root_size = memory::load(fs_ptr + 24); + state->root_offset = state->game_offset + (state->root_sector * kXESectorSize); + if (state->root_size < kEntryHeaderSize - 1 || state->root_size > 32 * 1024 * 1024) { + return Error::kErrorDamagedFile; + } + if (state->root_offset >= state->size || state->root_size > state->size - state->root_offset) { + return Error::kErrorDamagedFile; + } + + return Error::kSuccess; +} + +bool DiscImageDevice::VerifyMagic(ParseState* state, size_t offset) { + if (offset + 20 > state->size) { + return false; + } + + // Simple check to see if the given offset contains the magic value. + return std::memcmp(state->ptr + offset, "MICROSOFT*XBOX*MEDIA", 20) == 0; +} + +DiscImageDevice::Error DiscImageDevice::ReadAllEntries(ParseState* state) { + auto root_entry = new DiscImageEntry(this, nullptr, "", mmap_.get()); + root_entry->attributes_ = kFileAttributeDirectory; + root_entry_ = std::unique_ptr(root_entry); + + if (!ReadDirectory(state, state->root_offset, state->root_size, root_entry, 0)) { + return Error::kErrorDamagedFile; + } + + return Error::kSuccess; +} + +bool DiscImageDevice::ReadDirectory(ParseState* state, size_t table_offset, size_t table_size, + DiscImageEntry* parent, uint32_t depth) { + if (depth > kMaxDirectoryDepth) { + return false; + } + if (table_offset >= state->size || table_size > state->size - table_offset) { + return false; + } + if (!state->visited_tables.insert(table_offset).second) { + // A directory table referenced twice = a cycle in a damaged image. + return false; + } + + const uint8_t* table = state->ptr + table_offset; + + // Iterative walk over the entry AVL tree. Ordinals 0 and 0xFFFF are branch + // terminators; a seen-set guards against self-referencing nodes. + std::vector pending; + std::set seen; + pending.push_back(0); + while (!pending.empty()) { + uint16_t ordinal = pending.back(); + pending.pop_back(); + if (!seen.insert(ordinal).second) { + continue; + } + + size_t offset = size_t(ordinal) * 4; + if (offset + kEntryHeaderSize > table_size) { + // Padding at the tail of a directory sector; nothing to read here. + continue; + } + const uint8_t* p = table + offset; + + uint16_t node_l = memory::load(p + 0); + uint16_t node_r = memory::load(p + 2); + size_t sector = memory::load(p + 4); + size_t length = memory::load(p + 8); + uint8_t attributes = memory::load(p + 12); + uint8_t name_length = memory::load(p + 13); + + if (node_l && node_l != kOrdinalTerminator) { + pending.push_back(node_l); + } + if (node_r && node_r != kOrdinalTerminator) { + pending.push_back(node_r); + } + + if (!name_length || offset + kEntryHeaderSize + name_length > table_size) { + continue; + } + auto name = std::string(reinterpret_cast(p + kEntryHeaderSize), name_length); + + auto entry = DiscImageEntry::Create(this, parent, name, mmap_.get()); + entry->attributes_ = attributes | kFileAttributeReadOnly; + entry->size_ = length; + entry->allocation_size_ = rex::round_up(length, bytes_per_sector()); + + // Set to January 1, 1970 (UTC) in 100-nanosecond intervals + entry->create_timestamp_ = 10000 * 11644473600000LL; + entry->access_timestamp_ = 10000 * 11644473600000LL; + entry->write_timestamp_ = 10000 * 11644473600000LL; + + if (attributes & kFileAttributeDirectory) { + // Folder. + entry->data_offset_ = 0; + entry->data_size_ = 0; + auto* dir = entry.get(); + parent->children_.emplace_back(std::move(entry)); + if (length) { + // Not a leaf - read in children. + size_t child_offset = state->game_offset + (sector * kXESectorSize); + if (!ReadDirectory(state, child_offset, length, dir, depth + 1)) { + return false; + } + } + } else { + // File. + entry->data_offset_ = state->game_offset + (sector * kXESectorSize); + entry->data_size_ = length; + if (entry->data_offset_ > state->size || length > state->size - entry->data_offset_) { + // File data extends past the end of the image: truncated download or + // bad dump. Fail the mount so the caller reports it clearly. + return false; + } + parent->children_.emplace_back(std::move(entry)); + } + } + + return true; +} + +} // namespace rex::filesystem diff --git a/thirdparty/rexglue-sdk/src/native/filesystem/devices/disc_image_entry.cpp b/thirdparty/rexglue-sdk/src/native/filesystem/devices/disc_image_entry.cpp new file mode 100644 index 00000000..411e55a3 --- /dev/null +++ b/thirdparty/rexglue-sdk/src/native/filesystem/devices/disc_image_entry.cpp @@ -0,0 +1,51 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2020 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + * + * @modified Tom Clay, 2026 - Adapted for ReXGlue runtime + */ + +#include + +#include + +#include +#include + +namespace rex::filesystem { + +DiscImageEntry::DiscImageEntry(Device* device, Entry* parent, const std::string_view path, + memory::MappedMemory* mmap) + : Entry(device, parent, path), mmap_(mmap), data_offset_(0), data_size_(0) {} + +DiscImageEntry::~DiscImageEntry() = default; + +std::unique_ptr DiscImageEntry::Create(Device* device, Entry* parent, + const std::string_view name, + memory::MappedMemory* mmap) { + auto path = rex::string::utf8_join_guest_paths(parent->path(), name); + return std::make_unique(device, parent, path, mmap); +} + +X_STATUS DiscImageEntry::Open(uint32_t desired_access, File** out_file) { + *out_file = new DiscImageFile(desired_access, this); + return X_STATUS_SUCCESS; +} + +std::unique_ptr DiscImageEntry::OpenMapped(memory::MappedMemory::Mode mode, + size_t offset, size_t length) { + if (mode != memory::MappedMemory::Mode::kRead) { + // Only allow reads. + return nullptr; + } + + size_t real_offset = data_offset_ + offset; + size_t real_length = length ? std::min(length, data_size_) : data_size_; + return mmap_->Slice(real_offset, real_length); +} + +} // namespace rex::filesystem diff --git a/thirdparty/rexglue-sdk/src/native/filesystem/devices/disc_image_file.cpp b/thirdparty/rexglue-sdk/src/native/filesystem/devices/disc_image_file.cpp new file mode 100644 index 00000000..3dbfe9b7 --- /dev/null +++ b/thirdparty/rexglue-sdk/src/native/filesystem/devices/disc_image_file.cpp @@ -0,0 +1,42 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2013 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + * + * @modified Tom Clay, 2026 - Adapted for ReXGlue runtime + */ + +#include + +#include +#include + +#include + +namespace rex::filesystem { + +DiscImageFile::DiscImageFile(uint32_t file_access, DiscImageEntry* entry) + : File(file_access, entry), entry_(entry) {} + +DiscImageFile::~DiscImageFile() = default; + +void DiscImageFile::Destroy() { + delete this; +} + +X_STATUS DiscImageFile::ReadSync(std::span buffer, size_t byte_offset, + size_t* out_bytes_read) { + if (byte_offset >= entry_->size()) { + return X_STATUS_END_OF_FILE; + } + size_t real_offset = entry_->data_offset() + byte_offset; + size_t real_length = std::min(buffer.size(), entry_->data_size() - byte_offset); + std::memcpy(buffer.data(), entry_->mmap()->data() + real_offset, real_length); + *out_bytes_read = real_length; + return X_STATUS_SUCCESS; +} + +} // namespace rex::filesystem diff --git a/thirdparty/rexglue-sdk/src/native/filesystem/virtual_file_system.cpp b/thirdparty/rexglue-sdk/src/native/filesystem/virtual_file_system.cpp index 958ba457..32ec3eaa 100644 --- a/thirdparty/rexglue-sdk/src/native/filesystem/virtual_file_system.cpp +++ b/thirdparty/rexglue-sdk/src/native/filesystem/virtual_file_system.cpp @@ -104,11 +104,24 @@ Entry* VirtualFileSystem::ResolvePath(const std::string_view path) { normalized_path = resolved_path; } - // Find the device. - auto it = std::find_if(devices_.cbegin(), devices_.cend(), [&](const auto& d) { - return rex::string::utf8_starts_with_case(normalized_path, d->mount_path()); - }); - if (it == devices_.cend()) { + // Find the device. Registration order decides priority; a device marked + // layered() lets a resolution miss fall through to the next device with a + // matching mount path (loose files win, an image mounted below fills the + // gaps). Non-layered devices are terminal, the historical behaviour. + Device* device = nullptr; + Entry* entry = nullptr; + for (const auto& d : devices_) { + if (!rex::string::utf8_starts_with_case(normalized_path, d->mount_path())) { + continue; + } + device = d.get(); + auto relative_path = normalized_path.substr(device->mount_path().size()); + entry = device->ResolvePath(relative_path); + if (entry || !device->layered()) { + break; + } + } + if (!device) { REXFS_WARN("VFS: '{}' -> [no device]", path); // Supress logging the error for ShaderDumpxe:\CompareBackEnds as this is // not an actual problem nor something we care about. @@ -118,10 +131,6 @@ Entry* VirtualFileSystem::ResolvePath(const std::string_view path) { return nullptr; } - const auto& device = *it; - auto relative_path = normalized_path.substr(device->mount_path().size()); - auto* entry = device->ResolvePath(relative_path); - if (entry) { if (had_symlink) { REXFS_TRACE("VFS resolved '{}' via symlink '{}' on device '{}' -> '{}'", path, @@ -221,7 +230,9 @@ X_STATUS VirtualFileSystem::OpenFile(Entry* root_entry, const std::string_view p } // If the cached entry does not exist on host anymore, invalidate it. - if (parent_entry) { + // Only applies when the entry actually lives on the same host device as + // the parent (a layered lower-level entry is not backed by that host dir). + if (parent_entry && entry->device() == parent_entry->device()) { const auto* host_path_entry = dynamic_cast(parent_entry); if (host_path_entry) { const auto file_path = host_path_entry->host_path() / rex::to_path(entry->name()); @@ -233,6 +244,15 @@ X_STATUS VirtualFileSystem::OpenFile(Entry* root_entry, const std::string_view p } } + // Layered mounts: the parent directory may resolve on the top layer while + // the requested child exists only in a lower layer. Retry with a full-path + // resolution, which walks the layers. Inert unless the parent's device + // opted into layering. + if (!entry && !root_entry && parent_entry && parent_entry->device() && + parent_entry->device()->layered()) { + entry = ResolvePath(path); + } + // Check if exists (if we need it to), or that it doesn't (if it shouldn't). switch (creation_disposition) { case FileDisposition::kOpen: diff --git a/thirdparty/rexglue-sdk/src/native/ui/rex_app.cpp b/thirdparty/rexglue-sdk/src/native/ui/rex_app.cpp index 17aacb3b..d30e9aa4 100644 --- a/thirdparty/rexglue-sdk/src/native/ui/rex_app.cpp +++ b/thirdparty/rexglue-sdk/src/native/ui/rex_app.cpp @@ -36,13 +36,44 @@ #include #include +#include +#include + +#include #include +#include +#include + +#if REX_PLATFORM_WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#endif + #include REXCVAR_DEFINE_STRING(user_data_root, "", "Runtime", "Override user data path"); REXCVAR_DEFINE_STRING(update_data_root, "", "Runtime", "Override update data path"); +REXCVAR_DEFINE_BOOL(iso_direct, true, "Runtime", + "Allow mounting the game directly from a disc image (.iso). The loose " + "assets folder always takes priority when present; set false to ignore " + "disc images entirely."); +REXCVAR_DEFINE_STRING(game_iso, "", "Runtime", + "Path to the game's disc image (.iso). Empty = automatic: use the " + "assets folder if present, else scan for a matching .iso next to the " + "executable."); + +REXCVAR_DEFINE_BOOL(dlc_containers, true, "Runtime", + "Mount DLC content containers (STFS/LIVE packages) directly from the dlc " + "folder and the content root, no extraction needed. Containers take " + "priority over extracted package folders of the same name."); +REXCVAR_DEFINE_STRING(dlc_dir, "", "Runtime", + "Override the DLC container folder. Empty = 'dlc' next to the " + "executable."); + REXCVAR_DEFINE_BOOL(use_shader_disk_cache, true, "GPU", "Pre-compile the game's GPU pipelines from a persistent on-disk cache at " "launch (during load) and record new ones, so pipelines do not compile " @@ -52,6 +83,138 @@ REXCVAR_DEFINE_BOOL(use_shader_disk_cache, true, "GPU", namespace rex { +namespace { + +// A fatal setup problem the user must fix (missing/wrong game data). The log +// carries the details; the box exists so a double-clicked exe does not just +// silently vanish. +void ShowFatalMessageBox(const std::string& title, const std::string& message) { +#if REX_PLATFORM_WIN32 + auto wtitle = rex::string::to_utf16(title); + auto wmessage = rex::string::to_utf16(message); + MessageBoxW(nullptr, reinterpret_cast(wmessage.c_str()), + reinterpret_cast(wtitle.c_str()), MB_OK | MB_ICONERROR); +#else + (void)title; + (void)message; +#endif +} + +// Result of probing a candidate disc image: does it parse as a disc, does it +// contain a readable default.xex, and which title is it for. +struct ImageProbe { + bool readable = false; + uint32_t title_id = 0; + std::string reason; +}; + +// Reads the title id out of a xex2 image's execution-info optional header. +// All offsets are validated against the buffer; a malformed header simply +// fails the probe instead of crashing. +bool ReadXexTitleId(const std::vector& buf, uint32_t* out_title_id) { + if (buf.size() < sizeof(xex2_header)) { + return false; + } + const auto* header = reinterpret_cast(buf.data()); + if (header->magic != 0x58455832) { // 'XEX2' + return false; + } + const uint32_t header_count = header->header_count; + if (header_count > 1024 || 0x18 + size_t(header_count) * 8 > buf.size()) { + return false; + } + for (uint32_t i = 0; i < header_count; i++) { + const xex2_opt_header& opt = header->headers[i]; + if (opt.key != XEX_HEADER_EXECUTION_INFO) { + continue; + } + const uint32_t offset = opt.offset; + if (offset + sizeof(xex2_opt_execution_info) > buf.size()) { + return false; + } + const auto* info = reinterpret_cast(buf.data() + offset); + *out_title_id = info->title_id; + return true; + } + return false; +} + +// Mounts a candidate image standalone (no VFS registration) and reads the +// title id from its default.xex. +ImageProbe ProbeGameImage(const std::filesystem::path& path) { + ImageProbe probe; + rex::filesystem::DiscImageDevice device("", path); + if (!device.Initialize()) { + probe.reason = "not a readable Xbox 360 disc image (or a damaged dump)"; + return probe; + } + auto* entry = device.ResolvePath("default.xex"); + if (!entry) { + probe.reason = "image contains no default.xex"; + return probe; + } + rex::filesystem::File* file = nullptr; + if (XFAILED(entry->Open(rex::filesystem::FileAccess::kFileReadData, &file)) || !file) { + probe.reason = "could not open default.xex inside the image"; + return probe; + } + std::vector buf(std::min(entry->size(), 64 * 1024)); + size_t bytes_read = 0; + auto status = file->ReadSync(std::span(buf.data(), buf.size()), 0, &bytes_read); + file->Destroy(); + if (XFAILED(status) || bytes_read != buf.size()) { + probe.reason = "could not read default.xex inside the image"; + return probe; + } + uint32_t title_id = 0; + if (!ReadXexTitleId(buf, &title_id)) { + probe.reason = "default.xex inside the image has no readable title id"; + return probe; + } + probe.readable = true; + probe.title_id = title_id; + return probe; +} + +// Scans a directory (non-recursive) for *.iso files, probes each in name +// order, and returns the first whose title id matches (any title if +// expected_title_id is 0). Rejects are logged, not fatal. +std::filesystem::path ScanForGameImage(const std::filesystem::path& dir, + uint32_t expected_title_id) { + std::vector candidates; + std::error_code ec; + for (auto it = std::filesystem::directory_iterator(dir, ec); + !ec && it != std::filesystem::directory_iterator(); it.increment(ec)) { + if (!it->is_regular_file(ec)) { + continue; + } + auto ext = rex::path_to_utf8(it->path().extension()); + std::transform(ext.begin(), ext.end(), ext.begin(), + [](unsigned char c) { return char(std::tolower(c)); }); + if (ext == ".iso") { + candidates.push_back(it->path()); + } + } + std::sort(candidates.begin(), candidates.end()); + for (const auto& candidate : candidates) { + auto probe = ProbeGameImage(candidate); + if (!probe.readable) { + REXLOG_INFO("Game source: ignoring {}: {}", rex::path_to_utf8(candidate.filename()), + probe.reason); + continue; + } + if (expected_title_id && probe.title_id != expected_title_id) { + REXLOG_INFO("Game source: ignoring {}: title id {:08X} (expected {:08X})", + rex::path_to_utf8(candidate.filename()), probe.title_id, expected_title_id); + continue; + } + return candidate; + } + return {}; +} + +} // namespace + // --- ReXApp --- ReXApp::~ReXApp() = default; @@ -62,6 +225,119 @@ ReXApp::ReXApp(ui::WindowedAppContext& ctx, std::string_view name, PPCImageInfo AddPositionalOption("game_directory"); } +bool ReXApp::ResolveGameSource(const std::filesystem::path& exe_dir, + std::filesystem::path& out_image) { + out_image.clear(); + const uint32_t expected_title_id = OnGetExpectedTitleId(); + const bool iso_direct_enabled = REXCVAR_GET(iso_direct); + + const std::string app_name(GetName()); + // path_to_utf8 throughout: path::string() converts through the ANSI + // codepage on Windows, which garbles non-ASCII file names in the log and + // in the (UTF-8-consuming) message box. + auto reject_image = [&](const std::filesystem::path& path, const std::string& reason) { + REXLOG_ERROR("Game source: rejected image {}: {}", rex::path_to_utf8(path), reason); + auto message = fmt::format( + "The disc image was rejected:\n\n{}\n\n{}\n\n" + "Provide your own legally obtained copy of the game.", + rex::path_to_utf8(path), reason); + ShowFatalMessageBox(app_name, message); + return false; + }; + auto check_expected_title = [&](const ImageProbe& probe, const std::filesystem::path& path, + std::string& reason_out) { + if (!probe.readable) { + reason_out = probe.reason; + return false; + } + if (expected_title_id && probe.title_id != expected_title_id) { + reason_out = fmt::format("image is for title id {:08X}, this game expects {:08X}", + probe.title_id, expected_title_id); + return false; + } + return true; + }; + + // The game_directory argument may point straight at a disc image file. + if (!game_data_root_.empty() && std::filesystem::is_regular_file(game_data_root_)) { + auto image_path = game_data_root_; + std::string reason; + if (!iso_direct_enabled) { + return reject_image(image_path, "iso_direct is disabled in the config"); + } + if (!check_expected_title(ProbeGameImage(image_path), image_path, reason)) { + return reject_image(image_path, reason); + } + game_data_root_.clear(); + out_image = image_path; + REXLOG_INFO("Game source: iso: {}", rex::path_to_utf8(image_path)); + return true; + } + + const bool have_loose = std::filesystem::is_directory(game_data_root_) && + std::filesystem::exists(game_data_root_ / "default.xex"); + + if (iso_direct_enabled) { + const std::string iso_cvar = REXCVAR_GET(game_iso); + if (!iso_cvar.empty()) { + // An explicit config path must be honoured or fail loudly, never + // silently fall back. to_path: the toml string is UTF-8, not the ANSI + // codepage a bare path construction would assume on Windows. + std::filesystem::path image_path = rex::to_path(iso_cvar); + std::string reason; + if (!std::filesystem::is_regular_file(image_path)) { + return reject_image(image_path, "game_iso does not point at a file"); + } + if (!check_expected_title(ProbeGameImage(image_path), image_path, reason)) { + return reject_image(image_path, reason); + } + out_image = image_path; + } else { + out_image = ScanForGameImage(exe_dir, expected_title_id); + } + } + + if (have_loose) { + // Loose assets are the primary source, exactly as before; an image below + // only fills per-file gaps. + REXLOG_INFO("Game source: assets: {}", + rex::path_to_utf8(std::filesystem::absolute(game_data_root_))); + if (!out_image.empty()) { + REXLOG_INFO("Game source: iso underlay: {} (loose files override the image)", + rex::path_to_utf8(out_image)); + } + return true; + } + + if (!out_image.empty()) { + REXLOG_INFO("Game source: iso: {}", rex::path_to_utf8(std::filesystem::absolute(out_image))); + std::error_code ec; + if (std::filesystem::is_directory(game_data_root_) && + !std::filesystem::is_empty(game_data_root_, ec) && !ec) { + // A partial assets folder on top of an image: the modding path. + REXLOG_INFO("Game source: loose overlay: {} (loose files override the image)", + rex::path_to_utf8(std::filesystem::absolute(game_data_root_))); + } else { + game_data_root_.clear(); + } + return true; + } + + // Neither an assets folder nor a usable image: name both options clearly. + auto assets_hint = game_data_root_.empty() ? (exe_dir / "assets") : game_data_root_; + REXLOG_ERROR("Game source: none found - expected game files at {} or a .iso next to {}", + rex::path_to_utf8(assets_hint), rex::path_to_utf8(exe_dir)); + auto message = fmt::format( + "No game data was found.\n\n" + "Provide your own legally obtained copy of the game in one of two ways:\n\n" + "1. Extract the game's files into:\n {}\n\n" + "2. Place the game's disc image (.iso) next to the executable:\n {}\n\n" + "Advanced: set game_iso = \"path/to/image.iso\" in {}.toml.", + rex::path_to_utf8(assets_hint), rex::path_to_utf8(exe_dir), app_name); + ShowFatalMessageBox(app_name, message); + return false; +} + bool ReXApp::OnInitialize() { auto exe_dir = rex::filesystem::GetExecutableFolder(); @@ -127,16 +403,25 @@ bool ReXApp::OnInitialize() { } REXLOG_INFO("{} starting", GetName()); - REXLOG_INFO(" Game directory: {}", game_data_root_.string()); + + // Resolve where the game's data comes from. Runs after LoadConfig so the + // game_iso / iso_direct toml overrides apply. + std::filesystem::path game_image; + if (!ResolveGameSource(exe_dir, game_image)) { + return false; + } if (!user_data_root_.empty()) { - REXLOG_INFO(" User data: {}", user_data_root_.string()); + REXLOG_INFO(" User data: {}", rex::path_to_utf8(user_data_root_)); } if (!update_data_root_.empty()) { - REXLOG_INFO(" Update data: {}", update_data_root_.string()); + REXLOG_INFO(" Update data: {}", rex::path_to_utf8(update_data_root_)); } // Create runtime runtime_ = std::make_unique(game_data_root_, user_data_root_, update_data_root_); + if (!game_image.empty()) { + runtime_->set_game_image_path(game_image); + } runtime_->set_app_context(&app_context()); // Build runtime config with default platform backends @@ -172,6 +457,15 @@ bool ReXApp::OnInitialize() { return false; } + // Discover raw DLC containers now that the title id is known. Logs one + // line per package found; extracted folders keep priority. + if (REXCVAR_GET(dlc_containers)) { + const std::string dlc_dir_cvar = REXCVAR_GET(dlc_dir); + const std::filesystem::path dlc_dir = + dlc_dir_cvar.empty() ? (exe_dir / "dlc") : rex::to_path(dlc_dir_cvar); + runtime_->kernel_state()->content_manager()->DiscoverContainers(dlc_dir); + } + // Initialize rexcrt heap after LoadXexImage to avoid guest memory writes // corrupting the heap region. rexcrt_heap is set by codegen (REXCRT_HEAP) // when [rexcrt] contains heap functions -- originals are stripped so init diff --git a/thirdparty/rexglue-sdk/src/system/runtime.cpp b/thirdparty/rexglue-sdk/src/system/runtime.cpp index 4b212078..26b783c9 100644 --- a/thirdparty/rexglue-sdk/src/system/runtime.cpp +++ b/thirdparty/rexglue-sdk/src/system/runtime.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -222,29 +223,58 @@ uint8_t* Runtime::virtual_membase() const { } bool Runtime::SetupVfs() { - if (game_data_root_.empty()) { + const bool have_image = !game_image_path_.empty(); + if (game_data_root_.empty() && !have_image) { REXSYS_WARN("Runtime::SetupVfs: No game_data_root specified, skipping VFS setup"); return true; } - auto abs_game_root = std::filesystem::absolute(game_data_root_); - if (!std::filesystem::exists(abs_game_root)) { - REXSYS_ERROR("Runtime::SetupVfs: game_data_root does not exist: {}", abs_game_root.string()); - return false; + auto mount_path = "\\Device\\Harddisk0\\Partition1"; + + // Loose directory first: with an image below it, loose files always win and + // the image only fills the gaps (layered). Without an image this is the + // historical single-device path, bit for bit. + if (!game_data_root_.empty()) { + auto abs_game_root = std::filesystem::absolute(game_data_root_); + if (!std::filesystem::exists(abs_game_root)) { + if (!have_image) { + REXSYS_ERROR("Runtime::SetupVfs: game_data_root does not exist: {}", + rex::path_to_utf8(abs_game_root)); + return false; + } + // Image-only launch; the missing loose directory is fine. + } else { + auto device = + std::make_unique(mount_path, abs_game_root, true); + if (!device->Initialize()) { + REXSYS_ERROR("Runtime::SetupVfs: Failed to initialize host path device"); + return false; + } + device->set_layered(have_image); + if (!file_system_->RegisterDevice(std::move(device))) { + REXSYS_ERROR("Runtime::SetupVfs: Failed to register host path device"); + return false; + } + REXSYS_INFO(" Mounted {} at {}{}", rex::path_to_utf8(abs_game_root), mount_path, + have_image ? " (loose layer, overrides image)" : ""); + } } - // Mount game_data_root as \Device\Harddisk0\Partition1 - auto mount_path = "\\Device\\Harddisk0\\Partition1"; - auto device = std::make_unique(mount_path, abs_game_root, true); - if (!device->Initialize()) { - REXSYS_ERROR("Runtime::SetupVfs: Failed to initialize host path device"); - return false; + // Disc image below the loose layer (or alone). + if (have_image) { + auto image_device = + std::make_unique(mount_path, game_image_path_); + if (!image_device->Initialize()) { + REXSYS_ERROR("Runtime::SetupVfs: Failed to mount disc image: {}", + rex::path_to_utf8(game_image_path_)); + return false; + } + if (!file_system_->RegisterDevice(std::move(image_device))) { + REXSYS_ERROR("Runtime::SetupVfs: Failed to register disc image device"); + return false; + } + REXSYS_INFO(" Mounted {} at {}", rex::path_to_utf8(game_image_path_), mount_path); } - if (!file_system_->RegisterDevice(std::move(device))) { - REXSYS_ERROR("Runtime::SetupVfs: Failed to register host path device"); - return false; - } - REXSYS_INFO(" Mounted {} at {}", abs_game_root.string(), mount_path); // Register symbolic links for game: and D: file_system_->RegisterSymbolicLink("game:", mount_path); @@ -260,7 +290,7 @@ bool Runtime::SetupVfs() { std::make_unique(update_mount, abs_update_root, true); if (update_device->Initialize() && file_system_->RegisterDevice(std::move(update_device))) { file_system_->RegisterSymbolicLink("update:", update_mount); - REXSYS_INFO(" Mounted {} at update:", abs_update_root.string()); + REXSYS_INFO(" Mounted {} at update:", rex::path_to_utf8(abs_update_root)); } } } diff --git a/thirdparty/rexglue-sdk/src/system/xam/content_manager.cpp b/thirdparty/rexglue-sdk/src/system/xam/content_manager.cpp index 974c4057..64182069 100644 --- a/thirdparty/rexglue-sdk/src/system/xam/content_manager.cpp +++ b/thirdparty/rexglue-sdk/src/system/xam/content_manager.cpp @@ -9,6 +9,7 @@ * @modified Tom Clay, 2026 - Adapted for ReXGlue runtime */ +#include #include #include #include @@ -20,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -60,10 +62,31 @@ ContentPackage::ContentPackage(KernelState* kernel_state, const std::string_view content_data_ = data; auto fs = kernel_state_->file_system(); - auto device = - std::make_unique(device_path_, package_path, false); - device->Initialize(); - fs->RegisterDevice(std::move(device)); + std::error_code ec; + if (std::filesystem::is_regular_file(package_path, ec)) { + // A raw STFS/LIVE container: mount it directly, no extraction step. + is_container_ = true; + auto device = + std::make_unique(device_path_, package_path); + if (device->Initialize()) { + for (size_t i = 0; i < 0x10; i++) { + if (device->header().header.licenses[i].license_flags) { + container_license_ |= device->header().header.licenses[i].license_bits; + } + } + fs->RegisterDevice(std::move(device)); + device_mounted_ = true; + } else { + REXSYS_ERROR("ContentPackage: failed to mount container {}", rex::path_to_utf8(package_path)); + } + } else { + // An extracted folder: the historical path, unchanged. + auto device = + std::make_unique(device_path_, package_path, false); + device->Initialize(); + fs->RegisterDevice(std::move(device)); + device_mounted_ = true; + } fs->RegisterSymbolicLink(root_name_ + ":", device_path_); } @@ -165,7 +188,7 @@ std::vector ContentManager::ListContent(uint32_t device auto file_infos = rex::filesystem::ListFiles(package_root); for (const auto& file_info : file_infos) { if (file_info.type != rex::filesystem::FileInfo::Type::kDirectory) { - // Directories only. + // Directories only; raw container files are handled below. continue; } @@ -184,22 +207,78 @@ std::vector ContentManager::ListContent(uint32_t device } } + // Discovered raw containers take priority over an ambient extracted install + // for the same package name - the user placed them next to the exe, so they + // win, same rule as the assets folder (and Windows' local-DLL search order). + // A container replaces its extracted twin in the listing; unique names + // simply append. + if (!containers_.empty() && title_id == kernel_state_->title_id()) { + for (const auto& [key, container] : containers_) { + if (container.content_type != content_type) { + continue; + } + XCONTENT_AGGREGATE_DATA content_data; + content_data.device_id = device_id; + content_data.content_type = content_type; + content_data.set_display_name(container.display_name.empty() + ? rex::path_to_utf16(container.path.filename()) + : container.display_name); + content_data.set_file_name(key.view()); + content_data.title_id = title_id; + content_data.xuid = xuid; + auto existing = std::find_if(result.begin(), result.end(), [&](const auto& data) { + return rex::string::utf8_equal_case(data.file_name(), key.view()); + }); + if (existing != result.end()) { + *existing = content_data; + } else { + result.emplace_back(std::move(content_data)); + } + } + } + return result; } +const DiscoveredContainer* ContentManager::FindContainer(const std::string_view file_name, + XContentType content_type) const { + auto it = containers_.find(string::string_key_case(file_name)); + if (it == containers_.end() || it->second.content_type != content_type) { + return nullptr; + } + return &it->second; +} + +std::filesystem::path ContentManager::ResolvePackageDataPath(uint64_t xuid, + const XCONTENT_AGGREGATE_DATA& data) { + // A container the user placed (the dlc folder, or dropped into the content + // root) takes priority over an ambient extracted install - same rule as the + // assets folder and Windows' local-DLL search order. + if (const auto* container = FindContainer(data.file_name(), data.content_type)) { + return container->path; + } + // Else the canonical package path - an extracted folder, or a container + // file sitting at the package's canonical place in the content root. + auto package_path = ResolvePackagePath(xuid, data); + std::error_code ec; + if (std::filesystem::exists(package_path, ec)) { + return package_path; + } + return {}; +} + std::unique_ptr ContentManager::ResolvePackage( const std::string_view root_name, uint64_t xuid, const XCONTENT_AGGREGATE_DATA& data) { - auto package_path = ResolvePackagePath(xuid, data); - if (!std::filesystem::exists(package_path)) { + auto data_path = ResolvePackageDataPath(xuid, data); + if (data_path.empty()) { return nullptr; } - auto package = std::make_unique(kernel_state_, root_name, data, package_path); + auto package = std::make_unique(kernel_state_, root_name, data, data_path); return package; } bool ContentManager::ContentExists(uint64_t xuid, const XCONTENT_AGGREGATE_DATA& data) { - auto path = ResolvePackagePath(xuid, data); - return std::filesystem::exists(path); + return !ResolvePackageDataPath(xuid, data).empty(); } X_RESULT ContentManager::WriteContentHeaderFile(uint64_t xuid, XCONTENT_AGGREGATE_DATA data, @@ -308,12 +387,15 @@ X_RESULT ContentManager::OpenContent(const std::string_view root_name, uint64_t } } - auto package_path = ResolvePackagePath(xuid, data); - if (!std::filesystem::exists(package_path)) { + auto package = ResolvePackage(root_name, xuid, data); + if (!package) { + return X_ERROR_FILE_NOT_FOUND; + } + if (!package->device_mounted()) { + // A corrupt/truncated container must read as "content missing", not as an + // empty package that succeeds and then mysteriously has no files. return X_ERROR_FILE_NOT_FOUND; } - auto package = ResolvePackage(root_name, xuid, data); - assert_not_null(package); package->LoadPackageLicenseMask(ResolvePackageHeaderPath( data.file_name(), xuid, kernel_state_->title_id(), data.content_type)); content_license = package->GetPackageLicense(); @@ -397,6 +479,18 @@ X_RESULT ContentManager::DeleteContent(uint64_t xuid, const XCONTENT_AGGREGATE_D return X_ERROR_ACCESS_DENIED; } + // Never delete a directly-mounted container: it is the user's only copy of + // that package, not something this port installed. + { + std::error_code ec; + auto data_path = ResolvePackageDataPath(xuid, data); + if (!data_path.empty() && std::filesystem::is_regular_file(data_path, ec)) { + REXSYS_WARN("ContentManager: refusing to delete container-backed content {}", + rex::path_to_utf8(data_path)); + return X_ERROR_ACCESS_DENIED; + } + } + auto package_path = ResolvePackagePath(xuid, data); std::error_code ec; auto dir_removed = std::filesystem::remove_all(package_path, ec); @@ -443,6 +537,17 @@ X_RESULT ContentManager::UnmountAndDeleteContent(uint64_t xuid, } delete package; + // Never delete a directly-mounted container (the user's only copy). + { + std::error_code ec; + auto data_path = ResolvePackageDataPath(xuid, data); + if (!data_path.empty() && std::filesystem::is_regular_file(data_path, ec)) { + REXSYS_WARN("ContentManager: refusing to delete container-backed content {}", + rex::path_to_utf8(data_path)); + return X_ERROR_ACCESS_DENIED; + } + } + // Delete phase: remove package directory and .header file auto package_path = ResolvePackagePath(xuid, data); @@ -668,6 +773,135 @@ X_RESULT ContentManager::InstallContent(const std::filesystem::path& package_pat return WriteContentHeaderFile(0, content_data, license_mask); } +// Xbox 360 title update content type. Deliberately not mounted: this port +// statically recompiles the base executable at build time, so a TU's +// executable delta patch cannot apply and is not needed. Content sets in the +// wild usually ship one; that is normal and harmless. +static constexpr uint32_t kTitleUpdateContentType = 0x000B0000; + +void ContentManager::DiscoverContainersInDir(const std::filesystem::path& dir, const char* source, + bool recursive, uint32_t title_id) { + std::error_code ec; + if (dir.empty() || !std::filesystem::is_directory(dir, ec) || ec) { + return; + } + + auto handle_file = [&](const std::filesystem::path& path) { + auto header = rex::filesystem::StfsContainerDevice::ReadPackageHeader(path); + if (!header) { + // Not an STFS/LIVE/PIRS container (thumbnails, .header sidecars, ...). + REXSYS_DEBUG("ContentManager: ignoring non-container file {}", rex::path_to_utf8(path)); + return; + } + const uint32_t container_title = header->metadata.execution_info.title_id; + if (container_title != title_id) { + REXSYS_DEBUG("ContentManager: ignoring container for another title ({:08X}): {}", + container_title, rex::path_to_utf8(path)); + return; + } + const XContentType content_type = header->metadata.content_type; + if (uint32_t(content_type) == kTitleUpdateContentType) { + REXSYS_DEBUG("ContentManager: title update package present (not used by this port): {}", + rex::path_to_utf8(path.filename())); + return; + } + if (content_type != XContentType::kMarketplaceContent) { + REXSYS_DEBUG("ContentManager: ignoring container of content type {:08X}: {}", + uint32_t(content_type), rex::path_to_utf8(path)); + return; + } + + // Key = the file name enumeration hands to the game (42-char field). + auto key_name = rex::path_to_utf8(path.filename()); + if (key_name.size() > 42) { + key_name.resize(42); + } + if (containers_.count(string::string_key_case(key_name))) { + REXSYS_DEBUG("ContentManager: duplicate container name {}, keeping the first found", + key_name); + return; + } + + DiscoveredContainer container; + container.path = path; + container.content_type = content_type; + container.title_id = container_title; + container.display_name = header->metadata.display_name(XLanguage::kEnglish); + container.source = source; + containers_.insert({string::string_key_case::create(key_name), std::move(container)}); + }; + + if (recursive) { + for (auto it = std::filesystem::recursive_directory_iterator( + dir, std::filesystem::directory_options::skip_permission_denied, ec); + !ec && it != std::filesystem::recursive_directory_iterator(); it.increment(ec)) { + if (it->is_regular_file(ec)) { + handle_file(it->path()); + } + } + } else { + for (auto it = std::filesystem::directory_iterator( + dir, std::filesystem::directory_options::skip_permission_denied, ec); + !ec && it != std::filesystem::directory_iterator(); it.increment(ec)) { + if (it->is_regular_file(ec)) { + handle_file(it->path()); + } + } + } +} + +void ContentManager::DiscoverContainers(const std::filesystem::path& dlc_dir) { + const uint32_t title_id = kernel_state_->title_id(); + containers_.clear(); + + // The dlc folder next to the exe: recursive, so a flat dump, an unzipped + // content set, and Xenia's content-directory layout all work unchanged. + DiscoverContainersInDir(dlc_dir, "dlc", true, title_id); + // Containers dropped straight into the content root's marketplace folder. + DiscoverContainersInDir(ResolvePackageRoot(0, XContentType::kMarketplaceContent, title_id), + "content root", false, title_id); + + // Startup report, one line per package: "did my DLC install work?" becomes + // a log grep instead of a guess. Containers take priority, so a folder with + // a same-named container is the overridden one. + size_t folder_count = 0; + auto package_root = ResolvePackageRoot(0, XContentType::kMarketplaceContent, title_id); + auto file_infos = rex::filesystem::ListFiles(package_root); + for (const auto& file_info : file_infos) { + if (file_info.type != rex::filesystem::FileInfo::Type::kDirectory) { + continue; + } + auto name = rex::path_to_utf8(file_info.name); + std::string display; + XCONTENT_AGGREGATE_DATA content_data; + if (XSUCCEEDED(ReadContentHeaderFile(name, 0, title_id, XContentType::kMarketplaceContent, + content_data))) { + display = rex::string::to_utf8(content_data.display_name()); + } + const bool overridden = + FindContainer(name, XContentType::kMarketplaceContent) != nullptr; + REXSYS_INFO("DLC: \"{}\" [{}] - extracted folder{}", display.empty() ? name : display, name, + overridden ? " (overridden by a container)" : ""); + folder_count++; + } + + std::vector sorted_containers; + sorted_containers.reserve(containers_.size()); + for (const auto& entry : containers_) { + sorted_containers.push_back(&entry); + } + std::sort(sorted_containers.begin(), sorted_containers.end(), + [](const auto* a, const auto* b) { return a->first.view() < b->first.view(); }); + for (const auto* entry : sorted_containers) { + const auto& container = entry->second; + REXSYS_INFO("DLC: \"{}\" [{}] - container ({}): {}", + rex::string::to_utf8(container.display_name), entry->first.view(), + container.source, rex::path_to_utf8(container.path)); + } + REXSYS_INFO("DLC: {} extracted folder(s), {} container(s) mounted with priority", folder_count, + containers_.size()); +} + } // namespace xam } // namespace system } // namespace rex