From 8c8e4561e90544633153e858148b38ca18912703 Mon Sep 17 00:00:00 2001 From: iChris4 Date: Thu, 3 Sep 2026 03:59:42 +0200 Subject: [PATCH] translator: add project entry observers for MKW VR --- projects/mkwii/recomp.yml | 13 ++++ translator/README.md | 3 + translator/src/Translator.Cli/Program.cs | 71 ++++++++++++++++--- .../TranslationProjectConfig.cs | 55 +++++++++++++- .../Analysis/GuestAbiContractAnalyzer.cs | 20 ++++++ .../GuestAbiInterproceduralAnalyzer.cs | 21 +++++- ...xxLinearCodeGenerator.RegisterResidency.cs | 2 +- .../CodeGen/CxxLinearCodeGenerator.cs | 33 ++++++++- .../Translation/FunctionTranslator.cs | 22 +++++- .../CxxLinearCodeGeneratorEmitExtensions.cs | 10 ++- .../Translator.Tests/GenericProjectTests.cs | 8 +++ .../GqrPrologueHoistingCodeGenTests.cs | 24 ++++++- .../GuestAbiInterproceduralAnalyzerTests.cs | 29 ++++++++ .../MkwVrObserverCodeGenTests.cs | 43 +++++++++++ .../RegisterResidencyCodeGenTests.cs | 69 +++++++++++++++++- 15 files changed, 398 insertions(+), 25 deletions(-) create mode 100644 translator/tests/Translator.Tests/MkwVrObserverCodeGenTests.cs diff --git a/projects/mkwii/recomp.yml b/projects/mkwii/recomp.yml index bd8f727..375bb96 100644 --- a/projects/mkwii/recomp.yml +++ b/projects/mkwii/recomp.yml @@ -34,6 +34,19 @@ translation: #just a precaution, but this should never be needed for MKWii allow_unsupported_instructions: false + # PAL RMCP01 entry observers used by the OpenXR presentation policy. Keeping + # these in the game manifest prevents the generic translator from acquiring a + # Mario Kart runtime dependency merely because another title shares an address. + # The callback contract is read-only: (uint32_t, const CpuContext*). + entry_observer: + header: vr/mkw_vr_instrumentation.h + symbol: MkwVRObserveTranslatedFunctionEntry + entry_points: + - 0x80553C50 # RaceScene::OnEnter + - 0x805549B0 # RaceScene::OnExit + - 0x805A21D0 # RaceCamera::Update + - 0x805B1CD8 # ScnMgrRace::Draw + runtime: native_registration_root: runtime/src native_abi_directories: diff --git a/translator/README.md b/translator/README.md index 6ea5709..b5f862b 100644 --- a/translator/README.md +++ b/translator/README.md @@ -56,6 +56,9 @@ the translator does not guess them. - `translation.function_map.path` - optional symbol map used as the discovery oracle. - `translation.allow_unsupported_instructions` - off by default; enabling it emits runtime traps instead of failing, and such a build can never ship. +- `translation.entry_observer` - optional header, C symbol, and entry-point list for a read-only +host observer. Its callback must accept `(uint32_t, const CpuContext*)`; the translator materializes +the complete guest context before every direct or transitive path that can reach it. Relative paths resolve from `workspace_root`, which itself resolves from the manifest directory. diff --git a/translator/src/Translator.Cli/Program.cs b/translator/src/Translator.Cli/Program.cs index c8f7fe4..26dda80 100644 --- a/translator/src/Translator.Cli/Program.cs +++ b/translator/src/Translator.Cli/Program.cs @@ -121,8 +121,14 @@ var modPatchProfiles = new Lazy>(() => BuildModPatchedAddresses(modPatchProfiles.Value)); var leafInliningBlockedTargets = new Lazy>(() => - BuildLeafInliningBlockedTargets( - modPatchedAddresses.Value, runtimeNativeGuestEffects.Value, baseTranslationExclusions.Value)); +{ + var blocked = BuildLeafInliningBlockedTargets( + modPatchedAddresses.Value, runtimeNativeGuestEffects.Value, baseTranslationExclusions.Value) + .ToHashSet(); + if (project?.Translation.EntryObserver is { } observer) + blocked.UnionWith(observer.EntryPoints); + return blocked; +}); // Leaf inlining is a front-end decision: the splice happens before SSA, so the // canonical graph the emission wave lowers is already inlined and every @@ -417,6 +423,7 @@ int RunTranslateRecursive(string[] argsTail) outDir, RequireProject().Translation.EntryPoints .Append(startAddr) + .Concat(RequireProject().Translation.EntryObserver?.EntryPoints ?? new HashSet()) .Concat(functionMap?.Addresses ?? Array.Empty()), includeGeneratedHistory: false); Console.WriteLine($"[translator] Loaded {knownBaseFunctionEntryPoints.Count:N0} known base function start(s) for tail-call boundary detection."); @@ -434,12 +441,25 @@ int RunTranslateRecursive(string[] argsTail) var residentTranslationExclusions = baseTranslationExclusions.Value; Console.WriteLine( $"[translator] Leaf inlining: {leafInliningBlockedTargets.Value.Count:N0} address(es) are not " + - "inlinable (native, excluded from translation, or mod-patched)."); + "inlinable (native, excluded from translation, mod-patched, or observed)."); var queue = new Queue<(uint addr, int depth)>(); queue.Enqueue((startAddr, 0)); visited.Add(startAddr); + // Observers are an executable project contract, not advisory function-map + // metadata. Translate every configured target as a required root so a bad + // address fails the build instead of being silently dropped as a + // speculative map entry. + var observerEntryPoints = + RequireProject().Translation.EntryObserver?.EntryPoints ?? new HashSet(); + foreach (var observerEntryPoint in observerEntryPoints.Order()) + { + gqrUnknownEntryRoots.Add(observerEntryPoint); + if (visited.Add(observerEntryPoint)) + queue.Enqueue((observerEntryPoint, 0)); + } + // Map entries the call-graph walk never reaches. They are translated, but // on a decode or lift failure they are skipped with a count instead of // failing the run: unlike a walked call target, nothing proves the map @@ -730,18 +750,28 @@ int RunTranslateRecursive(string[] argsTail) allowUnsupportedInstructions); } - // The call-graph walk from the entry point, then everything the configured map - // knows about that the walk never reached. Those two sources replace the + // The call-graph walk from the executable roots, then everything the configured + // map knows about that the walk never reached. Those two sources replace the // constructor, vtable, function-pointer and adjacent-prologue scans, which // guessed at exactly this set - and the hand-maintained force-translate // list that existed to paper over the addresses they still missed. ProcessQueue(); + var missingObserverEntries = observerEntryPoints + .Where(address => !translated.Contains(address)) + .Order() + .ToArray(); + if (missingObserverEntries.Length != 0) + { + throw new InvalidDataException( + "Required entry observer target(s) were not translated: " + + string.Join(", ", missingObserverEntries.Select(static address => $"0x{address:X8}"))); + } var walkReachable = count; ProcessSpeculativeSeeds(); if (functionMap is not null) { Console.WriteLine( - $"[translator] Function starts: {walkReachable:N0} reached by the call-graph walk, " + + $"[translator] Function starts: {walkReachable:N0} required/root-reachable, " + $"{count - walkReachable:N0} added speculatively from the map."); if (speculativeSkips.Count != 0) { @@ -878,7 +908,8 @@ int RunTranslateRecursive(string[] argsTail) // rather than retaining every descendant dependency forever. var interproceduralGuestAbi = GuestAbiInterproceduralAnalyzer.Analyze( canonicalGuestFunctions, - nativeGuestEffects.Contracts); + nativeGuestEffects.Contracts, + observerEntryPoints); var guestAbiContracts = interproceduralGuestAbi.Contracts .ToDictionary(static pair => pair.Key, static pair => pair.Value); var recursiveComponents = interproceduralGuestAbi.StronglyConnectedComponents @@ -1007,6 +1038,7 @@ int RunTranslateRecursive(string[] argsTail) index, eligible = component.All(address => !residentTranslationExclusions.Contains(address) && + residentPolicy.EntryObserver?.EntryPoints.Contains(address) != true && !localGuestAbiContracts[address].HasFullSynchronizationFence && GuestStateLivenessAnalyzer.CanDeconstructWithoutContext(canonicalGuestFunctions[address]) && (!directlyCalledGuestFunctions.Contains(address) || @@ -1090,8 +1122,8 @@ int RunTranslateRecursive(string[] argsTail) StateFreeAbiContracts: stateFreeAbiContracts, StateFreeCallSymbols: stateFreeCallSymbols, StateFreeCallSiteVariants: stateFreeCallSiteVariantsByCaller.GetValueOrDefault(address), - StateFreeEntryVariants: stateFreeEntryVariantsByTarget.GetValueOrDefault(address), - ModOverridableCallTargets: modOverridableCallTargets), + StateFreeEntryVariants: stateFreeEntryVariantsByTarget.GetValueOrDefault(address), + ModOverridableCallTargets: modOverridableCallTargets), residentPolicy, address); } @@ -4019,8 +4051,13 @@ static IReadOnlySet BuildModPatchedAddresses( static TranslationOptions WithProjectCodegenPolicy( TranslationOptions options, ProjectTranslation translation, - uint entryPoint) => - options with + uint entryPoint) +{ + var fullContextCallTargets = options.FullContextCallTargets?.ToHashSet() ?? new HashSet(); + if (translation.EntryObserver is { } observer) + fullContextCallTargets.UnionWith(observer.EntryPoints); + + return options with { AllowUnsupportedInstructions = translation.AllowUnsupportedInstructions, // Leaf ABI spill elision is structurally self-guarding: it requires a @@ -4028,7 +4065,19 @@ static TranslationOptions WithProjectCodegenPolicy( // leaf, so a partial mod chunk that only carries one half of an ABI // prologue is rejected rather than mis-transformed. EnableLeafAbiSpillElision = true, + EntryObserverHeader = translation.EntryObserver?.EntryPoints.Contains(entryPoint) == true + ? translation.EntryObserver.Header + : null, + EntryObserverSymbol = translation.EntryObserver?.EntryPoints.Contains(entryPoint) == true + ? translation.EntryObserver.Symbol + : null, + // Read-only observer callbacks can inspect fields outside the guest + // callee's inferred ABI. Apply the full materialization boundary here, + // rather than only on the base path, so continuations, overlays, modules, + // and payloads cannot expose stale resident CpuContext state either. + FullContextCallTargets = fullContextCallTargets.Count == 0 ? null : fullContextCallTargets, }; +} /// /// One option carried by one command. is the metavariable diff --git a/translator/src/Translator.Cli/TranslationProjectConfig.cs b/translator/src/Translator.Cli/TranslationProjectConfig.cs index 2deb7df..1dba37f 100644 --- a/translator/src/Translator.Cli/TranslationProjectConfig.cs +++ b/translator/src/Translator.Cli/TranslationProjectConfig.cs @@ -97,7 +97,8 @@ internal sealed class TranslationProjectConfig var translation = new ProjectTranslation( entryPoints, functionMapPath, - dto.Translation?.AllowUnsupportedInstructions ?? false); + dto.Translation?.AllowUnsupportedInstructions ?? false, + ResolveEntryObserver(dto.Translation?.EntryObserver)); var abiDirectories = (dto.Runtime?.NativeAbiDirectories ?? []) .Select(pathValue => ResolvePath(workspaceRoot, pathValue)) @@ -262,6 +263,43 @@ internal sealed class TranslationProjectConfig private static string Require(string? value, string field) => !string.IsNullOrWhiteSpace(value) ? value : throw new InvalidDataException($"Translation project requires {field}."); + private static ProjectEntryObserver? ResolveEntryObserver(EntryObserverDto? dto) + { + if (dto is null) + { + return null; + } + + var header = Require(dto.Header, "translation.entry_observer.header") + .Trim().Replace('\\', '/'); + if (header.StartsWith("/", StringComparison.Ordinal) || + header.Split('/').Any(static component => component is "" or "." or "..") || + header.Any(static ch => !(char.IsAsciiLetterOrDigit(ch) || ch is '_' or '-' or '.' or '/'))) + { + throw new InvalidDataException( + "translation.entry_observer.header must be a safe relative include path."); + } + + var symbol = Require(dto.Symbol, "translation.entry_observer.symbol").Trim(); + if (!(char.IsAsciiLetter(symbol[0]) || symbol[0] == '_') || + symbol.Skip(1).Any(static ch => !(char.IsAsciiLetterOrDigit(ch) || ch == '_'))) + { + throw new InvalidDataException( + "translation.entry_observer.symbol must be a C identifier."); + } + + var entryPoints = (dto.EntryPoints ?? []) + .Select((value, index) => ParseUInt32( + value, null, $"translation.entry_observer.entry_points[{index}]")) + .ToHashSet(); + if (entryPoints.Count == 0) + { + throw new InvalidDataException( + "translation.entry_observer requires at least one entry point."); + } + return new ProjectEntryObserver(header, symbol, entryPoints); + } + // Riivolution declarations are verbatim distribution metadata, not paths on this machine: // the XML lives inside the pack the runtime mounts, so it stays a forward-slashed // pack-relative string and is never resolved against the workspace root. @@ -405,6 +443,14 @@ internal sealed class TranslationProjectConfig public List? EntryPoints { get; init; } public FunctionMapDto? FunctionMap { get; init; } public bool? AllowUnsupportedInstructions { get; init; } + public EntryObserverDto? EntryObserver { get; init; } + } + + private sealed class EntryObserverDto + { + public string? Header { get; init; } + public string? Symbol { get; init; } + public List? EntryPoints { get; init; } } private sealed class FunctionMapDto @@ -472,7 +518,12 @@ internal sealed record ProjectInputs(ProjectBinaryInput Dol, ProjectRelInput? Re internal sealed record ProjectTranslation( IReadOnlyList EntryPoints, string? FunctionMapPath, - bool AllowUnsupportedInstructions); + bool AllowUnsupportedInstructions, + ProjectEntryObserver? EntryObserver); +internal sealed record ProjectEntryObserver( + string Header, + string Symbol, + IReadOnlySet EntryPoints); internal sealed record ProjectRuntime( IReadOnlyList NativeAbiDirectories, string NativeRegistrationRoot); diff --git a/translator/src/Translator.Core/Analysis/GuestAbiContractAnalyzer.cs b/translator/src/Translator.Core/Analysis/GuestAbiContractAnalyzer.cs index 991bd04..514e35b 100644 --- a/translator/src/Translator.Core/Analysis/GuestAbiContractAnalyzer.cs +++ b/translator/src/Translator.Core/Analysis/GuestAbiContractAnalyzer.cs @@ -227,6 +227,26 @@ public static class GuestAbiContractAnalyzer }; } + /// + /// Adds the architectural reads performed by a const host-side entry + /// observer. The translated body's write effects remain unchanged; the + /// fence forces complete materialization and propagates through callers. + /// + public static GuestAbiContract WithReadOnlyContextObserver(GuestAbiContract contract) => + contract with + { + GprReadBeforeWriteMask = uint.MaxValue, + FprReadBeforeWriteMask = uint.MaxValue, + CrReadBeforeWriteMask = byte.MaxValue, + ReadsXerBeforeWrite = true, + ReadsCtrBeforeWrite = true, + ReadsLrBeforeWrite = true, + ReadsFpscrBeforeWrite = true, + GqrReadBeforeWriteMask = byte.MaxValue, + HidReadBeforeWriteMask = byte.MaxValue, + BoundaryFlags = contract.BoundaryFlags | GuestCallBoundaryFlags.RequiresCompleteContext, + }; + /// /// Successor count (0, 1, or 2) and labels for ; count-plus-out-params /// keeps the CFG walk allocation-free. diff --git a/translator/src/Translator.Core/Analysis/GuestAbiInterproceduralAnalyzer.cs b/translator/src/Translator.Core/Analysis/GuestAbiInterproceduralAnalyzer.cs index f6817d3..e614b79 100644 --- a/translator/src/Translator.Core/Analysis/GuestAbiInterproceduralAnalyzer.cs +++ b/translator/src/Translator.Core/Analysis/GuestAbiInterproceduralAnalyzer.cs @@ -18,9 +18,13 @@ public static class GuestAbiInterproceduralAnalyzer { public static GuestAbiInterproceduralResult Analyze( IReadOnlyDictionary functions, - IReadOnlyDictionary? externalContracts = null) + IReadOnlyDictionary? externalContracts = null, + IEnumerable? contextObservingEntryPoints = null) { externalContracts ??= new Dictionary(); + var fullContextSet = (contextObservingEntryPoints ?? Array.Empty()) + .Where(functions.ContainsKey) + .ToHashSet(); var addresses = functions.Keys.OrderBy(static address => address).ToArray(); // GuestAbiContractAnalyzer.Analyze is pure and the round's contract snapshot is frozen, so @@ -40,7 +44,9 @@ public static class GuestAbiInterproceduralAnalyzer 0, addresses.Length, parallelOptions, - index => current[index] = GuestAbiContractAnalyzer.Analyze(bodies[index])); + index => current[index] = AddFullContextEntryEffects( + GuestAbiContractAnalyzer.Analyze(bodies[index]), + fullContextSet.Contains(addresses[index]))); var contracts = new Dictionary(addresses.Length); for (var index = 0; index < addresses.Length; ++index) contracts.Add(addresses[index], current[index]); @@ -95,7 +101,9 @@ public static class GuestAbiInterproceduralAnalyzer Parallel.For(0, worklistCount, parallelOptions, position => { var ordinal = worklist[position]; - var next = GuestAbiContractAnalyzer.Analyze(bodies[ordinal], visible); + var next = AddFullContextEntryEffects( + GuestAbiContractAnalyzer.Analyze(bodies[ordinal], visible), + fullContextSet.Contains(addresses[ordinal])); roundResults[position] = Equivalent(current[ordinal], next) ? null : next; }); @@ -124,6 +132,13 @@ public static class GuestAbiInterproceduralAnalyzer return new GuestAbiInterproceduralResult(contracts, BuildComponents(functions, contracts)); } + private static GuestAbiContract AddFullContextEntryEffects( + GuestAbiContract contract, + bool requiresFullContext) => + requiresFullContext + ? GuestAbiContractAnalyzer.WithReadOnlyContextObserver(contract) + : contract; + /// /// Ordinals of functions this one calls directly, from raw IR rather than /// , which omits inline save/restore thunks diff --git a/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.RegisterResidency.cs b/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.RegisterResidency.cs index 9c03dba..dda02bc 100644 --- a/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.RegisterResidency.cs +++ b/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.RegisterResidency.cs @@ -354,7 +354,7 @@ public sealed partial class CxxLinearCodeGenerator } /// - /// Resolves the boundary sync set for a direct guest call, falling back to when narrowing is disabled, residency is inactive, the callee has no contract or a full-sync fence, or a mod could replace the callee with a re-translation this body's contract doesn't describe. + /// Resolves the boundary sync set for a direct guest call, falling back to when narrowing is disabled, residency is inactive, the callee has no contract or a full-sync fence, or host-side behavior (such as a mod replacement or entry observer) can access state outside the guest contract. /// private static ResidencyBoundarySync ResolveDirectCallBoundarySync( uint target, diff --git a/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.cs b/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.cs index 17b7fe4..aef844d 100644 --- a/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.cs +++ b/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.cs @@ -100,9 +100,21 @@ public sealed partial class CxxLinearCodeGenerator bool gqrConstantsRequireRuntimeGuard = false, bool enableLeafAbiSpillElision = false, IReadOnlySet? modOverridableCallTargets = null, - bool enableGpuFifoBurstCoalescing = true) + IReadOnlySet? fullContextCallTargets = null, + bool enableGpuFifoBurstCoalescing = true, + string? entryObserverHeader = null, + string? entryObserverSymbol = null) { modOverridableCallTargets ??= new HashSet(); + if (fullContextCallTargets is { Count: > 0 }) + { + // The configured observer is read-only, but it can inspect fields + // outside the inferred guest ABI. Reuse the conservative boundary + // machinery so all resident state is materialized before entry. + var completeContextTargets = modOverridableCallTargets.ToHashSet(); + completeContextTargets.UnionWith(fullContextCallTargets); + modOverridableCallTargets = completeContextTargets; + } nonReturningCallTargets ??= new HashSet(); lrContinuationCallTargets ??= new HashSet(); guestAbiContracts ??= new Dictionary(); @@ -149,6 +161,10 @@ public sealed partial class CxxLinearCodeGenerator // tuple for every instruction of every emission of this body. var suppressedInstructionMasks = BuildSuppressedInstructionMasks(func, flagElision); var cxxName = SanitizeIdentifier(signature.Name); + var emitEntryObserver = !string.IsNullOrWhiteSpace(entryObserverHeader) && + !string.IsNullOrWhiteSpace(entryObserverSymbol); + if (emitEntryObserver) + guestAbiContract = GuestAbiContractAnalyzer.WithReadOnlyContextObserver(guestAbiContract); var pairedFlow = ComputePairedFlowStates(func, cfg, _guestAbiProvider); var pairedIn = pairedFlow.In; var pairedOut = pairedFlow.Out; @@ -661,6 +677,21 @@ public sealed partial class CxxLinearCodeGenerator { code = ApplyGuardedGqrFunctionVersioning(code, implementationName, gqrEntryGuards); } + if (emitEntryObserver) + { + var declaration = FunctionDefinitionSignature(implementationName); + var declarationStart = code.IndexOf(declaration, StringComparison.Ordinal); + if (declarationStart < 0) + throw new InvalidOperationException($"Entry observer could not find the function body for 0x{entryPoint:X8}."); + var openBrace = code.IndexOf('{', declarationStart + declaration.Length); + if (openBrace < 0) + throw new InvalidOperationException($"Entry observer found no function body for 0x{entryPoint:X8}."); + // The explicit const conversion makes the manifest's read-only + // callback contract a compile-time requirement for every project. + var observer = $"{Environment.NewLine} {entryObserverSymbol}(0x{entryPoint:X8}u, static_cast(ctx));"; + code = $"#include \"{entryObserverHeader}\"" + Environment.NewLine + + code.Insert(openBrace + 1, observer); + } return new CxxEmissionResult( code, diff --git a/translator/src/Translator.Core/Translation/FunctionTranslator.cs b/translator/src/Translator.Core/Translation/FunctionTranslator.cs index 34bb2aa..f3ee992 100644 --- a/translator/src/Translator.Core/Translation/FunctionTranslator.cs +++ b/translator/src/Translator.Core/Translation/FunctionTranslator.cs @@ -49,6 +49,13 @@ public sealed record TranslationOptions( bool GqrConstantsRequireRuntimeGuard = false, bool EnableLeafAbiSpillElision = false, IReadOnlySet? ModOverridableCallTargets = null, + /// + /// Direct-call targets whose read-only host-side entry observer may inspect + /// any field in CpuContext, independently of the translated guest ABI. + /// Callers materialize all resident architectural state before these + /// boundaries. Observer APIs must accept a const CpuContext pointer. + /// + IReadOnlySet? FullContextCallTargets = null, bool GenerateCxx = true, bool DiscoveryOnly = false, /// @@ -70,7 +77,15 @@ public sealed record TranslationOptions( /// acyclic and every exit is a plain return. Emergency opt-out; the /// straight-line shape keeps working with this off. /// - bool LeafInliningAllowMultiBlockCallees = true) + bool LeafInliningAllowMultiBlockCallees = true, + /// + /// Optional project-configured observer inserted at this function's public + /// CpuContext entry. The CLI excludes observed functions from leaf inlining + /// and state-free ABI lowering, and marks calls to them as full-context + /// boundaries, so no direct-call optimization can bypass or starve it. + /// + string? EntryObserverHeader = null, + string? EntryObserverSymbol = null) { public static TranslationOptions Default { get; } = new(); } @@ -746,7 +761,10 @@ public sealed class FunctionTranslator gqrCalleeWriteMasks: options.GqrCalleeWriteMasks, gqrConstantsRequireRuntimeGuard: options.GqrConstantsRequireRuntimeGuard, enableLeafAbiSpillElision: options.EnableLeafAbiSpillElision, - modOverridableCallTargets: options.ModOverridableCallTargets); + modOverridableCallTargets: options.ModOverridableCallTargets, + fullContextCallTargets: options.FullContextCallTargets, + entryObserverHeader: options.EntryObserverHeader, + entryObserverSymbol: options.EntryObserverSymbol); } private static IrTracePpc CreateTrace(PpcInstruction instruction) diff --git a/translator/tests/Translator.Tests/CxxLinearCodeGeneratorEmitExtensions.cs b/translator/tests/Translator.Tests/CxxLinearCodeGeneratorEmitExtensions.cs index 9ea4f49..478d156 100644 --- a/translator/tests/Translator.Tests/CxxLinearCodeGeneratorEmitExtensions.cs +++ b/translator/tests/Translator.Tests/CxxLinearCodeGeneratorEmitExtensions.cs @@ -38,7 +38,10 @@ internal static class CxxLinearCodeGeneratorEmitExtensions bool gqrConstantsRequireRuntimeGuard = false, bool enableLeafAbiSpillElision = false, IReadOnlySet? modOverridableCallTargets = null, - bool enableGpuFifoBurstCoalescing = true) => + IReadOnlySet? fullContextCallTargets = null, + bool enableGpuFifoBurstCoalescing = true, + string? entryObserverHeader = null, + string? entryObserverSymbol = null) => generator.EmitWithFacts( entryPoint: entryPoint, ssa: ssa, @@ -63,5 +66,8 @@ internal static class CxxLinearCodeGeneratorEmitExtensions gqrConstantsRequireRuntimeGuard: gqrConstantsRequireRuntimeGuard, enableLeafAbiSpillElision: enableLeafAbiSpillElision, modOverridableCallTargets: modOverridableCallTargets, - enableGpuFifoBurstCoalescing: enableGpuFifoBurstCoalescing).Code; + fullContextCallTargets: fullContextCallTargets, + enableGpuFifoBurstCoalescing: enableGpuFifoBurstCoalescing, + entryObserverHeader: entryObserverHeader, + entryObserverSymbol: entryObserverSymbol).Code; } diff --git a/translator/tests/Translator.Tests/GenericProjectTests.cs b/translator/tests/Translator.Tests/GenericProjectTests.cs index 6893137..5155c28 100644 --- a/translator/tests/Translator.Tests/GenericProjectTests.cs +++ b/translator/tests/Translator.Tests/GenericProjectTests.cs @@ -110,6 +110,10 @@ public sealed class GenericProjectTests translation: entry_points: [0x80001000] allow_unsupported_instructions: true + entry_observer: + header: instrumentation/entry_observer.h + symbol: ObserveEntry + entry_points: [0x80001000] output: root: out """); @@ -117,6 +121,10 @@ public sealed class GenericProjectTests var project = TranslationProjectConfig.Load(projectPath); Assert.Null(project.Inputs.Rel); Assert.Equal(entry, Assert.Single(project.Translation.EntryPoints)); + var entryObserver = Assert.IsType(project.Translation.EntryObserver); + Assert.Equal("instrumentation/entry_observer.h", entryObserver.Header); + Assert.Equal("ObserveEntry", entryObserver.Symbol); + Assert.Contains(entry, entryObserver.EntryPoints); var dol = DolFile.Load(project.Inputs.Dol.Path); var image = new ProgramImageBuilder().Build(dol, ramBase: project.Memory.Base, ramSize: project.Memory.Size); diff --git a/translator/tests/Translator.Tests/GqrPrologueHoistingCodeGenTests.cs b/translator/tests/Translator.Tests/GqrPrologueHoistingCodeGenTests.cs index e707911..49b2473 100644 --- a/translator/tests/Translator.Tests/GqrPrologueHoistingCodeGenTests.cs +++ b/translator/tests/Translator.Tests/GqrPrologueHoistingCodeGenTests.cs @@ -19,13 +19,17 @@ public class GqrPrologueHoistingCodeGenTests { private static string Emit( IrFunction function, - IReadOnlyDictionary? gqrCalleeWriteMasks = null) => + IReadOnlyDictionary? gqrCalleeWriteMasks = null, + IReadOnlyDictionary? gqrEntryConstants = null, + IReadOnlySet? fullContextCallTargets = null) => new CxxLinearCodeGenerator().Emit( 0x80001000u, new SsaTransformer().Convert(function), new FunctionAbiClassification(function.Name, ValueRepresentation.Void), new RepresentationEnvironment(new Dictionary()), - gqrCalleeWriteMasks: gqrCalleeWriteMasks); + gqrEntryConstants: gqrEntryConstants, + gqrCalleeWriteMasks: gqrCalleeWriteMasks, + fullContextCallTargets: fullContextCallTargets); private static IrCall PsqLoad(int index, string address = "r4") => new("f1", "PPC_PsqL", [IrValue.Register(address), IrValue.Imm(0), IrValue.Imm(index)]); @@ -141,6 +145,22 @@ public class GqrPrologueHoistingCodeGenTests Assert.Equal(2, CountOccurrences(code, "mkw_gqr3 = ctx->gqr[3];")); } + [Fact] + public void ReadOnlyContextObserverDoesNotInventGqrWrites() + { + var code = Emit( + Function("observed_call", + new IrCall(string.Empty, "func_80002000", []), + PsqLoad(3), + new IrReturn(null)), + gqrCalleeWriteMasks: new Dictionary { [0x80002000u] = 0 }, + gqrEntryConstants: new Dictionary { ["gqr3"] = 0x00070007u }, + fullContextCallTargets: new HashSet { 0x80002000u }); + + Assert.DoesNotContain("mkw_gqr3", code, StringComparison.Ordinal); + Assert.Contains("PPC_PsqLKnownInline<0u, 3u, 0x00070007u>", code, StringComparison.Ordinal); + } + [Fact] public void RuntimeHelperCallsDoNotForceAReload() { diff --git a/translator/tests/Translator.Tests/GuestAbiInterproceduralAnalyzerTests.cs b/translator/tests/Translator.Tests/GuestAbiInterproceduralAnalyzerTests.cs index 9c9daa1..56aaac2 100644 --- a/translator/tests/Translator.Tests/GuestAbiInterproceduralAnalyzerTests.cs +++ b/translator/tests/Translator.Tests/GuestAbiInterproceduralAnalyzerTests.cs @@ -84,6 +84,35 @@ public sealed class GuestAbiInterproceduralAnalyzerTests result.Contracts[second].GprReadBeforeWriteMask & ((1u << 3) | (1u << 4))); } + [Fact] + public void FullContextEntryEffectsPropagateThroughAllCallers() + { + const uint root = 0x80001000u; + const uint middle = 0x80002000u; + const uint observed = 0x80003000u; + var functions = new Dictionary + { + [root] = Function("root", + new IrCall("lr", $"0x{middle:X8}", Array.Empty()), + new IrReturn(null)), + [middle] = Function("middle", + new IrCall("lr", $"0x{observed:X8}", Array.Empty()), + new IrReturn(null)), + [observed] = Function("observed", new IrReturn(null)) + }; + + var result = GuestAbiInterproceduralAnalyzer.Analyze( + functions, + contextObservingEntryPoints: new[] { observed }); + + Assert.All(new[] { root, middle, observed }, + address => Assert.True(result.Contracts[address].HasFullSynchronizationFence)); + Assert.Equal(uint.MaxValue, result.Contracts[observed].GprReadBeforeWriteMask); + Assert.Equal(0u, result.Contracts[observed].GprPossibleWriteMask); + Assert.Equal(byte.MaxValue, result.Contracts[observed].GqrReadBeforeWriteMask); + Assert.Equal((byte)0, result.Contracts[observed].GqrPossibleWriteMask); + } + [Fact] public void DeepCallGraphDoesNotConsumeTheNativeStack() { diff --git a/translator/tests/Translator.Tests/MkwVrObserverCodeGenTests.cs b/translator/tests/Translator.Tests/MkwVrObserverCodeGenTests.cs new file mode 100644 index 0000000..7b4b7f3 --- /dev/null +++ b/translator/tests/Translator.Tests/MkwVrObserverCodeGenTests.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using Translator.Core.Analysis.Representation; +using Translator.Core.Analysis.Ssa; +using Translator.Core.CodeGen; +using Translator.Core.Ir; +using Translator.Core.Representation; +using Xunit; + +namespace Translator.Tests; + +public sealed class MkwVrObserverCodeGenTests +{ + [Fact] + public void EmitsOnlyWhenProjectConfiguresAnObserver() + { + var function = new IrFunction( + "race_scene_enter", + "entry", + new[] + { + new IrBasicBlock("entry", new IrInstruction[] { new IrReturn(null) }) + }); + var types = new RepresentationEnvironment(new Dictionary()); + var signature = new FunctionAbiClassification("race_scene_enter", ValueRepresentation.Void); + var ssa = new SsaTransformer().Convert(function); + + var observed = new CxxLinearCodeGenerator().EmitWithFacts( + 0x80553C50u, ssa, signature, types, + entryObserverHeader: "vr/mkw_vr_instrumentation.h", + entryObserverSymbol: "MkwVRObserveTranslatedFunctionEntry"); + var ordinary = new CxxLinearCodeGenerator().Emit(0x80553C50u, ssa, signature, types); + + Assert.Contains("#include \"vr/mkw_vr_instrumentation.h\"", observed.Code); + Assert.Contains( + "MkwVRObserveTranslatedFunctionEntry(0x80553C50u, static_cast(ctx));", + observed.Code); + Assert.True(observed.GuestAbiContract.HasFullSynchronizationFence); + Assert.Equal(uint.MaxValue, observed.GuestAbiContract.GprReadBeforeWriteMask); + Assert.Equal(0u, observed.GuestAbiContract.GprPossibleWriteMask); + Assert.Contains("fence=1", observed.GuestAbiMarker); + Assert.DoesNotContain("MkwVRObserveTranslatedFunctionEntry", ordinary); + } +} diff --git a/translator/tests/Translator.Tests/RegisterResidencyCodeGenTests.cs b/translator/tests/Translator.Tests/RegisterResidencyCodeGenTests.cs index 67f9a78..dd93807 100644 --- a/translator/tests/Translator.Tests/RegisterResidencyCodeGenTests.cs +++ b/translator/tests/Translator.Tests/RegisterResidencyCodeGenTests.cs @@ -18,7 +18,8 @@ public class RegisterResidencyCodeGenTests RepresentationEnvironment? types = null, bool stateFree = false, uint entryPoint = 0x80001000u, - IReadOnlyDictionary? guestAbiContracts = null) + IReadOnlyDictionary? guestAbiContracts = null, + IReadOnlySet? fullContextCallTargets = null) { var contract = GuestAbiContractAnalyzer.Analyze(function); return new CxxLinearCodeGenerator().Emit( @@ -27,6 +28,7 @@ public class RegisterResidencyCodeGenTests new FunctionAbiClassification(function.Name, ValueRepresentation.Void), types ?? new RepresentationEnvironment(new Dictionary()), guestAbiContracts: guestAbiContracts, + fullContextCallTargets: fullContextCallTargets, emitStateFreeLeafVariant: stateFree, stateFreeAbiContracts: stateFree ? new Dictionary { [entryPoint] = contract } @@ -308,6 +310,71 @@ public class RegisterResidencyCodeGenTests Assert.DoesNotContain("r5 = ctx->gpr[5];", afterCall, StringComparison.Ordinal); } + [Fact] + public void ContextObservingTargetForcesAFullDirectCallBoundary() + { + var target = 0x80002000u; + var contracts = new Dictionary + { + [target] = NarrowContract(gprRead: 1u << 4, gprWrite: 1u << 3) + }; + var body = FunctionBody( + Emit( + CallerAcross("observed_call", $"0x{target:X8}"), + guestAbiContracts: contracts, + fullContextCallTargets: new HashSet { target }), + "observed_call"); + var call = body.IndexOf($"InvokeDirectCpu<0x{target:X8}u>(ctx);", StringComparison.Ordinal); + + Assert.True(call > 0); + // r5 is outside the guest callee's contract, but a host observer may + // inspect it through CpuContext. + Assert.Contains("ctx->gpr[5] = r5;", body[..call], StringComparison.Ordinal); + Assert.Contains(" r4 = ctx->gpr[4];", body[call..], StringComparison.Ordinal); + } + + [Fact] + public void ContextObserverForcesFullBoundariesThroughIntermediateCallers() + { + const uint root = 0x80001000u; + const uint middle = 0x80002000u; + const uint observed = 0x80003000u; + var rootFunction = CallerAcross("transitive_observed_call", $"0x{middle:X8}"); + var functions = new Dictionary + { + [root] = rootFunction, + [middle] = new IrFunction("middle", "entry", new[] + { + new IrBasicBlock("entry", new IrInstruction[] + { + new IrCall(string.Empty, $"0x{observed:X8}", Array.Empty()), + new IrReturn(null) + }) + }), + [observed] = new IrFunction("observed", "entry", new[] + { + new IrBasicBlock("entry", new IrInstruction[] { new IrReturn(null) }) + }) + }; + var contracts = GuestAbiInterproceduralAnalyzer.Analyze( + functions, + contextObservingEntryPoints: new[] { observed }).Contracts; + + var body = FunctionBody( + Emit( + rootFunction, + entryPoint: root, + guestAbiContracts: contracts, + fullContextCallTargets: new HashSet { observed }), + "transitive_observed_call"); + var call = body.IndexOf($"InvokeDirectCpu<0x{middle:X8}u>(ctx);", StringComparison.Ordinal); + + Assert.True(contracts[middle].HasFullSynchronizationFence); + Assert.True(call > 0); + Assert.Contains("ctx->gpr[5] = r5;", body[..call], StringComparison.Ordinal); + Assert.Contains(" r4 = ctx->gpr[4];", body[call..], StringComparison.Ordinal); + } + [Fact] public void BoundaryNarrowingFlushesRegistersTheCalleeOnlyMayWrite() {