Scope Kamek bl-patch LR-continuation detection to genuine skip-return… (#218)

* Scope Kamek bl-patch LR-continuation detection to genuine skip-return targets

Fix crash from Kamek skip-return hooks (Item Rain crash) (#182) added every
Kamek BranchLink patch target to lrContinuationCallTargets unconditionally,
with no filter analogous to the RetroWfcHookSetsLinkRegister check already
used for RetroWFC hooks. Since bl is the ordinary PowerPC call instruction,
this made the codegen treat effectively every patched call in the mod as a
potential skip-return hook, forcing conservative handling (full register
reload, disabled resident-call fast paths, local LR-continuation dispatch
tables) onto thousands of calls that just return normally.

For Retro Rewind this inflated total translated mod size by +42%
(1,414,327 -> 2,005,284 lines), concentrated in ~10 unrelated overlay
functions that happened to call a patched target, and was enough to make
one aggregate build shard pathologically slow to compile (hangs Linux CI).

Instead, only mark a bl target as LR-continuation-aware if a lightweight
discovery-only decode of its own body actually finds evidence of
skip-return behavior via DiscoverLrRelativeIndirectJumpOffsets. Falls back
to the conservative (old) behavior if a target can't be statically
analyzed, so no skip-return case is silently missed.

Verified against the real Retro Rewind mod: total mod size returns to
1,416,350 lines (+0.14% vs. pre-fix, down from +42%), all 6 genuinely new
continuation functions from the original fix are preserved, zero
functions lost, and all 609 existing translator tests still pass.

* Distinguish exhausted from truncated LR-relative offset search

CodeRabbit flagged that TargetExhibitsLrSkipReturn (added in ad2d4e7) treated
an empty DiscoverLrRelativeIndirectJumpOffsets result as a verified "this
target never skip-returns," but the analysis silently drops any path state
once more than MaxStatesPerInstruction (16) distinct states reach one
instruction - a bctr/return on a dropped state can never contribute its
offset, so an empty result could be an incomplete search rather than a real
negative. Treating every capped case as "skip-return possible" outright was
rejected as too broad a fallback given how conservative/expensive that path
already is.

Instead: raise MaxStatesPerInstruction 16 -> 512 (an arbitrary conservative
bound to begin with, not something correctness depended on) so genuinely
branchy functions have far more headroom to reach an exhaustive answer, and
give DiscoverLrRelativeIndirectJumpOffsets an optional onStateCapExceeded
callback that fires exactly when a state is dropped. TargetExhibitsLrSkipReturn
now only falls back to the conservative "treat as skip-return" answer when
the search both found nothing and the cap was actually hit during that run -
not whenever the cap merely exists - so a target is trusted as clean once the
search genuinely exhausts it.

Verified: all 609 translator tests pass, and a full translate-mod run against
the real Retro Rewind mod produces byte-for-byte identical output to the
prior fix (same 4,065 functions, 1,416,350 total lines) - confirming the
16-state cap was never actually the limiting factor in practice and this
change is a pure safety-net closure, not a behavior change for this mod.

* Add LR continuation regression tests

* Refine LR continuation hook analysis

---------

Co-authored-by: patchzyy <64382339+patchzyy@users.noreply.github.com>
This commit is contained in:
theofficialgmanandpatchzyy authored and GitHub committed 2026-09-14 16:47:28 +02:00
1 parent 6458ec6abe
commit 8e0cc96898
6 files changed
+608 -42

No files matched your search

+47 -39
View File
@@ -2282,9 +2282,37 @@ int EmitModCpp(
.Where(h => h.TargetAddress.HasValue && RetroWfcHookSetsLinkRegister(h))
.Select(h => (h.TargetAddress!.Value, h.ContinuationAddress)));
}
var hookLrAnalysis = new Dictionary<uint, LrContinuationAnalysis>();
var hookDiscoveryCache = new Dictionary<uint, IReadOnlyList<PpcInstruction>>();
IReadOnlyList<PpcInstruction> DiscoverHookBody(uint target)
{
if (!hookDiscoveryCache.TryGetValue(target, out var instructions))
{
instructions = modTranslator.Discover(target,
new TranslationOptions(KnownFunctionEntryPoints: knownFunctionEntryPoints)).Instructions;
hookDiscoveryCache.Add(target, instructions);
}
return instructions;
}
LrContinuationAnalysis AnalyzeHook(uint target)
{
if (!hookLrAnalysis.TryGetValue(target, out var analysis))
{
analysis = LrContinuationAnalysis.Analyze(target, DiscoverHookBody);
hookLrAnalysis.Add(target, analysis);
}
return analysis;
}
foreach (var patch in patchPlan.ExecutablePatches.Where(p => p.CommandId == KamekCommandId.BranchLink && p.Arguments.Count > 0))
{
var target = KamekAddress.Resolve(patch.Arguments[0], patchPlan.ModuleGuestBase);
if (!AnalyzeHook(target).MaySkipReturn)
{
continue;
}
hookLrBases.Add((target, checked(patch.CommandAddress + 4u)));
}
@@ -2387,50 +2415,42 @@ int EmitModCpp(
}
}
void RecordDiscoveredLrRelativeBaseContinuations(
FunctionTranslationResult result,
IReadOnlyList<uint> lrBases,
string reason)
void RecordHookContinuations(uint hookTarget, IReadOnlyList<uint> lrBases)
{
if (lrBases.Count == 0)
var analysis = AnalyzeHook(hookTarget);
foreach (var lrBase in lrBases)
{
return;
}
foreach (var offset in DiscoverLrRelativeIndirectJumpOffsets(result).Distinct())
{
foreach (var lrBase in lrBases)
var targets = analysis.Offsets.Select(offset => unchecked(lrBase + (uint)offset));
if (analysis.WasTruncated && baseFunctions.FindContaining(lrBase - 4u) is { } caller)
{
// Unknown offsets can resume at any aligned instruction in this caller.
targets = targets.Concat(Enumerable.Range(0, checked((int)((caller.End - caller.Start) / 4)))
.Select(index => caller.Start + (uint)index * 4u));
}
foreach (var target in targets.Distinct())
{
var target = unchecked(lrBase + (uint)offset);
var section = baseManifest.Sections.FirstOrDefault(s => target >= s.GuestStart && target < s.GuestEnd);
if (section is null || !section.Executable)
{
if (section is null || !section.Executable || (target & 3u) != 0)
continue;
}
var containing = baseFunctions.FindContaining(target);
if (containing is null || containing.Start == target)
{
if (containing is null || containing.Start == target || !queuedContinuationAddresses.Add(target))
continue;
}
if (!queuedContinuationAddresses.Add(target))
{
continue;
}
discoveredContinuationQueue.Enqueue(new ContinuationEntry(
target,
containing.Start,
containing.End,
section.Name,
result.EntryPoint,
hookTarget,
KamekCommandId.Branch,
$"{reason}; LR-relative jump offset {offset:+#;-#;0}"));
$"LR-relative hook target 0x{hookTarget:X8}"));
}
}
}
foreach (var (target, lrBases) in linkedHookLrBasesByTarget)
RecordHookContinuations(target, lrBases);
ModTranslationWork CreateContinuationWork(ContinuationEntry continuation)
{
var name = $"rr_continue_{continuation.Address:X8}";
@@ -2596,16 +2616,7 @@ int EmitModCpp(
}
CommitWave(attempts, (result, work) =>
{
RecordDiscoveredBaseContinuations(result, $"base continuation discovered from module 0x{work.Address:X8}");
if (linkedHookLrBasesByTarget.TryGetValue(work.Address, out var lrBases))
{
RecordDiscoveredLrRelativeBaseContinuations(
result,
lrBases,
$"base continuation discovered from LR-relative hook target 0x{work.Address:X8}");
}
});
RecordDiscoveredBaseContinuations(result, $"base continuation discovered from module 0x{work.Address:X8}"));
}
DrainDiscoveredContinuations();
@@ -2763,9 +2774,6 @@ IEnumerable<uint> DirectModuleTargets(FunctionTranslationResult result, uint mod
}
}
IEnumerable<int> DiscoverLrRelativeIndirectJumpOffsets(FunctionTranslationResult result) =>
ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(result.Instructions);
static bool RetroWfcHookSetsLinkRegister(RetroWfcExecutableHookPlan hook) =>
hook.TypeName is "call" or "branchCtrLink" ||
hook.Intent.Contains("Call", StringComparison.Ordinal);
@@ -275,7 +275,13 @@ public static class ContinuationPlanner
AddSigned
}
public static IEnumerable<int> DiscoverLrRelativeIndirectJumpOffsets(IReadOnlyList<PpcInstruction> instructions)
// Dropped states make a negative result inconclusive.
private const int MaxStatesPerInstruction = 512;
public static IEnumerable<int> DiscoverLrRelativeIndirectJumpOffsets(
IReadOnlyList<PpcInstruction> instructions,
Action? onStateCapExceeded = null,
Action? onUnresolvedExit = null)
{
if (instructions.Count == 0)
{
@@ -296,7 +302,6 @@ public static class ContinuationPlanner
var seenOffsets = new HashSet<int>();
var worklist = new Queue<(int Index, PathState State)>();
const int MaxStatesPerInstruction = 16;
void Enqueue(int targetIndex, PathState stateToEnqueue)
{
@@ -325,6 +330,7 @@ public static class ContinuationPlanner
if (visited[idx].Count > MaxStatesPerInstruction)
{
onStateCapExceeded?.Invoke();
continue;
}
@@ -484,6 +490,9 @@ public static class ContinuationPlanner
yield return state.CtrOffset.Value;
}
if (!state.CtrOffset.HasValue && instruction.BranchTargets.Count == 0)
onUnresolvedExit?.Invoke();
nextState = nextState.WithCtrOffset(null);
if (instruction.BranchTargets.Count == 0)
{
@@ -495,6 +504,8 @@ public static class ContinuationPlanner
(mnemonic.StartsWith("b", StringComparison.Ordinal) && mnemonic.EndsWith("lr", StringComparison.Ordinal)));
if (isReturn)
{
if (!state.LrReturnOffset.HasValue)
onUnresolvedExit?.Invoke();
if (state.LrReturnOffset.HasValue && state.LrReturnOffset.Value != 0 && seenOffsets.Add(state.LrReturnOffset.Value))
{
yield return state.LrReturnOffset.Value;
@@ -524,7 +535,7 @@ public static class ContinuationPlanner
Enqueue(fallthrough.Value, nextState);
}
if (!isReturn)
if (!isReturn && !instruction.IsCall)
{
foreach (var target in instruction.BranchTargets)
{
@@ -0,0 +1,83 @@
using Translator.Core.Disassembly;
namespace Translator.Core.Mods;
// Opaque exits prevent classification; truncated exploration may also hide offsets.
public sealed record LrContinuationAnalysis(IReadOnlyList<int> Offsets, bool IsComplete, bool WasTruncated)
{
public bool MaySkipReturn => !IsComplete || Offsets.Count != 0;
public static LrContinuationAnalysis Analyze(
uint entryPoint,
Func<uint, IReadOnlyList<PpcInstruction>> discover)
{
const int maxFunctions = 256;
const int maxInstructions = 65536;
var instructions = new Dictionary<uint, PpcInstruction>();
var pending = new Queue<uint>();
var visited = new HashSet<uint>();
var complete = true;
var truncated = false;
pending.Enqueue(entryPoint);
while (pending.TryDequeue(out var entry))
{
if (instructions.ContainsKey(entry) || !visited.Add(entry))
continue;
if (visited.Count > maxFunctions)
{
complete = false;
truncated = true;
break;
}
IReadOnlyList<PpcInstruction> body;
try
{
body = discover(entry);
}
catch (Exception ex) when (ex is InvalidOperationException or ArgumentException
or IndexOutOfRangeException or NotSupportedException or OverflowException)
{
complete = false;
truncated = true;
continue;
}
if (!body.Any(instruction => instruction.Address == entry) ||
instructions.Count + body.Count > maxInstructions)
{
complete = false;
truncated = true;
continue;
}
foreach (var instruction in body)
instructions.TryAdd(instruction.Address, instruction);
foreach (var instruction in body)
{
if (!instruction.IsCall)
{
foreach (var target in instruction.BranchTargets)
if (!instructions.ContainsKey(target)) pending.Enqueue(target);
}
if ((!instruction.IsReturn && !instruction.IsUnconditionalBranch) ||
instruction.IsConditionalBranch)
{
if (!instructions.ContainsKey(instruction.EndAddress))
pending.Enqueue(instruction.EndAddress);
}
}
}
if (!instructions.TryGetValue(entryPoint, out var first))
return new LrContinuationAnalysis([], false, true);
// Keep the original entry first, including when a tail target precedes it.
var ordered = new[] { first }.Concat(instructions.Values
.Where(instruction => instruction.Address != entryPoint)
.OrderBy(instruction => instruction.Address)).ToArray();
var offsets = ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(
ordered, () => { complete = false; truncated = true; }, () => complete = false).ToArray();
return new LrContinuationAnalysis(offsets, complete, truncated);
}
}
@@ -0,0 +1,261 @@
using System.Buffers.Binary;
using System.Text.Json;
using Translator.Cli.Configuration;
using Translator.Core.Build;
using Translator.Core.Mods;
using Translator.Core.Parsing.Kamek;
namespace Translator.Tests;
public sealed class KamekLrContinuationIntegrationTests
{
private const uint Caller = 0x80004000;
private const uint Module = 0x80010000;
private const uint Hook = Module + 0x40;
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public void SkipReturnThroughKnownTailCallsRetainsCallerDispatch(int tailDepth)
{
var bundle = Translate(tailDepth, SkipReturn(20));
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
Assert.Contains(bundle.Entries, entry => entry.EntryPoint == Caller + 24 &&
entry.VirtualPath.Contains("rr_continue_"));
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public void OrdinaryReturnsThroughKnownTailCallsStayLightweight(int tailDepth)
{
var bundle = Translate(tailDepth, [0x38630001u, 0x4E800020u]); // addi r3,r3,1; blr
var caller = Source(bundle, Caller);
Assert.DoesNotContain("switch (ctx->lr)", caller);
Assert.DoesNotContain("if (ctx->lr !=", caller);
Assert.DoesNotContain(bundle.Entries, entry => entry.VirtualPath.Contains("rr_continue_"));
}
[Fact]
public void DirectSkipReturnRegistersTheAdjustedBaseAddress()
{
var bundle = Translate(0, SkipReturn(20));
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
Assert.Contains(bundle.Entries, entry => entry.EntryPoint == Caller + 24 &&
entry.VirtualPath.Contains("rr_continue_"));
}
[Fact]
public void CtrSkipRetainsDispatchAndRegistersItsContinuation()
{
var bundle = Translate(0,
[
0x7D8802A6u, // mflr r12
0x398C0014u, // addi r12,r12,20
0x7D8903A6u, // mtctr r12
0x4E800420u // bctr
]);
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
Assert.Contains(bundle.Entries, entry => entry.EntryPoint == Caller + 24 &&
entry.VirtualPath.Contains("rr_continue_"));
}
[Fact]
public void NormalReturnArmDoesNotHideTailCalledSkipReturn()
{
var bundle = Translate(1, SkipReturn(20), conditionalWrapper: true);
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
}
[Theory]
[InlineData(0)]
[InlineData(1)]
public void SharedHookRetainsResumeDispatchAtEveryCallSite(int tailDepth)
{
var bundle = Translate(tailDepth, SkipReturn(20), sharedTarget: true);
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
AssertResumeDispatch(Source(bundle, Caller + 0x40), Caller + 0x40, Caller + 0x58);
}
[Theory]
[InlineData(0)]
[InlineData(1)]
public void SkipReturnHandlingDoesNotSpreadToAnOrdinaryPatchedCaller(int tailDepth)
{
var bundle = Translate(tailDepth, SkipReturn(20), companionBody: [0x38630001u, 0x4E800020u]);
var ordinaryCaller = Source(bundle, Caller + 0x40);
Assert.DoesNotContain("switch (ctx->lr)", ordinaryCaller);
Assert.DoesNotContain("if (ctx->lr !=", ordinaryCaller);
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
}
[Theory]
[InlineData(0)]
[InlineData(20)]
public void SavedAndRestoredLrDistinguishesOrdinaryAndSkipReturns(int offset)
{
var bundle = Translate(0,
[
0x7D8802A6u, // mflr r12
0x91810004u, // stw r12,4(r1)
0x81810004u, // lwz r12,4(r1)
0x398C0000u | (ushort)offset, // addi r12,r12,offset
0x7D8803A6u, // mtlr r12
0x4E800020u // blr
]);
var caller = Source(bundle, Caller);
if (offset == 0)
Assert.DoesNotContain("if (ctx->lr !=", caller);
else
AssertResumeDispatch(caller, Caller, Caller + 4 + (uint)offset);
}
[Fact]
public void CappedAnalysisDoesNotClassifyAHookAsAnOrdinaryReturn()
{
var bundle = Translate(0, SyntheticLrHookFactory.ManyOrdinaryReturnPaths());
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
Assert.Contains(bundle.Entries, entry => entry.EntryPoint == Caller + 24 &&
entry.VirtualPath.Contains("rr_continue_"));
}
private static void AssertResumeDispatch(string source, uint callSite, uint continuation)
{
var call = source.IndexOf($"InvokeDirectCpu<0x{Hook:X8}u>(ctx);", StringComparison.Ordinal);
Assert.True(call >= 0, source);
var guard = source.IndexOf($"if (ctx->lr != 0x{callSite + 4:X8}u)", call, StringComparison.Ordinal);
Assert.True(guard > call, $"The hook must dispatch its adjusted return address.\n{source}");
Assert.Contains($"case 0x{continuation:X8}u:", source[guard..]);
Assert.Contains($"goto loc_{continuation:X8};", source[guard..]);
Assert.Contains($"loc_{continuation:X8}:", source);
}
private static uint[] SkipReturn(int offset) =>
[
0x7D8802A6u, // mflr r12
0x398C0000u | (ushort)offset, // addi r12,r12,offset
0x7D8803A6u, // mtlr r12
0x4E800020u // blr
];
private static string Source(TranslationSourceBundle bundle, uint address) =>
Assert.Single(bundle.Entries.Where(entry => entry.EntryPoint == address &&
entry.VirtualPath.StartsWith("overlays/", StringComparison.Ordinal))).Source;
private static TranslationSourceBundle Translate(int tailDepth, uint[] body,
bool conditionalWrapper = false, bool sharedTarget = false, uint[]? companionBody = null)
{
var root = Path.Combine(Path.GetTempPath(), $"kamek-lr-integration-{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
try
{
uint[] caller = [0x60000000u, 0x38630001u, 0x38630001u, 0x38630001u,
0x38630001u, 0x38630001u, 0x60000000u, 0x4E800020u];
var hasSecondCaller = sharedTarget || companionBody is not null;
var baseWords = Enumerable.Repeat(0x4E800020u, hasSecondCaller ? 24 : 8).ToArray();
caller.CopyTo(baseWords, 0);
if (hasSecondCaller) caller.CopyTo(baseWords, 16);
var baseBytes = Words(baseWords);
File.WriteAllBytes(Path.Combine(root, "main.dol"), SyntheticDolFactory.CreateBytes(
Caller, sections: [SyntheticDolFactory.Text(0, Caller, baseWords)]));
File.WriteAllBytes(Path.Combine(root, "base.bin"), baseBytes);
// translate-mod requires a REL; this fixture has no REL code or relocations.
File.WriteAllBytes(Path.Combine(root, "empty.rel"), new byte[0x48]);
var functions = new List<BaseFunctionRangeMetadata>
{
new(Caller, Caller + 32, "caller", ".text", 0, "synthetic", ["Executable"])
};
if (hasSecondCaller)
functions.Add(new(Caller + 0x40, Caller + 0x60, "second_caller", ".text", 0x40,
"synthetic", ["Executable"]));
var manifest = new BaseManifest("synthetic", 1, "TEST01", "P", "", 0,
[new BaseSectionMetadata(".text", "main.dol", Caller, Caller + (uint)baseBytes.Length,
true, false, "base.bin", 0)],
functions, "ranges.json");
var manifestPath = Path.Combine(root, "base.json");
File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest));
var projectPath = Path.Combine(root, "recomp.yml");
Directory.CreateDirectory(Path.Combine(root, "native"));
Directory.CreateDirectory(Path.Combine(root, "generated", "functions"));
File.WriteAllText(projectPath, """
schema_version: 1
project:
id: kamek-lr-test
memory:
base: 0x80000000
size: 0x00020000
sda_base: 0x80002000
sda2_base: 0x80003000
inputs:
dol:
path: main.dol
rel:
path: empty.rel
load_address: 0x80008000
runtime:
native_registration_root: native
output:
root: generated
""");
var moduleSize = companionBody is null ? 0x40 * (tailDepth + 1) + body.Length * 4
: 0x300 + companionBody.Length * 4;
var moduleWords = Enumerable.Repeat(0x4E800020u, moduleSize / 4).ToArray();
body.CopyTo(moduleWords, (0x40 * (tailDepth + 1)) / 4);
var commands = new List<uint> { ((uint)KamekCommandId.BranchLink << 24) | 0x00FFFFFEu, Caller, Hook - Module };
if (hasSecondCaller)
commands.AddRange([((uint)KamekCommandId.BranchLink << 24) | 0x00FFFFFEu,
Caller + 0x40, sharedTarget ? Hook - Module : 0x300u]);
companionBody?.CopyTo(moduleWords, 0x300 / 4);
for (var depth = 0; depth < tailDepth; depth++)
{
var offset = 0x40u * (uint)(depth + 1);
var branchOffset = offset;
if (conditionalWrapper && depth == 0)
{
moduleWords[offset / 4] = 0x2C030000u; // cmpwi r3,0
moduleWords[offset / 4 + 1] = 0x4D820020u; // beqlr
branchOffset += 8;
}
// Explicit Kamek branch targets make every helper a known function boundary.
commands.Add(((uint)KamekCommandId.Branch << 24) | branchOffset);
commands.Add(offset + 0x40);
}
var code = Words(moduleWords);
var commandBytes = Words(commands.ToArray());
var pul = new byte[KamekChunk.HeaderSize + code.Length + commandBytes.Length];
Write(pul, 0, KamekChunk.Magic0);
Write(pul, 4, KamekChunk.Magic1);
Write(pul, 12, (uint)code.Length);
Write(pul, 24, (uint)pul.Length);
code.CopyTo(pul, KamekChunk.HeaderSize);
commandBytes.CopyTo(pul, KamekChunk.HeaderSize + code.Length);
var pulPath = Path.Combine(root, "Code.pul");
File.WriteAllBytes(pulPath, pul);
var output = Path.Combine(root, "mod");
string[] args = ["translate-mod", "--project", projectPath, "--code-pul", pulPath,
"--base-manifest", manifestPath, "--out", output, "--module-guest-base", $"0x{Module:X8}",
"--module-link-base", $"0x{Module:X8}", "--skip-retro-wfc", "--emit-cpp", "--threads", "1"];
var entryPoint = typeof(TranslationProjectConfig).Assembly.EntryPoint!;
var exitCode = Assert.IsType<int>(entryPoint.Invoke(null, [args]));
Assert.Equal(0, exitCode);
return TranslationSourceBundle.Read(Path.Combine(output, "translated_sources.bin"));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
private static byte[] Words(uint[] words)
{
var bytes = new byte[words.Length * 4];
for (var index = 0; index < words.Length; index++) Write(bytes, index * 4, words[index]);
return bytes;
}
private static void Write(byte[] bytes, int offset, uint value) =>
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(offset, 4), value);
}
@@ -0,0 +1,125 @@
using Translator.Core.Disassembly;
using Translator.Core.Mods;
namespace Translator.Tests;
public sealed class LrContinuationAnalysisTests
{
private const uint Entry = 0x80010000;
private const uint Tail = Entry - 0x40;
[Fact]
public void TailHelperUsesTheWrappersLrState()
{
var result = Analyze(new Dictionary<uint, uint[]>
{
[Entry] = [0x7D8802A6, 0x398C0014, 0x4BFFFFB8], // mflr; addi +20; b Tail
[Tail] = [0x7D8803A6, 0x4E800020] // mtlr r12; blr
});
Assert.True(result.IsComplete);
Assert.Equal(new[] { 20 }, result.Offsets);
}
[Fact]
public void TailHelperUsesTheWrappersStackFrame()
{
var result = Analyze(new Dictionary<uint, uint[]>
{
[Entry] = [0x7D8802A6, 0x9421FFF0, 0x91810014, 0x4BFFFFB4],
[Tail] = [0x38210010, 0x81810004, 0x398C0014, 0x7D8803A6, 0x4E800020]
});
Assert.True(result.IsComplete);
Assert.Equal(new[] { 20 }, result.Offsets);
}
[Fact]
public void OrdinaryTailCycleTerminatesWithoutInventingOffsets()
{
var result = Analyze(new Dictionary<uint, uint[]>
{
[Entry] = [0x4BFFFFC0],
[Tail] = [0x48000040]
});
Assert.True(result.IsComplete);
Assert.False(result.MaySkipReturn);
}
[Fact]
public void AdjustingTailCycleReportsIncompleteAnalysis()
{
var result = Analyze(new Dictionary<uint, uint[]>
{
[Entry] = [0x7D8802A6, 0x4800003C],
[Entry + 0x40] = [0x398C0004, 0x4BFFFFFC]
});
Assert.False(result.IsComplete);
Assert.True(result.WasTruncated);
Assert.True(result.MaySkipReturn);
}
[Fact]
public void UndecodableTailDoesNotProveAnOrdinaryReturn()
{
var result = Analyze(new Dictionary<uint, uint[]> { [Entry] = [0x4BFFFFC0] });
Assert.False(result.IsComplete);
Assert.True(result.WasTruncated);
Assert.True(result.MaySkipReturn);
}
[Fact]
public void NormalCallDoesNotInheritItsCalleesSkipOffset()
{
var visited = new List<uint>();
var result = LrContinuationAnalysis.Analyze(Entry, address =>
{
visited.Add(address);
return Decode(address, [0x7FE802A6, 0x4BFFFFBD, 0x7FE803A6, 0x4E800020]);
});
Assert.Equal(new[] { Entry }, visited);
Assert.True(result.IsComplete);
Assert.False(result.MaySkipReturn);
}
[Fact]
public void UnknownIndirectTailIsIncomplete()
{
var result = Analyze(new Dictionary<uint, uint[]> { [Entry] = [0x7D8903A6, 0x4E800420] });
Assert.False(result.IsComplete);
Assert.False(result.WasTruncated);
Assert.True(result.MaySkipReturn);
}
[Fact]
public void ConditionalCallDoesNotTraverseATargetAlsoUsedByATailBranch()
{
var result = Analyze(new Dictionary<uint, uint[]>
{
[Entry] = [0x7FE802A6, 0x4182FFBD, 0x3BE00000, 0x4BFFFFB4],
[Tail] = [0x3BFF0014, 0x7FE803A6, 0x4E800020]
});
Assert.Empty(result.Offsets);
}
[Fact]
public void TailDiscoveryBudgetDoesNotProveAnOrdinaryReturn()
{
var calls = 0;
var result = LrContinuationAnalysis.Analyze(Entry, address =>
{
calls++;
return Decode(address, [0x48000040]);
});
Assert.InRange(calls, 1, 256);
Assert.False(result.IsComplete);
Assert.True(result.WasTruncated);
Assert.True(result.MaySkipReturn);
}
private static LrContinuationAnalysis Analyze(Dictionary<uint, uint[]> functions) =>
LrContinuationAnalysis.Analyze(Entry, address => functions.TryGetValue(address, out var words)
? Decode(address, words)
: throw new InvalidOperationException("No synthetic function at this address."));
private static PpcInstruction[] Decode(uint address, uint[] words) =>
words.Select((word, index) => PpcDecoder.Decode(address + (uint)index * 4, word)).ToArray();
}
@@ -0,0 +1,78 @@
using Translator.Core.Disassembly;
using Translator.Core.Mods;
namespace Translator.Tests;
public sealed class LrRelativeAnalysisCompletenessTests
{
[Fact]
public void EmptyResultReportsWhenPathStatesWereDropped()
{
var capped = false;
var offsets = Analyze(SyntheticLrHookFactory.ManyOrdinaryReturnPaths(), () => capped = true);
Assert.Empty(offsets);
Assert.True(capped, "An empty result from capped exploration must not prove an ordinary return.");
}
[Theory]
[InlineData(0)]
[InlineData(20)]
public void ExhaustiveAnalysisDoesNotReportAStateCap(int offset)
{
var capped = false;
var offsets = Analyze(
[
0x7D8802A6u, // mflr r12
0x398C0000u | (ushort)offset, // addi r12,r12,offset
0x7D8803A6u, // mtlr r12
0x4E800020u // blr
], () => capped = true);
Assert.False(capped);
Assert.Equal(offset == 0 ? Array.Empty<int>() : [offset], offsets);
}
[Fact]
public void CappedArmDoesNotDiscardAnOffsetFoundOnAnotherArm()
{
var capped = false;
var offsets = Analyze(
[
0x7D8802A6u, // +00: mflr r12
0x2C030000u, // +04: cmpwi r3,0
0x41820010u, // +08: beq +0x18
0x398C0014u, // +0C: addi r12,r12,20
0x7D8803A6u, // +10: mtlr r12
0x4E800020u, // +14: blr
.. SyntheticLrHookFactory.ManyOrdinaryReturnPaths() // +18
], () => capped = true);
Assert.True(capped);
Assert.Equal(new[] { 20 }, offsets);
}
private static int[] Analyze(uint[] words, Action onStateCapExceeded)
{
var instructions = words.Select((word, index) =>
PpcDecoder.Decode(0x80010000u + (uint)index * 4, word)).ToArray();
return ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions, onStateCapExceeded).ToArray();
}
}
internal static class SyntheticLrHookFactory
{
public static uint[] ManyOrdinaryReturnPaths()
{
// 1,024 possible states exceed the 512-state cap without an unbounded loop.
var words = new List<uint> { 0x7FE802A6u }; // mflr r31
for (uint register = 3; register <= 12; register++)
{
words.Add(0x2C000000u | (register << 16)); // cmpwi rN,0
words.Add(0x41820008u); // beq +8
words.Add(0x38000004u | (register << 21) | (31u << 16)); // addi rN,r31,4
}
words.Add(0x4E800020u); // blr with unchanged LR
return words.ToArray();
}
}