From 27e9be32ed61ac93cd8fddbb99ad5bab7979e072 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:47:16 +0100 Subject: [PATCH] let wrapper natives run mod overlays (#359) --- runtime/include/abi_bridge.h | 8 ++- runtime/include/guest_function_imports.h | 12 ++++ runtime/src/abi_bridge.cpp | 55 ++++++++++++++----- runtime/src/hle/boot_race.cpp | 6 +- runtime/src/hle/storage/nand_isfs.cpp | 2 +- translator/src/Translator.Cli/Program.cs | 17 ++++-- .../src/Translator.Core/GeneratedMarkers.cs | 6 +- .../src/Translator.Core/RuntimeNativeIndex.cs | 34 ++++++++++-- .../Translator.Core/RuntimeNativeIndexFile.cs | 17 ++++-- .../GuestAddressTableTests.cs | 4 +- .../RuntimeNativeIndexTests.cs | 39 ++++++++++++- 11 files changed, 161 insertions(+), 39 deletions(-) diff --git a/runtime/include/abi_bridge.h b/runtime/include/abi_bridge.h index 205cd62..4ca4439 100644 --- a/runtime/include/abi_bridge.h +++ b/runtime/include/abi_bridge.h @@ -157,7 +157,7 @@ public: #define MKW_DETAIL_CAT(a, b) a##b #define MKW_DETAIL_MAKE_UNIQUE(a, b) MKW_DETAIL_CAT(a, b) -// Three hand-written registration macros, given the PAL address. Generated code never registers +// Hand-written registration macros, given the PAL address. Generated code never registers // per function; every translated function reaches the registry via the bulk // BulkTranslatedFunctionRecord tables in the translator's *_registration TUs. #define REGISTER_TRANSLATED_FUNCTION(address, fn) \ @@ -168,3 +168,9 @@ public: #define REGISTER_NATIVE_FUNCTION_AS(address, fn, pretty_name) \ static AbiTrampoline MKW_DETAIL_MAKE_UNIQUE(_abi_native_trampoline_named_, __COUNTER__)(::GuestRegion::Translate(address), pretty_name, fn, FunctionKind::Native, false, kPpcAllNonvolatileFprMask, 0, 0, &AbiRawCpuThunk<&fn>::Invoke) + +// A native that calls through with MKW_GUEST_NEXT, so a mod overlay at its address still runs. +#define REGISTER_NATIVE_WRAPPER_AS(address, fn, pretty_name) REGISTER_NATIVE_FUNCTION_AS(address, fn, pretty_name) + +// A native that deliberately takes over its address from any mod overlay there. +#define REGISTER_NATIVE_REPLACEMENT_AS(address, fn, pretty_name) REGISTER_NATIVE_FUNCTION_AS(address, fn, pretty_name) diff --git a/runtime/include/guest_function_imports.h b/runtime/include/guest_function_imports.h index 2b32810..d7f31d9 100644 --- a/runtime/include/guest_function_imports.h +++ b/runtime/include/guest_function_imports.h @@ -3,6 +3,8 @@ // Base translations the runtime calls directly, named by PAL identity and resolved through the // function registry on first use. // MKW_GUEST_FUNC(801A0620)(ctx); +// A native registered with REGISTER_NATIVE_WRAPPER_AS calls through with MKW_GUEST_NEXT instead, +// so a mod overlay at the same address still runs. #include "guest_abi/cpu_context.h" #include "guest_abi/guest_region.h" @@ -12,6 +14,10 @@ using Entry = void (*)(CpuContext*); // The base translation at a guest address, also where a native or mod overrides it. Entry BaseTranslation(uint32_t address); + +// The translation a native wrapper sits on top of: the winning mod overlay, else the base +// translation. +Entry NextTranslation(uint32_t address); } // namespace GuestFunctions #define MKW_GUEST_FUNC(pal) \ @@ -19,3 +25,9 @@ Entry BaseTranslation(uint32_t address); static const ::GuestFunctions::Entry entry = ::GuestFunctions::BaseTranslation(MKW_GADDR(pal)); \ return entry; \ }()) + +#define MKW_GUEST_NEXT(pal) \ + (*[] { \ + static const ::GuestFunctions::Entry entry = ::GuestFunctions::NextTranslation(MKW_GADDR(pal)); \ + return entry; \ + }()) diff --git a/runtime/src/abi_bridge.cpp b/runtime/src/abi_bridge.cpp index e339244..55fe416 100644 --- a/runtime/src/abi_bridge.cpp +++ b/runtime/src/abi_bridge.cpp @@ -426,20 +426,10 @@ const TranslatedFunctionInfo* TranslatedFunctionRegistry::FindByAddressPtrSlow(u return nullptr; } -GuestFunctions::Entry GuestFunctions::BaseTranslation(uint32_t address) { - std::unique_lock lock(RegistryMutex(), std::defer_lock); - if (!RegistryFrozen().load(std::memory_order_acquire)) { - lock.lock(); - } - for (const auto& entry : Registry()) { - if (entry.address == address && entry.kind == FunctionKind::BaseTranslated && entry.rawCpuInvoker) { - return entry.rawCpuInvoker; - } - } - if (lock.owns_lock()) { - lock.unlock(); - } - RT_LOG(RT_TAG_RUNTIME) << "ERROR: no base translation registered at 0x" << std::hex << address << std::dec +namespace { + +[[noreturn]] void ReportMissingGuestFunction(uint32_t address) { + RT_LOG(RT_TAG_RUNTIME) << "ERROR: no translation registered at 0x" << std::hex << address << std::dec << std::endl; std::ostringstream message; message << "The runtime calls the translated function at guest address 0x" << std::hex << address @@ -451,6 +441,43 @@ GuestFunctions::Entry GuestFunctions::BaseTranslation(uint32_t address) { std::exit(EXIT_FAILURE); } +} // namespace + +GuestFunctions::Entry GuestFunctions::BaseTranslation(uint32_t address) { + { + std::unique_lock lock(RegistryMutex(), std::defer_lock); + if (!RegistryFrozen().load(std::memory_order_acquire)) { + lock.lock(); + } + for (const auto& entry : Registry()) { + if (entry.address == address && entry.kind == FunctionKind::BaseTranslated && entry.rawCpuInvoker) { + return entry.rawCpuInvoker; + } + } + } + ReportMissingGuestFunction(address); +} + +GuestFunctions::Entry GuestFunctions::NextTranslation(uint32_t address) { + { + std::unique_lock lock(RegistryMutex(), std::defer_lock); + if (!RegistryFrozen().load(std::memory_order_acquire)) { + lock.lock(); + } + const TranslatedFunctionInfo* best = nullptr; + for (const auto& entry : Registry()) { + if (entry.address == address && entry.kind != FunctionKind::Native && entry.rawCpuInvoker && + (!best || IsBetterCandidate(entry, *best))) { + best = &entry; + } + } + if (best) { + return best->rawCpuInvoker; + } + } + ReportMissingGuestFunction(address); +} + const RawDispatchRecord* TranslatedFunctionRegistry::FindRawByAddressPtrSlow(uint32_t address) { if (!RegistryFrozen().load(std::memory_order_acquire)) { return nullptr; diff --git a/runtime/src/hle/boot_race.cpp b/runtime/src/hle/boot_race.cpp index 6d592fc..3705bb0 100644 --- a/runtime/src/hle/boot_race.cpp +++ b/runtime/src/hle/boot_race.cpp @@ -162,7 +162,7 @@ REGISTER_NATIVE_FUNCTION_AS(0x805B9344, ArgumentParser_Parse_805b9344, "Argument // RacedataScenario::InitScreens; local racers keep no real controller channel. extern "C" void RacedataScenario_InitScreens_8052f4e8(CpuContext* ctx) { if (!g_cpuDriven) { - MKW_GUEST_FUNC(8052F4E8)(ctx); + MKW_GUEST_NEXT(8052F4E8)(ctx); return; } const uint32_t players = ctx->gpr[3] + kPlayersOffset; @@ -170,7 +170,7 @@ extern "C" void RacedataScenario_InitScreens_8052f4e8(CpuContext* ctx) { for (uint32_t i = 0; i < kPlayerCount; ++i) { channels[i] = Memory::Read8(players + i * kPlayerSize + kPlayerControllerChannelOffset); } - MKW_GUEST_FUNC(8052F4E8)(ctx); + MKW_GUEST_NEXT(8052F4E8)(ctx); for (uint32_t i = 0; i < kPlayerCount; ++i) { const uint32_t player = players + i * kPlayerSize; if (Memory::Read32(player + kPlayerTypeOffset) == kPlayerRealLocal) { @@ -178,7 +178,7 @@ extern "C" void RacedataScenario_InitScreens_8052f4e8(CpuContext* ctx) { } } } -REGISTER_NATIVE_FUNCTION_AS(0x8052F4E8, RacedataScenario_InitScreens_8052f4e8, "RacedataScenario_InitScreens_8052f4e8"); +REGISTER_NATIVE_WRAPPER_AS(0x8052F4E8, RacedataScenario_InitScreens_8052f4e8, "RacedataScenario_InitScreens_8052f4e8"); // KartAIController::IsCPU extern "C" void KartAIController_IsCPU_8072624c(CpuContext* ctx) { diff --git a/runtime/src/hle/storage/nand_isfs.cpp b/runtime/src/hle/storage/nand_isfs.cpp index d91dd89..6111b20 100644 --- a/runtime/src/hle/storage/nand_isfs.cpp +++ b/runtime/src/hle/storage/nand_isfs.cpp @@ -436,7 +436,7 @@ extern "C" void NAND_IOS_OpenBody_HLE_801938FC(CpuContext* ctx) { ctx->gpr[3] = static_cast(result); ctx->gpr[1] = ctx->gpr[1] + 32u; } -REGISTER_NATIVE_FUNCTION_AS(0x801938FC, NAND_IOS_OpenBody_HLE_801938FC, "NAND_IOS_OpenBody_HLE_801938FC"); +REGISTER_NATIVE_REPLACEMENT_AS(0x801938FC, NAND_IOS_OpenBody_HLE_801938FC, "NAND_IOS_OpenBody_HLE_801938FC"); extern "C" int32_t NAND_IOS_Close_HLE(uint32_t fd) { if (fd == ISFS_DEV_FD) { diff --git a/translator/src/Translator.Cli/Program.cs b/translator/src/Translator.Cli/Program.cs index 2e00de5..e1164dd 100644 --- a/translator/src/Translator.Cli/Program.cs +++ b/translator/src/Translator.Cli/Program.cs @@ -2278,12 +2278,21 @@ void WriteResolvedDispatchProfile( // functions the runtime must own (hardware, the strap scene). But when the mod's whole point // is replacing that function, e.g. Retro Rewind's DVDOpen override at 0x8015E2BC, the overlay // is silently dropped. Name every such address so the tradeoff is a choice, not an accident. + // Wrapper natives still run the overlay, and replacement natives already made that choice. + var nativesKeepingModOverlayDecision = runtimeNativeIndex.Value.Registrations + .Where(static registration => registration.ModOverlay != NativeModOverlay.Unspecified) + .Select(static registration => (registration.Address, registration.Symbol)) + .ToHashSet(); var nativeOverriddenModPatches = groups .Where(group => - group.Any(entry => entry.Kind == "rr") && - group.OrderByDescending(entry => entry.Priority) - .ThenByDescending(entry => KindRank(entry.Kind)) - .First().Kind == "native") + { + var winner = group.OrderByDescending(entry => entry.Priority) + .ThenByDescending(entry => KindRank(entry.Kind)) + .First(); + return group.Any(entry => entry.Kind == "rr") && + winner.Kind == "native" && + !nativesKeepingModOverlayDecision.Contains((winner.Address, winner.Symbol)); + }) .Select(group => new { Address = group.Key, diff --git a/translator/src/Translator.Core/GeneratedMarkers.cs b/translator/src/Translator.Core/GeneratedMarkers.cs index 6ce362d..f34ee45 100644 --- a/translator/src/Translator.Core/GeneratedMarkers.cs +++ b/translator/src/Translator.Core/GeneratedMarkers.cs @@ -83,9 +83,11 @@ public static partial class GeneratedMarkers /// /// REGISTER_NATIVE_FUNCTION(0x…, symbol) and its _AS alias form; - /// the as group tells the two apart. + /// the as group tells the two apart. REGISTER_NATIVE_WRAPPER_AS and + /// REGISTER_NATIVE_REPLACEMENT_AS are alias forms too, named by the + /// modOverlay group. /// - [GeneratedRegex(@"REGISTER_NATIVE_FUNCTION(?_AS)?\s*\(\s*0x(?
[0-9A-Fa-f]{8})\s*,\s*(?[A-Za-z_][A-Za-z0-9_]*)", RegexOptions.CultureInvariant)] + [GeneratedRegex(@"REGISTER_NATIVE_(?:FUNCTION(?_AS)?|(?WRAPPER|REPLACEMENT)_AS)\s*\(\s*0x(?
[0-9A-Fa-f]{8})\s*,\s*(?[A-Za-z_][A-Za-z0-9_]*)", RegexOptions.CultureInvariant)] public static partial Regex NativeFunctionRegistrationPattern(); [GeneratedRegex(@"REGISTER_TRANSLATED_FUNCTION\s*\(\s*0x(?
[0-9A-Fa-f]{8})\s*,\s*(?[A-Za-z_][A-Za-z0-9_]*)", RegexOptions.CultureInvariant)] diff --git a/translator/src/Translator.Core/RuntimeNativeIndex.cs b/translator/src/Translator.Core/RuntimeNativeIndex.cs index 74c6fee..66a1f4b 100644 --- a/translator/src/Translator.Core/RuntimeNativeIndex.cs +++ b/translator/src/Translator.Core/RuntimeNativeIndex.cs @@ -11,7 +11,19 @@ public sealed record RuntimeNativeRegistration( string Symbol, string SourceFile, bool IsTranslatedOverride, - bool ExcludesBaseTranslation); + bool ExcludesBaseTranslation, + NativeModOverlay ModOverlay = NativeModOverlay.Unspecified); + +/// What happens to a mod overlay at a native's address. +public enum NativeModOverlay +{ + /// Plain registration: the overlay is lost, and reported as such. + Unspecified, + /// The native calls through to the overlay (MKW_GUEST_NEXT). + Wrapper, + /// The native deliberately takes over the overlay's address. + Replacement +} public sealed record RuntimeNativeAbiEntry( uint Address, @@ -119,7 +131,17 @@ public static class RuntimeNativeIndexBuilder { var source = sourceFile.Content; foreach (Match match in GeneratedMarkers.NativeFunctionRegistrationPattern().Matches(source)) - Add(match, match.Groups["symbol"].Value, false, !match.Groups["as"].Success); + { + var modOverlay = match.Groups["modOverlay"]; + Add(match, match.Groups["symbol"].Value, false, + !match.Groups["as"].Success && !modOverlay.Success, + modOverlay.Value switch + { + "WRAPPER" => NativeModOverlay.Wrapper, + "REPLACEMENT" => NativeModOverlay.Replacement, + _ => NativeModOverlay.Unspecified + }); + } foreach (Match match in GeneratedMarkers.TranslatedFunctionRegistrationPattern().Matches(source)) Add(match, match.Groups["symbol"].Value, true, true); foreach (Match match in GeneratedMarkers.NativeOverridePattern().Matches(source)) @@ -127,10 +149,11 @@ public static class RuntimeNativeIndexBuilder foreach (Match match in GeneratedMarkers.FatalStubPattern().Matches(source)) Add(match, $"GX_FATAL_STUB_{match.Groups["address"].Value}", false, true); - void Add(Match match, string symbol, bool translated, bool excludesBase) => + void Add(Match match, string symbol, bool translated, bool excludesBase, + NativeModOverlay modOverlay = NativeModOverlay.Unspecified) => registrations.Add(new RuntimeNativeRegistration( ParseAddress(match.Groups["address"].Value), symbol, sourceFile.RelativePath, - translated, excludesBase)); + translated, excludesBase, modOverlay)); } return registrations @@ -139,7 +162,8 @@ public static class RuntimeNativeIndexBuilder .ThenBy(static registration => registration.Symbol, StringComparer.Ordinal) .ThenBy(static registration => registration.SourceFile, StringComparer.Ordinal) .ThenBy(static registration => registration.IsTranslatedOverride) - .ThenBy(static registration => registration.ExcludesBaseTranslation); + .ThenBy(static registration => registration.ExcludesBaseTranslation) + .ThenBy(static registration => registration.ModOverlay); } private static uint ParseAddress(string value) => GuestTargetParser.ParseHexAddress(value); diff --git a/translator/src/Translator.Core/RuntimeNativeIndexFile.cs b/translator/src/Translator.Core/RuntimeNativeIndexFile.cs index 0be7120..0a3c273 100644 --- a/translator/src/Translator.Core/RuntimeNativeIndexFile.cs +++ b/translator/src/Translator.Core/RuntimeNativeIndexFile.cs @@ -14,9 +14,9 @@ namespace Translator.Core; public static class RuntimeNativeIndexFile { public const string Format = "wiicompiled-runtime-native-index"; - public const int FormatVersion = 1; + public const int FormatVersion = 2; // Bump whenever the scanner or the ABI/effect analyzers can produce different output for the same sources. - public const int ExtractorVersion = 1; + public const int ExtractorVersion = 2; public const string DefaultFileName = "native-index.json"; private static readonly JsonSerializerOptions Options = new() @@ -59,13 +59,15 @@ public static class RuntimeNativeIndexFile .ThenBy(static registration => registration.SourceFile, StringComparer.Ordinal) .ThenBy(static registration => registration.IsTranslatedOverride) .ThenBy(static registration => registration.ExcludesBaseTranslation) + .ThenBy(static registration => registration.ModOverlay) .Select(static registration => new RegistrationDto { Address = FormatAddress(registration.Address), Symbol = registration.Symbol, SourceFile = registration.SourceFile, IsTranslatedOverride = registration.IsTranslatedOverride, - ExcludesBaseTranslation = registration.ExcludesBaseTranslation + ExcludesBaseTranslation = registration.ExcludesBaseTranslation, + ModOverlay = registration.ModOverlay.ToString() }) .ToList(), VoidStubAbis = index.VoidStubAbis @@ -160,7 +162,8 @@ public static class RuntimeNativeIndexFile Present(registration.Symbol, "symbol"), Present(registration.SourceFile, "sourceFile"), registration.IsTranslatedOverride, - registration.ExcludesBaseTranslation)) + registration.ExcludesBaseTranslation, + ParseModOverlay(Present(registration.ModOverlay, "modOverlay")))) .ToArray(), Present(document.VoidStubAbis, "voidStubAbis") .Select(static entry => new RuntimeNativeAbiEntry( @@ -185,6 +188,11 @@ public static class RuntimeNativeIndexFile private static string FormatAddress(uint address) => $"0x{address:X8}"; + private static NativeModOverlay ParseModOverlay(string text) => + Enum.TryParse(text, ignoreCase: false, out var value) && Enum.IsDefined(value) + ? value + : throw new FormatException($"Unknown modOverlay '{text}'."); + private static uint ParseAddress(string text) => GuestTargetParser.ParseHexAddress(text); private static T Present(T? value, string name) where T : class => @@ -244,6 +252,7 @@ public static class RuntimeNativeIndexFile public required string SourceFile { get; init; } public required bool IsTranslatedOverride { get; init; } public required bool ExcludesBaseTranslation { get; init; } + public required string ModOverlay { get; init; } } private sealed class AbiDto diff --git a/translator/tests/Translator.Tests/GuestAddressTableTests.cs b/translator/tests/Translator.Tests/GuestAddressTableTests.cs index 4e972aa..7c174a0 100644 --- a/translator/tests/Translator.Tests/GuestAddressTableTests.cs +++ b/translator/tests/Translator.Tests/GuestAddressTableTests.cs @@ -129,7 +129,7 @@ public sealed class GuestAddressTableTests var calls = Directory.EnumerateFiles(Path.Combine(root, "runtime"), "*.*", SearchOption.AllDirectories) .Where(path => (path.EndsWith(".cpp", StringComparison.Ordinal) || path.EndsWith(".h", StringComparison.Ordinal)) && !path.Contains(thirdParty, StringComparison.Ordinal)) - .SelectMany(static path => Regex.Matches(File.ReadAllText(path), @"MKW_GUEST_FUNC\(\s*([0-9A-Fa-f]{8})\s*\)") + .SelectMany(static path => Regex.Matches(File.ReadAllText(path), @"MKW_GUEST_(?:FUNC|NEXT)\(\s*([0-9A-Fa-f]{8})\s*\)") .Select(static match => Convert.ToUInt32(match.Groups[1].Value, 16))) .Distinct() .ToArray(); @@ -145,7 +145,7 @@ public sealed class GuestAddressTableTests foreach (var (region, starts) in entries) { var address = table.Map(pal, region); - Assert.True(starts.Contains(address), $"MKW_GUEST_FUNC({pal:X8}) is not a {region} function entry (0x{address:X8})"); + Assert.True(starts.Contains(address), $"MKW_GUEST_FUNC/NEXT({pal:X8}) is not a {region} function entry (0x{address:X8})"); } } } diff --git a/translator/tests/Translator.Tests/RuntimeNativeIndexTests.cs b/translator/tests/Translator.Tests/RuntimeNativeIndexTests.cs index 4772a34..9efe879 100644 --- a/translator/tests/Translator.Tests/RuntimeNativeIndexTests.cs +++ b/translator/tests/Translator.Tests/RuntimeNativeIndexTests.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using System.Text; +using System.Text.RegularExpressions; using Translator.Core; using Translator.Core.CodeGen; using Xunit; @@ -40,14 +41,24 @@ public sealed class RuntimeNativeIndexTests PPC_NATIVE_OVERRIDE_VOID(80000040, Stub, (void), ()); GX_FATAL_STUB(80000050, "Fatal") // REGISTER_NATIVE_FUNCTION(0x80000060, CommentedOut); + REGISTER_NATIVE_WRAPPER_AS(0x80000070, Wrapper, "wrapper"); + REGISTER_NATIVE_REPLACEMENT_AS(0x80000080, Replacement, "replacement"); """); var registrations = RuntimeNativeIndexBuilder.Build(directory).Registrations; - Assert.Equal(5, registrations.Length); + Assert.Equal(7, registrations.Length); Assert.Contains(registrations, static entry => - entry.Address == 0x80000010u && entry.ExcludesBaseTranslation); + entry.Address == 0x80000010u && entry.ExcludesBaseTranslation && + entry.ModOverlay == NativeModOverlay.Unspecified); Assert.Contains(registrations, static entry => - entry.Address == 0x80000020u && !entry.ExcludesBaseTranslation); + entry.Address == 0x80000020u && !entry.ExcludesBaseTranslation && + entry.ModOverlay == NativeModOverlay.Unspecified); + Assert.Contains(registrations, static entry => + entry.Address == 0x80000070u && entry.Symbol == "Wrapper" && !entry.ExcludesBaseTranslation && + entry.ModOverlay == NativeModOverlay.Wrapper); + Assert.Contains(registrations, static entry => + entry.Address == 0x80000080u && entry.Symbol == "Replacement" && !entry.ExcludesBaseTranslation && + entry.ModOverlay == NativeModOverlay.Replacement); Assert.Contains(registrations, static entry => entry.Address == 0x80000030u && entry.IsTranslatedOverride); Assert.Contains(registrations, static entry => @@ -108,6 +119,8 @@ public sealed class RuntimeNativeIndexTests var sources = Path.Combine(ProjectPathsForTests.FindRepositoryRoot(), "runtime", "src"); var built = RuntimeNativeIndexBuilder.Build(sources); Assert.NotEmpty(built.Registrations); + Assert.Contains(built.Registrations, static entry => entry.ModOverlay == NativeModOverlay.Wrapper); + Assert.Contains(built.Registrations, static entry => entry.ModOverlay == NativeModOverlay.Replacement); Assert.NotEmpty(built.VoidStubAbis); Assert.NotEmpty(built.Effects); @@ -126,6 +139,26 @@ public sealed class RuntimeNativeIndexTests } } + // A wrapper that calls through with MKW_GUEST_FUNC would silently drop the mod overlay it promises to run. + [Fact] + public void RepositoryWrapperNativesCallThroughToTheNextTranslation() + { + var sources = Path.Combine(ProjectPathsForTests.FindRepositoryRoot(), "runtime", "src"); + var wrappers = RuntimeNativeIndexBuilder.Build(sources).Registrations + .Where(static entry => entry.ModOverlay == NativeModOverlay.Wrapper) + .ToArray(); + Assert.NotEmpty(wrappers); + foreach (var wrapper in wrappers) + { + var source = File.ReadAllText(Path.Combine(sources, wrapper.SourceFile)); + var pal = $"{wrapper.Address:X8}"; + Assert.True(Regex.IsMatch(source, $@"MKW_GUEST_NEXT\(\s*{pal}\s*\)", RegexOptions.IgnoreCase), + $"{wrapper.Symbol} is a wrapper but never calls MKW_GUEST_NEXT({pal})"); + Assert.False(Regex.IsMatch(source, $@"MKW_GUEST_FUNC\(\s*{pal}\s*\)", RegexOptions.IgnoreCase), + $"{wrapper.Symbol} is a wrapper but calls MKW_GUEST_FUNC({pal})"); + } + } + [Fact] public void ResolveUsesAMatchingIndexAndKeepsWorkingWithoutSources() {