This commit is contained in:
patchzyy
2026-08-23 17:10:50 +02:00
commit ec226e8348
1099 changed files with 400981 additions and 0 deletions
@@ -0,0 +1,82 @@
#pragma once
#include "isa/big_endian.h"
#include <array>
#include <cstddef>
#include <cstdint>
namespace PadStatusContract {
inline constexpr std::size_t kGuestStatusSize = 0x0C;
using GuestStatus = std::array<uint8_t, kGuestStatusSize>;
struct Fields {
uint16_t buttons = 0;
int8_t stickX = 0;
int8_t stickY = 0;
int8_t substickX = 0;
int8_t substickY = 0;
uint8_t triggerLeft = 0;
uint8_t triggerRight = 0;
uint8_t analogA = 0;
uint8_t analogB = 0;
int8_t error = 0;
};
inline GuestStatus Encode(const Fields& fields)
{
GuestStatus status{};
BigEndian::Write16(status.data(), fields.buttons);
status[0x02] = static_cast<uint8_t>(fields.stickX);
status[0x03] = static_cast<uint8_t>(fields.stickY);
status[0x04] = static_cast<uint8_t>(fields.substickX);
status[0x05] = static_cast<uint8_t>(fields.substickY);
status[0x06] = fields.triggerLeft;
status[0x07] = fields.triggerRight;
status[0x08] = fields.analogA;
status[0x09] = fields.analogB;
status[0x0A] = static_cast<uint8_t>(fields.error);
return status;
}
} // namespace PadStatusContract
namespace WpadContract {
inline constexpr std::size_t kChannelCount = 4;
inline constexpr int32_t kStatusDisabled = 0;
inline constexpr int32_t kStatusReady = 3;
inline constexpr int32_t kErrorNoController = -1;
inline constexpr int32_t kErrorNotReady = -2;
inline constexpr int32_t kErrorBadChannel = -6;
inline constexpr int32_t kExtensionCore = 0;
class State {
public:
void Initialize() { m_initialized = true; }
bool IsInitialized() const { return m_initialized; }
int32_t GetLibraryStatus() const { return m_initialized ? kStatusReady : kStatusDisabled; }
int32_t GetDataFormat(uint32_t chan) const
{
if (chan >= kChannelCount) {
return kErrorBadChannel;
}
return m_initialized ? 0 : kErrorNotReady;
}
int32_t SetDataFormat(uint32_t chan, int32_t format) const
{
(void)format;
if (chan >= kChannelCount) {
return kErrorBadChannel;
}
return m_initialized ? kErrorNoController : kErrorNotReady;
}
private:
bool m_initialized = false;
};
} // namespace WpadContract
+338
View File
@@ -0,0 +1,338 @@
#pragma once
#include "isa/big_endian.h"
#include <algorithm>
#include <cctype>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <limits>
#include <map>
#include <memory>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace DvdFstContract {
struct RegisteredFile {
std::string hostPath;
std::string dvdPath;
uint32_t size = 0;
uint32_t discOffsetWords = 0;
};
struct IndexedEntry {
std::string hostPath;
std::string dvdPath;
uint32_t size = 0;
uint32_t discOffsetWords = 0;
uint32_t parentIndex = 0;
uint32_t subtreeEnd = 0;
bool isDirectory = false;
};
struct Image {
std::vector<IndexedEntry> entries;
std::map<std::string, int32_t> pathToEntry;
std::vector<uint8_t> bytes;
};
struct GuestPlacement {
uint32_t address = 0;
uint32_t reservedArenaHi = 0;
};
inline std::string CanonicalizePath(const std::string& input) {
std::string path = input;
std::replace(path.begin(), path.end(), '\\', '/');
std::vector<std::string> components;
size_t cursor = 0;
while (cursor < path.size()) {
while (cursor < path.size() && path[cursor] == '/') {
++cursor;
}
const size_t start = cursor;
while (cursor < path.size() && path[cursor] != '/') {
++cursor;
}
if (start == cursor) {
continue;
}
std::string component = path.substr(start, cursor - start);
if (component == ".") {
continue;
}
if (component == "..") {
if (!components.empty()) {
components.pop_back();
}
continue;
}
components.push_back(std::move(component));
}
std::string canonical = "/";
for (size_t i = 0; i < components.size(); ++i) {
if (i != 0) {
canonical.push_back('/');
}
canonical += components[i];
}
return canonical;
}
inline std::string NormalizeLookupPath(const std::string& input) {
std::string path = CanonicalizePath(input);
std::transform(path.begin(), path.end(), path.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
return path;
}
namespace Detail {
struct TreeNode {
std::string name;
std::map<std::string, std::unique_ptr<TreeNode>> children;
std::optional<RegisteredFile> file;
};
inline std::vector<std::string> Components(const std::string& canonicalPath) {
std::vector<std::string> result;
size_t cursor = canonicalPath == "/" ? canonicalPath.size() : 1;
while (cursor < canonicalPath.size()) {
const size_t slash = canonicalPath.find('/', cursor);
const size_t end = slash == std::string::npos ? canonicalPath.size() : slash;
result.push_back(canonicalPath.substr(cursor, end - cursor));
cursor = end + 1;
}
return result;
}
inline std::string Lowercase(std::string value) {
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
return value;
}
inline uint32_t EmitTree(const TreeNode& directory,
uint32_t directoryIndex,
const std::string& directoryPath,
Image& image,
std::vector<std::string>& names) {
for (const auto& [lookupName, childPointer] : directory.children) {
(void)lookupName;
const TreeNode& child = *childPointer;
const std::string childPath = directoryPath == "/"
? "/" + child.name
: directoryPath + "/" + child.name;
const uint32_t index = static_cast<uint32_t>(image.entries.size());
if (!child.children.empty()) {
if (child.file.has_value()) {
throw std::runtime_error("DVD FST path is both a file and a directory: " + childPath);
}
image.entries.push_back({{}, childPath, 0, 0, directoryIndex, 0, true});
names.push_back(child.name);
image.pathToEntry.emplace(NormalizeLookupPath(childPath), static_cast<int32_t>(index));
image.entries[index].subtreeEnd = EmitTree(child, index, childPath, image, names);
continue;
}
if (!child.file.has_value()) {
throw std::runtime_error("DVD FST contains an empty implicit node: " + childPath);
}
const RegisteredFile& file = *child.file;
image.entries.push_back({file.hostPath, childPath, file.size, file.discOffsetWords,
directoryIndex, index + 1, false});
names.push_back(child.name);
image.pathToEntry.emplace(NormalizeLookupPath(childPath), static_cast<int32_t>(index));
}
return static_cast<uint32_t>(image.entries.size());
}
} // namespace Detail
inline Image BuildImage(const std::vector<RegisteredFile>& registrations) {
// Overlay scanning deliberately registers later mappings last. Collapse those
// mappings before assigning FST indices so one guest path has one stable entry.
std::map<std::string, RegisteredFile> filesByPath;
for (RegisteredFile file : registrations) {
file.dvdPath = CanonicalizePath(file.dvdPath);
if (file.dvdPath == "/") {
throw std::runtime_error("DVD FST cannot register the root as a file");
}
filesByPath[NormalizeLookupPath(file.dvdPath)] = std::move(file);
}
Detail::TreeNode root;
for (const auto& [lookupPath, file] : filesByPath) {
(void)lookupPath;
Detail::TreeNode* node = &root;
const std::vector<std::string> components = Detail::Components(file.dvdPath);
for (const std::string& component : components) {
const std::string key = Detail::Lowercase(component);
auto& child = node->children[key];
if (!child) {
child = std::make_unique<Detail::TreeNode>();
child->name = component;
}
node = child.get();
}
node->name = components.back();
node->file = file;
}
Image image;
image.entries.push_back({{}, "/", 0, 0, 0, 0, true});
image.pathToEntry.emplace("/", 0);
std::vector<std::string> names(1);
image.entries[0].subtreeEnd = Detail::EmitTree(root, 0, "/", image, names);
if (image.entries.size() > std::numeric_limits<uint32_t>::max() / 12u) {
throw std::runtime_error("DVD FST contains too many entries");
}
const size_t entriesSize = image.entries.size() * 12u;
std::vector<uint8_t> stringTable(1, 0);
std::vector<uint32_t> nameOffsets(image.entries.size(), 0);
for (size_t i = 1; i < names.size(); ++i) {
if (stringTable.size() > 0x00FFFFFFu) {
throw std::runtime_error("DVD FST name table exceeds the Wii 24-bit offset limit");
}
nameOffsets[i] = static_cast<uint32_t>(stringTable.size());
stringTable.insert(stringTable.end(), names[i].begin(), names[i].end());
stringTable.push_back(0);
}
image.bytes.assign(entriesSize + stringTable.size(), 0);
for (size_t i = 0; i < image.entries.size(); ++i) {
const IndexedEntry& entry = image.entries[i];
const uint32_t typeAndName = (entry.isDirectory ? 0x01000000u : 0u) | nameOffsets[i];
const uint32_t word1 = entry.isDirectory ? entry.parentIndex : entry.discOffsetWords;
const uint32_t word2 = entry.isDirectory ? entry.subtreeEnd : entry.size;
BigEndian::Write32(image.bytes.data(), i * 12u + 0u, typeAndName);
BigEndian::Write32(image.bytes.data(), i * 12u + 4u, word1);
BigEndian::Write32(image.bytes.data(), i * 12u + 8u, word2);
}
std::copy(stringTable.begin(), stringTable.end(), image.bytes.begin() + entriesSize);
return image;
}
inline std::optional<GuestPlacement> ReserveBelowArena(uint32_t arenaLo,
uint32_t arenaHi,
size_t byteCount) {
constexpr uint32_t kAlignment = 32;
if (byteCount == 0 || byteCount > std::numeric_limits<uint32_t>::max() || arenaHi <= arenaLo) {
return std::nullopt;
}
const uint32_t size = static_cast<uint32_t>(byteCount);
if (size > arenaHi - arenaLo) {
return std::nullopt;
}
const uint32_t unaligned = arenaHi - size;
const uint32_t address = unaligned & ~(kAlignment - 1u);
if (address < arenaLo || static_cast<uint64_t>(address) + size > arenaHi) {
return std::nullopt;
}
return GuestPlacement{address, address};
}
} // namespace DvdFstContract
namespace DvdReadContract {
inline constexpr int32_t kInterruptTransferComplete = 1;
inline constexpr int32_t kInterruptDriveError = 2;
struct LowReadCompletion {
int32_t returnValue;
int32_t callbackResult;
};
inline constexpr LowReadCompletion CompletionFor(bool succeeded) noexcept {
return succeeded ? LowReadCompletion{1, kInterruptTransferComplete}
: LowReadCompletion{0, kInterruptDriveError};
}
enum class HostReadFailure : uint8_t {
None,
MissingFile,
BadOffset,
ShortRead,
};
inline constexpr const char* Describe(HostReadFailure failure) noexcept {
switch (failure) {
case HostReadFailure::None:
return "no error";
case HostReadFailure::MissingFile:
return "host file is missing or cannot be opened";
case HostReadFailure::BadOffset:
return "read offset is outside the host file";
case HostReadFailure::ShortRead:
return "host file did not contain the complete requested range";
}
return "unknown host read error";
}
// Read into private storage first and publish it only after the complete host
// range has been obtained. Callers can therefore leave a guest DMA destination
// untouched for every failure, including a host file truncated after indexing.
inline bool ReadExact(const std::filesystem::path& hostPath,
uint64_t offset,
uint32_t length,
std::vector<uint8_t>& destination,
HostReadFailure& failure) {
failure = HostReadFailure::None;
std::ifstream file(hostPath, std::ios::binary);
if (!file.is_open()) {
failure = HostReadFailure::MissingFile;
return false;
}
file.seekg(0, std::ios::end);
const std::streamoff fileSize = file.tellg();
if (fileSize < 0 ||
offset > static_cast<uint64_t>(std::numeric_limits<std::streamoff>::max()) ||
offset >= static_cast<uint64_t>(fileSize)) {
failure = HostReadFailure::BadOffset;
return false;
}
const uint64_t remaining = static_cast<uint64_t>(fileSize) - offset;
if (static_cast<uint64_t>(length) > remaining) {
failure = HostReadFailure::ShortRead;
return false;
}
std::vector<uint8_t> staged(length);
file.seekg(static_cast<std::streamoff>(offset), std::ios::beg);
if (!file) {
failure = HostReadFailure::BadOffset;
return false;
}
if (length != 0) {
file.read(reinterpret_cast<char*>(staged.data()),
static_cast<std::streamsize>(length));
if (file.gcount() != static_cast<std::streamsize>(length)) {
failure = HostReadFailure::ShortRead;
return false;
}
}
destination = std::move(staged);
return true;
}
} // namespace DvdReadContract
@@ -0,0 +1,199 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <exception>
#include <memory>
#include <optional>
#include <utility>
namespace NetworkDeferredContract {
enum class PreparationState : uint8_t {
NotApplicable,
Ready,
Error,
};
// A recognized operation must not be represented by an empty optional: doing
// so makes malformed input and resource failures indistinguishable from an
// unrelated ioctl and lets the caller fall through to a blocking fallback.
template <typename Work> class Preparation {
public:
static Preparation NotApplicable() {
return Preparation(PreparationState::NotApplicable, 0, std::nullopt);
}
static Preparation Ready(Work work, int32_t failureResult) {
return Preparation(PreparationState::Ready, failureResult,
std::optional<Work>(std::move(work)));
}
static Preparation Error(int32_t result) {
return Preparation(PreparationState::Error, result, std::nullopt);
}
PreparationState State() const noexcept { return state_; }
int32_t FailureResult() const noexcept { return failureResult_; }
Work &&TakeWork() && { return std::move(*work_); }
private:
Preparation(PreparationState state, int32_t failureResult,
std::optional<Work> work)
: state_(state), failureResult_(failureResult), work_(std::move(work)) {}
PreparationState state_ = PreparationState::NotApplicable;
int32_t failureResult_ = 0;
std::optional<Work> work_;
};
enum class StartDisposition : uint8_t {
NotApplicable,
Started,
ImmediateResult,
};
struct StartOutcome {
StartDisposition disposition = StartDisposition::NotApplicable;
int32_t result = 0;
uint64_t token = 0;
static constexpr StartOutcome NotApplicable() noexcept { return {}; }
static constexpr StartOutcome Started(uint64_t token) noexcept {
return {StartDisposition::Started, 0, token};
}
static constexpr StartOutcome Immediate(int32_t result) noexcept {
return {StartDisposition::ImmediateResult, result, 0};
}
};
// Launcher returns a token when the operation is fully installed. Token zero
// is valid for an asynchronous route because only synchronous callers consume
// it. A launcher failure or exception becomes the operation-specific immediate
// error; it can never be reinterpreted as "not applicable".
template <typename Work, typename Launcher>
StartOutcome StartPrepared(Preparation<Work> &&preparation,
Launcher &&launcher) {
const PreparationState state = preparation.State();
const int32_t failureResult = preparation.FailureResult();
if (state == PreparationState::NotApplicable) {
return StartOutcome::NotApplicable();
}
if (state == PreparationState::Error) {
return StartOutcome::Immediate(failureResult);
}
try {
const std::optional<uint64_t> token =
std::forward<Launcher>(launcher)(std::move(preparation).TakeWork());
return token ? StartOutcome::Started(*token)
: StartOutcome::Immediate(failureResult);
} catch (...) {
return StartOutcome::Immediate(failureResult);
}
}
// Keep an untouched copy of the host-only work value until resolution and
// completion publication both succeed. Any worker-side exception is converted
// into one failure-publication attempt instead of escaping the thread entry and
// terminating the process.
template <typename Work, typename Resolver, typename Publish,
typename PublishFailure>
void RunWorker(Work work, Resolver &&resolver, Publish &&publish,
PublishFailure &&publishFailure) noexcept {
try {
auto completion = std::forward<Resolver>(resolver)(work);
std::forward<Publish>(publish)(std::move(completion));
} catch (...) {
const std::exception_ptr error = std::current_exception();
try {
std::forward<PublishFailure>(publishFailure)(std::move(work), error);
} catch (...) {
// There is no safe blocking fallback from a detached worker. The
// production failure publisher logs if its completion queue cannot
// accept the already-normalized failure.
}
}
}
template <typename Worker, typename OnDetachFailure>
Worker *DetachOrRelease(std::unique_ptr<Worker> worker,
OnDetachFailure &&onDetachFailure) noexcept {
if (!worker) {
return nullptr;
}
try {
worker->detach();
return nullptr;
} catch (...) {
const std::exception_ptr error = std::current_exception();
Worker *const runningWorker = worker.release();
try {
std::forward<OnDetachFailure>(onDetachFailure)(error);
} catch (...) {
// Diagnostics must not turn containment of a running worker into a
// second failure path.
}
return runningWorker;
}
}
// IOS encodes an IPv4 sockaddr as a two-byte length/family header followed by
// sockaddr::sa_data. Advertising any larger ai_addrlen would expose bytes that
// were never copied into the guest result.
inline constexpr size_t kWiiSockAddrHeaderBytes = 2;
inline constexpr size_t kWiiSockAddrPayloadBytes = 14;
inline constexpr size_t kWiiIpv4SockAddrBytes =
kWiiSockAddrHeaderBytes + kWiiSockAddrPayloadBytes;
inline constexpr bool CanCopyIpv4SockAddr(int nativeFamily, size_t nativeLength,
int nativeIpv4Family) noexcept {
return nativeFamily == nativeIpv4Family &&
nativeLength >= kWiiIpv4SockAddrBytes;
}
inline constexpr bool
AdvertisedSockAddrFits(uint32_t advertisedLength) noexcept {
return advertisedLength <= kWiiIpv4SockAddrBytes;
}
} // namespace NetworkDeferredContract
namespace NetworkConnectContract {
// IOS presents a blocking socket to the guest while the retained host socket
// stays nonblocking. A blocking guest connect therefore waits on the guest
// OSThread, never inside WSAPoll/poll on the emulation scheduler thread.
inline constexpr int64_t kGuestBlockingTimeoutMilliseconds = 10000;
enum class ProbeDisposition : uint8_t {
StaleSocket,
PollError,
SocketReady,
TimedOut,
Pending,
};
// Keep the ordering explicit: fd reuse invalidates the operation before any
// host syscall, readiness wins at the deadline, and only a zero-result probe
// may remain pending or time out.
inline constexpr ProbeDisposition ClassifyProbe(
bool socketIdentityIsCurrent, int pollResult, bool deadlineExpired) noexcept {
if (!socketIdentityIsCurrent) {
return ProbeDisposition::StaleSocket;
}
if (pollResult < 0) {
return ProbeDisposition::PollError;
}
if (pollResult > 0) {
return ProbeDisposition::SocketReady;
}
return deadlineExpired ? ProbeDisposition::TimedOut
: ProbeDisposition::Pending;
}
} // namespace NetworkConnectContract
+161
View File
@@ -0,0 +1,161 @@
#pragma once
#include <array>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <vector>
#ifdef _WIN32
#include <winsock2.h>
#else
#include <poll.h>
#include <sys/socket.h>
#endif
namespace NetworkPollContract {
constexpr size_t kMaxDescriptors = 24;
#ifdef _WIN32
using NativeSocket = SOCKET;
using NativePollFd = WSAPOLLFD;
constexpr NativeSocket kInvalidSocket = INVALID_SOCKET;
#else
using NativeSocket = int;
using NativePollFd = pollfd;
constexpr NativeSocket kInvalidSocket = -1;
#endif
struct CopiedDescriptor {
uint32_t wiiFd = 0;
NativeSocket nativeFd = kInvalidSocket;
uint64_t socketGeneration = 0;
short events = 0;
short revents = 0;
};
// A zero-timeout SO_POLL is a pure readiness probe. Running it directly on
// the emulation thread is safe because ProbeNow always passes timeout zero to
// the host API; putting the guest IOS caller to sleep until the next scheduler
// pump only adds a needless context switch to every GameSpy update tick.
inline bool RequiresSchedulerWait(int64_t timeoutMilliseconds) {
return timeoutMilliseconds != 0;
}
inline short WiiEventsToNative(uint32_t events) {
int native = 0;
if (events & 0x0001u) native |= POLLRDNORM;
if (events & 0x0002u) native |= POLLRDBAND;
if (events & 0x0004u) native |= POLLPRI;
if (events & 0x0008u) native |= POLLWRNORM;
if (events & 0x0010u) native |= POLLWRBAND;
// ERR/HUP/NVAL are return-only. Winsock's WSAPoll also rejects the
// priority and write-band inputs which Dolphin masks on Windows.
native &= ~(POLLERR | POLLHUP | POLLNVAL);
#ifdef _WIN32
native &= ~(POLLPRI | POLLWRBAND);
#endif
return static_cast<short>(native);
}
inline uint32_t NativeEventsToWii(short events) {
uint32_t wii = 0;
if (events & POLLRDNORM) wii |= 0x0001u;
if (events & POLLRDBAND) wii |= 0x0002u;
if (events & POLLPRI) wii |= 0x0004u;
if (events & POLLWRNORM) wii |= 0x0008u;
if (events & POLLWRBAND) wii |= 0x0010u;
if (events & POLLERR) wii |= 0x0020u;
if (events & POLLHUP) wii |= 0x0040u;
if (events & POLLNVAL) wii |= 0x0080u;
return wii;
}
class Timeout {
public:
using Clock = std::chrono::steady_clock;
using TimePoint = Clock::time_point;
static Timeout FromMilliseconds(int64_t milliseconds, TimePoint now = Clock::now()) {
Timeout timeout;
if (milliseconds < 0) {
timeout.m_infinite = true;
timeout.m_deadline = TimePoint::max();
return timeout;
}
using Milliseconds = std::chrono::milliseconds;
const int64_t maximum = std::chrono::duration_cast<Milliseconds>(TimePoint::max() - now).count();
timeout.m_deadline = milliseconds >= maximum
? TimePoint::max()
: now + Milliseconds(milliseconds);
return timeout;
}
bool IsExpired(TimePoint now = Clock::now()) const {
return !m_infinite && now >= m_deadline;
}
bool ShouldRemainPending(int nativeResult, TimePoint now = Clock::now()) const {
return nativeResult == 0 && !IsExpired(now);
}
bool IsInfinite() const { return m_infinite; }
TimePoint Deadline() const { return m_deadline; }
private:
bool m_infinite = false;
TimePoint m_deadline{};
};
// Probes only descriptors whose copied socket identity is still live. A dead identity
// (SOClose/SOCleanup/slot reuse) reports POLLNVAL and counts toward readiness like IOS/Dolphin;
// skipping it silently would return 0 forever and strand an infinite-timeout SO_POLL parked
// during socket teardown mid-WFC-connect.
template <typename IsStillValid>
int ProbeNow(std::vector<CopiedDescriptor>& descriptors, IsStillValid&& isStillValid) {
if (descriptors.size() > kMaxDescriptors) {
return -1;
}
std::array<NativePollFd, kMaxDescriptors> active{};
std::array<size_t, kMaxDescriptors> originalIndices{};
size_t activeCount = 0;
int invalidCount = 0;
for (size_t i = 0; i < descriptors.size(); ++i) {
CopiedDescriptor& descriptor = descriptors[i];
descriptor.revents = 0;
if (!isStillValid(descriptor)) {
descriptor.revents = POLLNVAL;
++invalidCount;
continue;
}
active[activeCount].fd = descriptor.nativeFd;
active[activeCount].events = descriptor.events;
active[activeCount].revents = 0;
originalIndices[activeCount] = i;
++activeCount;
}
if (activeCount == 0) {
return invalidCount;
}
#ifdef _WIN32
const int result = WSAPoll(active.data(), static_cast<ULONG>(activeCount), 0);
#else
const int result = poll(active.data(), activeCount, 0);
#endif
if (result >= 0) {
for (size_t i = 0; i < activeCount; ++i) {
descriptors[originalIndices[i]].revents = active[i].revents;
}
return result + invalidCount;
}
return invalidCount > 0 ? invalidCount : result;
}
} // namespace NetworkPollContract
+692
View File
@@ -0,0 +1,692 @@
// Riivolution patch-XML parsing and patch selection.
//
// Ported from Dolphin Emulator's DiscIO/RiivolutionParser.h/cpp and the
// external-path resolution rules of DiscIO/RiivolutionPatcher.cpp
// (https://github.com/dolphin-emu/dolphin).
// Copyright 2021 Dolphin Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
//
// Deviations from Dolphin, all deliberate:
// - <memory> patches are parsed but never applied here: guest code patching
// belongs to the translator's Code.pul/lowmem pipeline, not the runtime.
// - Riivolution "macros" are not supported
#pragma once
#include <algorithm>
#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include <pugixml.hpp>
namespace RiivolutionContract {
// ============================================================================
// Minimal XML document model
// ============================================================================
struct XmlNode {
std::string name;
std::vector<std::pair<std::string, std::string>> attributes;
std::vector<XmlNode> children;
const std::string* FindAttribute(std::string_view attributeName) const {
for (const auto& attribute : attributes) {
if (attribute.first == attributeName) {
return &attribute.second;
}
}
return nullptr;
}
std::string Attribute(std::string_view attributeName, std::string_view fallback = {}) const {
const std::string* value = FindAttribute(attributeName);
return value ? *value : std::string(fallback);
}
bool AttributeBool(std::string_view attributeName, bool fallback) const {
const std::string* value = FindAttribute(attributeName);
if (!value) {
return fallback;
}
if (*value == "true" || *value == "1" || *value == "yes") {
return true;
}
if (*value == "false" || *value == "0" || *value == "no") {
return false;
}
return fallback;
}
// Accepts decimal and 0x-prefixed hex, matching pugixml's number parsing
// (Riivolution XMLs write memory offsets as 0x........).
uint32_t AttributeUint(std::string_view attributeName, uint32_t fallback) const {
const std::string* value = FindAttribute(attributeName);
if (!value || value->empty()) {
return fallback;
}
const std::string& text = *value;
size_t index = 0;
uint32_t base = 10;
if (text.size() > 2 && text[0] == '0' && (text[1] == 'x' || text[1] == 'X')) {
base = 16;
index = 2;
}
uint64_t result = 0;
for (; index < text.size(); ++index) {
const char c = text[index];
uint32_t digit;
if (c >= '0' && c <= '9') {
digit = static_cast<uint32_t>(c - '0');
} else if (base == 16 && c >= 'a' && c <= 'f') {
digit = static_cast<uint32_t>(c - 'a' + 10);
} else if (base == 16 && c >= 'A' && c <= 'F') {
digit = static_cast<uint32_t>(c - 'A' + 10);
} else {
return fallback;
}
result = result * base + digit;
if (result > 0xffffffffull) {
return fallback;
}
}
return index > (base == 16 ? 2u : 0u) ? static_cast<uint32_t>(result) : fallback;
}
int AttributeInt(std::string_view attributeName, int fallback) const {
const std::string* value = FindAttribute(attributeName);
if (!value || value->empty()) {
return fallback;
}
const bool negative = (*value)[0] == '-';
const uint32_t magnitude =
AttributeUintFromText(negative ? value->substr(1) : *value, 0x80000000u);
if (magnitude == 0x80000000u && !negative) {
return fallback;
}
return negative ? -static_cast<int>(magnitude) : static_cast<int>(magnitude);
}
private:
static uint32_t AttributeUintFromText(const std::string& text, uint32_t fallback) {
XmlNode probe;
probe.attributes.push_back({"v", text});
return probe.AttributeUint("v", fallback);
}
};
namespace XmlDetail {
inline bool StartsWith(std::string_view text, std::string_view prefix) {
return text.size() >= prefix.size() && text.compare(0, prefix.size(), prefix) == 0;
}
} // namespace XmlDetail
// Converts only element and attribute data from pugixml. Riivolution carries
// its data in attributes, so text, declarations, comments, and CDATA do not
// need to become part of the contract's data model.
inline XmlNode CopyXmlNode(const pugi::xml_node& source) {
XmlNode destination;
destination.name = source.name();
for (const pugi::xml_attribute& attribute : source.attributes()) {
destination.attributes.emplace_back(attribute.name(), attribute.value());
}
for (const pugi::xml_node& child : source.children()) {
if (child.type() == pugi::node_element) {
destination.children.push_back(CopyXmlNode(child));
}
}
return destination;
}
// Parses a document with pugixml and returns its root element, or nullopt when
// malformed. load_buffer accepts the UTF-8 BOM used by some Riivolution packs.
inline std::optional<XmlNode> ParseXml(std::string_view text) {
pugi::xml_document document;
const pugi::xml_parse_result result = document.load_buffer(
text.data(), text.size(), pugi::parse_default, pugi::encoding_utf8);
if (!result) {
return std::nullopt;
}
const pugi::xml_node root = document.document_element();
if (!root) {
return std::nullopt;
}
return CopyXmlNode(root);
}
// ============================================================================
// Riivolution data model (mirrors Dolphin's DiscIO::Riivolution)
// ============================================================================
struct GameFilter {
std::optional<std::string> game;
std::optional<std::string> developer;
std::optional<int> disc;
std::optional<int> version;
std::optional<std::vector<std::string>> regions;
};
struct PatchReference {
std::string id;
std::map<std::string, std::string> params;
};
struct Choice {
std::string name;
std::vector<PatchReference> patchReferences;
};
struct Option {
std::string name;
std::string id;
std::vector<Choice> choices;
// 1-based index into choices; 0 means disabled.
uint32_t selectedChoice = 0;
};
struct Section {
std::string name;
std::vector<Option> options;
};
struct File {
std::string disc;
std::string external;
bool resize = true;
bool create = false;
uint32_t offset = 0;
uint32_t fileoffset = 0;
uint32_t length = 0;
};
struct Folder {
std::string disc;
std::string external;
bool resize = true;
bool create = false;
bool recursive = true;
uint32_t length = 0;
};
struct Savegame {
std::string external;
bool clone = true;
};
// Parsed for completeness; the runtime never applies these (guest code and
// lowmem patching is the translator pipeline's job).
struct MemoryPatch {
uint32_t offset = 0;
std::string value;
std::string valuefile;
std::string original;
bool ocarina = false;
bool search = false;
uint32_t align = 1;
};
struct Patch {
std::string id;
std::string root;
std::vector<File> filePatches;
std::vector<Folder> folderPatches;
std::vector<Savegame> savegamePatches;
std::vector<MemoryPatch> memoryPatches;
};
struct Disc {
int version = 0;
GameFilter gameFilter;
std::vector<Section> sections;
std::vector<Patch> patches;
bool IsValidForGame(const std::string& gameId, std::optional<uint16_t> revision,
std::optional<uint8_t> discNumber) const;
std::vector<Patch> GeneratePatches(const std::string& gameId) const;
};
// riivolution/config/<GameID4>.xml - remembered option choices.
struct ConfigOption {
std::string id;
uint32_t defaultChoice = 0;
};
struct Config {
int version = 0;
std::vector<ConfigOption> options;
};
// An option choice pinned by the distribution manifest (recomp.yml).
struct OptionSelection {
std::string section; // empty = match any section
std::string option; // matches Option::id first, then Option::name
uint32_t choice = 0;
};
// ============================================================================
// Parsing
// ============================================================================
namespace Detail {
inline std::map<std::string, std::string> ReadParams(const XmlNode& node,
std::map<std::string, std::string> params = {}) {
for (const XmlNode& paramNode : node.children) {
if (paramNode.name != "param") {
continue;
}
params[paramNode.Attribute("name")] = paramNode.Attribute("value");
}
return params;
}
} // namespace Detail
inline std::optional<Disc> ParseString(std::string_view xml) {
const std::optional<XmlNode> root = ParseXml(xml);
if (!root || root->name != "wiidisc") {
return std::nullopt;
}
Disc disc;
disc.version = root->AttributeInt("version", -1);
if (disc.version != 1) {
return std::nullopt;
}
const std::string defaultRoot = root->Attribute("root");
for (const XmlNode& node : root->children) {
if (node.name == "id") {
for (const auto& attribute : node.attributes) {
if (attribute.first == "game") {
disc.gameFilter.game = attribute.second;
} else if (attribute.first == "developer") {
disc.gameFilter.developer = attribute.second;
} else if (attribute.first == "disc") {
disc.gameFilter.disc = node.AttributeInt("disc", -1);
} else if (attribute.first == "version") {
disc.gameFilter.version = node.AttributeInt("version", -1);
}
}
std::vector<std::string> regions;
for (const XmlNode& regionNode : node.children) {
if (regionNode.name == "region") {
regions.push_back(regionNode.Attribute("type"));
}
}
if (!regions.empty()) {
disc.gameFilter.regions = std::move(regions);
}
continue;
}
if (node.name == "options") {
for (const XmlNode& sectionNode : node.children) {
if (sectionNode.name != "section") {
continue;
}
Section section;
section.name = sectionNode.Attribute("name");
for (const XmlNode& optionNode : sectionNode.children) {
if (optionNode.name != "option") {
continue;
}
Option option;
option.id = optionNode.Attribute("id");
option.name = optionNode.Attribute("name");
option.selectedChoice = optionNode.AttributeUint("default", 0);
auto optionParams = Detail::ReadParams(optionNode);
for (const XmlNode& choiceNode : optionNode.children) {
if (choiceNode.name != "choice") {
continue;
}
Choice choice;
choice.name = choiceNode.Attribute("name");
auto choiceParams = Detail::ReadParams(choiceNode, optionParams);
for (const XmlNode& patchRefNode : choiceNode.children) {
if (patchRefNode.name != "patch") {
continue;
}
PatchReference patchReference;
patchReference.id = patchRefNode.Attribute("id");
patchReference.params = Detail::ReadParams(patchRefNode, choiceParams);
choice.patchReferences.push_back(std::move(patchReference));
}
option.choices.push_back(std::move(choice));
}
section.options.push_back(std::move(option));
}
disc.sections.push_back(std::move(section));
}
continue;
}
if (node.name == "patch") {
Patch patch;
patch.id = node.Attribute("id");
patch.root = node.Attribute("root");
if (patch.root.empty()) {
patch.root = defaultRoot;
}
for (const XmlNode& patchNode : node.children) {
if (patchNode.name == "file") {
File file;
file.disc = patchNode.Attribute("disc");
file.external = patchNode.Attribute("external");
file.resize = patchNode.AttributeBool("resize", true);
file.create = patchNode.AttributeBool("create", false);
file.offset = patchNode.AttributeUint("offset", 0);
file.fileoffset = patchNode.AttributeUint("fileoffset", 0);
file.length = patchNode.AttributeUint("length", 0);
patch.filePatches.push_back(std::move(file));
} else if (patchNode.name == "folder") {
Folder folder;
folder.disc = patchNode.Attribute("disc");
folder.external = patchNode.Attribute("external");
folder.resize = patchNode.AttributeBool("resize", true);
folder.create = patchNode.AttributeBool("create", false);
folder.recursive = patchNode.AttributeBool("recursive", true);
folder.length = patchNode.AttributeUint("length", 0);
patch.folderPatches.push_back(std::move(folder));
} else if (patchNode.name == "savegame") {
Savegame savegame;
savegame.external = patchNode.Attribute("external");
savegame.clone = patchNode.AttributeBool("clone", true);
patch.savegamePatches.push_back(std::move(savegame));
} else if (patchNode.name == "memory") {
MemoryPatch memory;
memory.offset = patchNode.AttributeUint("offset", 0);
memory.value = patchNode.Attribute("value");
memory.valuefile = patchNode.Attribute("valuefile");
memory.original = patchNode.Attribute("original");
memory.ocarina = patchNode.AttributeBool("ocarina", false);
memory.search = patchNode.AttributeBool("search", false);
memory.align = patchNode.AttributeUint("align", 1);
patch.memoryPatches.push_back(std::move(memory));
}
}
disc.patches.push_back(std::move(patch));
}
}
return disc;
}
inline std::optional<Config> ParseConfigString(std::string_view xml) {
const std::optional<XmlNode> root = ParseXml(xml);
if (!root || root->name != "riivolution") {
return std::nullopt;
}
Config config;
config.version = root->AttributeInt("version", -1);
if (config.version != 2) {
return std::nullopt;
}
for (const XmlNode& optionNode : root->children) {
if (optionNode.name != "option") {
continue;
}
ConfigOption option;
option.id = optionNode.Attribute("id");
option.defaultChoice = optionNode.AttributeUint("default", 0);
config.options.push_back(std::move(option));
}
return config;
}
// ============================================================================
// Game matching and patch generation
// ============================================================================
inline bool Disc::IsValidForGame(const std::string& gameId, std::optional<uint16_t> revision,
std::optional<uint8_t> discNumber) const {
if (gameId.size() != 6) {
return false;
}
const std::string_view gameIdFull(gameId);
const std::string_view gameRegion = gameIdFull.substr(3, 1);
const std::string_view gameDeveloper = gameIdFull.substr(4, 2);
const int discNumberInt = discNumber ? static_cast<int>(*discNumber) : -1;
const int revisionInt = revision ? static_cast<int>(*revision) : -1;
if (gameFilter.game && !XmlDetail::StartsWith(gameIdFull, *gameFilter.game)) {
return false;
}
if (gameFilter.developer && gameDeveloper != *gameFilter.developer) {
return false;
}
if (gameFilter.disc && discNumberInt != *gameFilter.disc) {
return false;
}
if (gameFilter.version && revisionInt != *gameFilter.version) {
return false;
}
if (gameFilter.regions) {
const auto& regions = *gameFilter.regions;
if (!regions.empty() &&
std::find(regions.begin(), regions.end(), std::string(gameRegion)) == regions.end()) {
return false;
}
}
return true;
}
inline std::vector<Patch> Disc::GeneratePatches(const std::string& gameId) const {
const std::string_view gameIdFull(gameId);
const std::string_view gameIdNoRegion = gameIdFull.substr(0, 3);
const std::string_view gameRegion = gameIdFull.substr(3, 1);
const std::string_view gameDeveloper = gameIdFull.size() >= 6 ? gameIdFull.substr(4, 2) : std::string_view();
const auto replaceVariables =
[](std::string_view sv, const std::vector<std::pair<std::string, std::string_view>>& replacements) {
std::string result;
result.reserve(sv.size());
while (!sv.empty()) {
bool replaced = false;
for (const auto& replacement : replacements) {
if (XmlDetail::StartsWith(sv, replacement.first)) {
result.append(replacement.second.data(), replacement.second.size());
sv = sv.substr(replacement.first.size());
replaced = true;
break;
}
}
if (replaced) {
continue;
}
result.push_back(sv[0]);
sv = sv.substr(1);
}
return result;
};
// Take only selected patches, replace placeholders in all strings, and
// return them.
std::vector<Patch> activePatches;
for (const Section& section : sections) {
for (const Option& option : section.options) {
const uint32_t selected = option.selectedChoice;
if (selected == 0 || selected > option.choices.size()) {
continue;
}
const Choice& choice = option.choices[selected - 1];
for (const PatchReference& patchReference : choice.patchReferences) {
const auto patch = std::find_if(patches.begin(), patches.end(), [&](const Patch& candidate) {
return candidate.id == patchReference.id;
});
if (patch == patches.end()) {
continue;
}
std::vector<std::pair<std::string, std::string_view>> replacements;
replacements.emplace_back("{$__gameid}", gameIdNoRegion);
replacements.emplace_back("{$__region}", gameRegion);
replacements.emplace_back("{$__maker}", gameDeveloper);
for (const auto& param : patchReference.params) {
replacements.emplace_back("{$" + param.first + "}", param.second);
}
Patch newPatch = *patch;
newPatch.root = replaceVariables(newPatch.root, replacements);
for (File& file : newPatch.filePatches) {
file.disc = replaceVariables(file.disc, replacements);
file.external = replaceVariables(file.external, replacements);
}
for (Folder& folder : newPatch.folderPatches) {
folder.disc = replaceVariables(folder.disc, replacements);
folder.external = replaceVariables(folder.external, replacements);
}
for (Savegame& savegame : newPatch.savegamePatches) {
savegame.external = replaceVariables(savegame.external, replacements);
}
for (MemoryPatch& memory : newPatch.memoryPatches) {
memory.valuefile = replaceVariables(memory.valuefile, replacements);
}
activePatches.push_back(std::move(newPatch));
}
}
}
return activePatches;
}
// ============================================================================
// Option selection
// ============================================================================
// Dolphin's config identifier: an option is addressed by its id when it has
// one, otherwise by the concatenation of section name and option name.
inline void ApplyConfigDefaults(Disc& disc, const Config& config) {
for (const ConfigOption& configOption : config.options) {
for (Section& section : disc.sections) {
for (Option& option : section.options) {
const bool matches = option.id.empty()
? (section.name + option.name) == configOption.id
: option.id == configOption.id;
if (matches) {
option.selectedChoice = configOption.defaultChoice;
}
}
}
}
}
// Applies distribution-pinned selections. Runs after ApplyConfigDefaults so a
// pin always wins over the user's remembered choice.
inline void ApplySelections(Disc& disc, const std::vector<OptionSelection>& selections) {
for (const OptionSelection& selection : selections) {
for (Section& section : disc.sections) {
if (!selection.section.empty() && section.name != selection.section) {
continue;
}
for (Option& option : section.options) {
const bool matches = (!option.id.empty() && option.id == selection.option) ||
option.name == selection.option;
if (matches && selection.choice <= option.choices.size()) {
option.selectedChoice = selection.choice;
}
}
}
}
}
// External path resolution (Dolphin FileDataLoaderHostFS semantics). A leading '/' is absolute
// (relative to the SD card root); otherwise relative to the patch root (the XML's folder, or
// its 'root' attribute override). All paths use '/' separators (callers convert host paths via
// generic_string() first); returns nullopt for ".." traversal or a backslash, which Riivolution
// treats as a filename character that Windows paths can't replicate.
inline std::optional<std::string> MakeAbsoluteFromRelative(std::string_view sdRoot,
std::string_view patchRoot,
std::string_view externalRelativePath) {
if (externalRelativePath.find('\\') != std::string_view::npos) {
return std::nullopt;
}
const bool absolute = !externalRelativePath.empty() && externalRelativePath[0] == '/';
std::string result(absolute ? sdRoot : patchRoot);
while (!result.empty() && result.back() == '/') {
result.pop_back();
}
std::string_view work = externalRelativePath;
while (!work.empty() && work.front() == '/') {
work.remove_prefix(1);
}
while (!work.empty() && work.back() == '/') {
work.remove_suffix(1);
}
size_t depth = 0;
while (!work.empty()) {
const size_t separator = work.find('/');
const std::string_view element = work.substr(0, separator);
if (element == ".") {
// Harmless, changes nothing.
} else if (element == "..") {
// Going up a level; never above the root.
if (depth == 0) {
return std::nullopt;
}
--depth;
const size_t lastSlash = result.rfind('/');
if (lastSlash == std::string::npos) {
return std::nullopt;
}
result.resize(lastSlash);
} else if (!element.empty()) {
++depth;
result.push_back('/');
result.append(element.data(), element.size());
}
if (separator == std::string_view::npos) {
break;
}
work.remove_prefix(separator + 1);
}
return result;
}
// Computes a patch's effective root directory from the XML file's directory
// and the patch's 'root' attribute.
inline std::string ResolvePatchRoot(std::string_view sdRoot, std::string_view xmlDirectory,
std::string_view rootAttribute) {
std::string patchRoot(xmlDirectory);
if (!rootAttribute.empty()) {
if (auto resolved = MakeAbsoluteFromRelative(sdRoot, xmlDirectory, rootAttribute)) {
patchRoot = std::move(*resolved);
}
}
return patchRoot;
}
// First <savegame> across the active patches, in order (Dolphin
// ExtractSavegameRedirect).
inline const Savegame* FindSavegamePatch(const std::vector<Patch>& activePatches) {
for (const Patch& patch : activePatches) {
if (!patch.savegamePatches.empty()) {
return &patch.savegamePatches[0];
}
}
return nullptr;
}
} // namespace RiivolutionContract