From a5e4a6a8b8d3486aa1a230146882cb70bf0e66bb Mon Sep 17 00:00:00 2001 From: Shane Michael Mathews Date: Mon, 6 Jul 2026 21:59:40 -0400 Subject: [PATCH] feat(recomp): discover packed jal-only entries via cross-unit manifests and thread-entry scan A callee reached only by a jal/j immediate from a caller outside the byte range grouped into one function unit is disassembled as unreachable tail code inside whichever unit contains it, but gets no callable entry of its own. At runtime lookupFunction() reports it not-found and the recovery path silently substitutes broken behavior instead of failing loudly. The existing post-pass discoverAdditionalEntryPoints() already handles same-invocation cross-function targets via resume mapping; two forms survived. Part A - cross-compilation-unit / overlay callers: each invocation now emits external_call_targets.txt (jal/j targets in an executable section but outside its own recompiled ranges) into the output directory, and a new [general] external_call_target_manifests config array ingests manifests produced by other invocations. Ingested targets that land inside this invocation's function ranges are registered through the existing resume-entry mapping (m_resumeEntryTargetsByOwner), so lookupFunction(target) dispatches into the owner unit at the right pc. The emit-selection logic is a public static (CollectExternalCallTargets) for unit testing. Part B - data-embedded thread entries: a new analysis pass locates CreateThread (syscall 0x20) call sites (direct syscall or jal to a libkernel wrapper), recovers the static ThreadParam pointer in $a0 via a bounded lui/addiu/ori constant walk (including the jal delay slot), reads the entry function pointer from ELF data at param+4, and registers entries that fall inside recompiled function ranges via the same resume mapping. Scope is CreateThread-with-immediate-ThreadParam only: StartThread (0x22), runtime-computed param pointers, and handler-registration variants are not covered. Both discovery paths feed the existing registration machinery; no runtime or emitter behavior changes. Includes unit tests for manifest parsing, the external-target selection helper, and the thread-entry discovery helper, plus end-to-end regression tests that drive the real recompile pipeline and assert registration through the emitted function table. --- README.md | 1 + ps2xRecomp/include/ps2recomp/ps2_recompiler.h | 26 + ps2xRecomp/include/ps2recomp/types.h | 1 + ps2xRecomp/src/lib/config_manager.cpp | 15 + ps2xRecomp/src/lib/ps2_recompiler.cpp | 475 +++++++++ ps2xTest/src/ps2_recompiler_tests.cpp | 972 +++++++++++++++++- 6 files changed, 1488 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b9d3ac840..7022aa7cd 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ Main fields in `config.toml`: * `general.patch_cache`: apply configured patches to CACHE instructions. * `general.stubs`: names to force as stubs. Also accepts `handler@0xADDRESS` to bind a stripped function address directly to a runtime syscall/stub handler. Includes generic handlers `ret0`, `ret1`, `reta0`. * `general.skip`: names to force as skipped wrappers. +* `general.external_call_target_manifests`: optional array of `external_call_targets.txt` paths emitted by other recompile invocations (e.g. a separately recompiled overlay); their call targets that land inside this invocation's functions are registered as additional entry points. Each invocation writes its own `external_call_targets.txt` into `general.output`. * `patches.instructions`: raw instruction replacements by address. Address binding for stripped ELFs: diff --git a/ps2xRecomp/include/ps2recomp/ps2_recompiler.h b/ps2xRecomp/include/ps2recomp/ps2_recompiler.h index 4ffeaa0d1..97fc4b64f 100644 --- a/ps2xRecomp/include/ps2recomp/ps2_recompiler.h +++ b/ps2xRecomp/include/ps2recomp/ps2_recompiler.h @@ -11,6 +11,8 @@ #include #include #include +#include +#include namespace ps2recomp { @@ -46,6 +48,27 @@ namespace ps2recomp static std::string ClampFilenameLength(const std::string& baseName, const std::string& extension, std::size_t maxLength); + // Parses a call-target manifest (one "0x%08x" address per line, blank lines and + // '#' comments ignored) into a sorted, de-duplicated list of addresses. Shared by + // the production manifest loader and unit tests. + static std::vector ParseCallTargetManifest(std::istream &input); + + // Discovers thread entry function pointers embedded in static ThreadParam + // structs passed to the CreateThread syscall (0x20). Returns the entry-pointer + // values read from data memory, sorted and de-duplicated. + static std::vector DiscoverDataEmbeddedThreadEntries( + const std::unordered_map> &decodedFunctions, + const std::function &isValidAddress, + const std::function &readWord); + + // Collects jal/j targets that fall in executable sections but outside every + // recompiled local function range - candidate cross-unit call targets to + // publish in the external call-target manifest. Sorted and de-duplicated. + static std::vector CollectExternalCallTargets( + const std::unordered_map> &decodedFunctions, + const std::vector &functions, + const std::vector
§ions); + private: ConfigManager m_configManager; std::unique_ptr m_elfParser; @@ -68,10 +91,13 @@ namespace ps2recomp std::map m_generatedStubs; std::unordered_map m_functionRenames; std::unordered_map> m_resumeEntryTargetsByOwner; + std::vector m_ingestedExternalCallTargets; CodeGenerator::BootstrapInfo m_bootstrapInfo; bool decodeFunction(Function &function); void discoverAdditionalEntryPoints(); + void loadExternalCallTargetManifests(); + void emitExternalCallTargetManifest(); bool shouldSkipFunction(const Function &function) const; bool isStubFunction(const Function &function) const; bool generateFunctionHeader(); diff --git a/ps2xRecomp/include/ps2recomp/types.h b/ps2xRecomp/include/ps2recomp/types.h index 7e03280bd..b46bf5aa6 100644 --- a/ps2xRecomp/include/ps2recomp/types.h +++ b/ps2xRecomp/include/ps2recomp/types.h @@ -183,6 +183,7 @@ namespace ps2recomp std::vector stubImplementations; std::unordered_map mmioByInstructionAddress; std::vector jumpTables; + std::vector externalCallTargetManifests; }; } // namespace ps2recomp diff --git a/ps2xRecomp/src/lib/config_manager.cpp b/ps2xRecomp/src/lib/config_manager.cpp index b6f382d98..b5c2c9983 100644 --- a/ps2xRecomp/src/lib/config_manager.cpp +++ b/ps2xRecomp/src/lib/config_manager.cpp @@ -83,6 +83,17 @@ namespace ps2recomp config.skipFunctions = toml::find>(data, "skip"); } + if (general.contains("external_call_target_manifests") && general.at("external_call_target_manifests").is_array()) + { + config.externalCallTargetManifests = + toml::find>(general, "external_call_target_manifests"); + } + else if (data.contains("external_call_target_manifests") && data.at("external_call_target_manifests").is_array()) + { + config.externalCallTargetManifests = + toml::find>(data, "external_call_target_manifests"); + } + if (data.contains("patches") && data.at("patches").is_table()) { const auto &patches = toml::find(data, "patches"); @@ -276,6 +287,10 @@ namespace ps2recomp general["patch_cache"] = config.patchCache; general["skip"] = config.skipFunctions; general["stubs"] = config.stubImplementations; + if (!config.externalCallTargetManifests.empty()) + { + general["external_call_target_manifests"] = config.externalCallTargetManifests; + } data["general"] = general; if (!config.mmioByInstructionAddress.empty()) diff --git a/ps2xRecomp/src/lib/ps2_recompiler.cpp b/ps2xRecomp/src/lib/ps2_recompiler.cpp index df7e356da..44725dfc9 100644 --- a/ps2xRecomp/src/lib/ps2_recompiler.cpp +++ b/ps2xRecomp/src/lib/ps2_recompiler.cpp @@ -3,10 +3,12 @@ #include "ps2recomp/types.h" #include "ps2recomp/elf_parser.h" #include "ps2recomp/r5900_decoder.h" +#include "ps2recomp/control_flow_utils.h" #include "ps2_runtime_calls.h" #include #include #include +#include #include #include #include @@ -982,6 +984,7 @@ namespace ps2recomp #endif } + loadExternalCallTargetManifests(); discoverAdditionalEntryPoints(); if (failedCount > 0) @@ -1714,6 +1717,125 @@ namespace ps2recomp } } + void PS2Recompiler::loadExternalCallTargetManifests() + { + m_ingestedExternalCallTargets.clear(); + + std::vector merged; + for (const auto &manifestPath : m_config.externalCallTargetManifests) + { + std::ifstream manifestFile(manifestPath); + if (!manifestFile) + { + m_reporter.warning("external-call-targets", "Failed to open manifest for reading: " + manifestPath); + continue; + } + + const std::vector parsed = ParseCallTargetManifest(manifestFile); + merged.insert(merged.end(), parsed.begin(), parsed.end()); + + std::ostringstream msg; + msg << "ingested " << parsed.size() << " external call target(s) from " << manifestPath; + m_reporter.progress(msg.str()); + } + + std::sort(merged.begin(), merged.end()); + merged.erase(std::unique(merged.begin(), merged.end()), merged.end()); + m_ingestedExternalCallTargets = std::move(merged); + } + + std::vector PS2Recompiler::CollectExternalCallTargets( + const std::unordered_map> &decodedFunctions, + const std::vector &functions, + const std::vector
§ions) + { + std::vector externalTargets; + + auto isInsideExecutableSection = [&](uint32_t address) -> bool + { + for (const auto §ion : sections) + { + if (!section.isCode) + { + continue; + } + + if (address >= section.address && address < section.address + section.size) + { + return true; + } + } + return false; + }; + + auto isInsideRecompiledFunction = [&](uint32_t address) -> bool + { + for (const auto &function : functions) + { + if (!function.isRecompiled || function.isStub || function.isSkipped) + { + continue; + } + + if (address >= function.start && address < function.end) + { + return true; + } + } + return false; + }; + + for (const auto &[functionStart, instructions] : decodedFunctions) + { + for (const auto &inst : instructions) + { + if (inst.opcode != OPCODE_JAL && inst.opcode != OPCODE_J) + { + continue; + } + + const uint32_t target = buildAbsoluteJumpTarget(inst.address, inst.target); + if (!isInsideExecutableSection(target)) + { + continue; + } + + if (isInsideRecompiledFunction(target)) + { + continue; + } + + externalTargets.push_back(target); + } + } + + std::sort(externalTargets.begin(), externalTargets.end()); + externalTargets.erase(std::unique(externalTargets.begin(), externalTargets.end()), externalTargets.end()); + + return externalTargets; + } + + void PS2Recompiler::emitExternalCallTargetManifest() + { + const std::vector externalTargets = + CollectExternalCallTargets(m_decodedFunctions, m_functions, m_sections); + + std::ostringstream ss; + for (uint32_t target : externalTargets) + { + ss << "0x" << std::hex << std::setw(8) << std::setfill('0') << target << std::dec << "\n"; + } + + const fs::path manifestPath = fs::path(m_config.outputPath) / "external_call_targets.txt"; + writeToFile(manifestPath.string(), ss.str()); + + { + std::ostringstream msg; + msg << "wrote " << externalTargets.size() << " candidate cross-unit call target(s) to " << manifestPath; + m_reporter.progress(msg.str()); + } + } + void PS2Recompiler::discoverAdditionalEntryPoints() { m_resumeEntryTargetsByOwner.clear(); @@ -1813,6 +1935,64 @@ namespace ps2recomp } } + for (uint32_t target : m_ingestedExternalCallTargets) + { + const Function *owner = findContainingFunction(target); + if (!owner) + { + continue; + } + + if (owner->start == target) + { + continue; + } + + auto &targets = m_resumeEntryTargetsByOwner[owner->start]; + targets.push_back(target); + } + + if (m_elfParser) + { + try + { + const std::vector threadEntries = DiscoverDataEmbeddedThreadEntries( + m_decodedFunctions, + [this](uint32_t address) { return m_elfParser->isValidAddress(address); }, + [this](uint32_t address) { return m_elfParser->readWord(address); }); + + size_t mappedThreadEntries = 0u; + for (uint32_t target : threadEntries) + { + const Function *owner = findContainingFunction(target); + if (!owner) + { + continue; + } + + if (owner->start == target) + { + continue; + } + + auto &targets = m_resumeEntryTargetsByOwner[owner->start]; + targets.push_back(target); + ++mappedThreadEntries; + } + + if (mappedThreadEntries > 0u) + { + std::ostringstream msg; + msg << "mapped " << mappedThreadEntries << " data-embedded thread entry point(s)"; + m_reporter.progress(msg.str()); + } + } + catch (const std::exception &ex) + { + m_reporter.warning("thread-entries", std::string("failed to discover data-embedded thread entries: ") + ex.what()); + } + } + size_t totalTargets = 0u; for (auto it = m_resumeEntryTargetsByOwner.begin(); it != m_resumeEntryTargetsByOwner.end();) { @@ -1841,6 +2021,8 @@ namespace ps2recomp << " owner function(s)"; m_reporter.progress(msg.str()); } + + emitExternalCallTargetManifest(); } bool PS2Recompiler::decodeFunction(Function &function) @@ -2120,4 +2302,297 @@ namespace ps2recomp { return clampFilenameLength(baseName, extension, maxLength); } + + std::vector PS2Recompiler::ParseCallTargetManifest(std::istream &input) + { + std::vector targets; + std::string line; + + while (std::getline(input, line)) + { + const size_t firstNonSpace = line.find_first_not_of(" \t\r\n"); + if (firstNonSpace == std::string::npos) + { + continue; + } + + if (line[firstNonSpace] == '#') + { + continue; + } + + try + { + uint32_t target = static_cast(std::stoul(line.substr(firstNonSpace), nullptr, 0)); + targets.push_back(target); + } + catch (const std::exception &) + { + continue; + } + } + + std::sort(targets.begin(), targets.end()); + targets.erase(std::unique(targets.begin(), targets.end()), targets.end()); + return targets; + } + + namespace + { + constexpr uint32_t kSyscallCreateThreadNumber = 0x20u; + constexpr uint32_t kThreadEntryRegV1 = 3u; + constexpr uint32_t kThreadEntryRegA0 = 4u; + constexpr size_t kThreadWrapperScanWindow = 8u; + constexpr size_t kThreadSyscallBackScanWindow = 8u; + constexpr size_t kThreadConstantScanWindow = 12u; + + bool isAddiuV1SyscallImm(const Instruction &inst) + { + return inst.opcode == OPCODE_ADDIU && inst.rt == kThreadEntryRegV1 && inst.rs == 0u && + (inst.immediate & 0xFFFFu) == kSyscallCreateThreadNumber; + } + + bool isSyscallInstruction(const Instruction &inst) + { + return inst.opcode == OPCODE_SPECIAL && inst.function == SPECIAL_SYSCALL; + } + + // Simplified "who writes this register" model shared by the $v1 clobber check + // and the $a0 constant-propagation walk below: for I-type opcodes the written + // register is rt, for OPCODE_SPECIAL it is rd. $zero is never considered written. + // J-type instructions (j/jal) are excluded: the decoder unconditionally populates + // rs/rt/rd from the raw instruction bits (see R5900Decoder::decodeInstruction), + // but for a 26-bit jump target those bit positions are part of the target, not a + // register field, so treating them as a register write would be spurious. + uint32_t writtenRegisterOrZero(const Instruction &inst) + { + if (inst.opcode == OPCODE_J || inst.opcode == OPCODE_JAL) + { + return 0u; + } + if (inst.opcode == OPCODE_SPECIAL) + { + return inst.rd; + } + return inst.rt; + } + + bool writesTrackedRegister(const Instruction &inst, uint32_t reg) + { + if (reg == 0u) + { + return false; + } + return writtenRegisterOrZero(inst) == reg; + } + + struct ThreadCreateCallSite + { + const std::vector *instructions; + size_t index; + bool isJal; + }; + } + + std::vector PS2Recompiler::DiscoverDataEmbeddedThreadEntries( + const std::unordered_map> &decodedFunctions, + const std::function &isValidAddress, + const std::function &readWord) + { + std::vector results; + + // Step 1: identify CreateThread (syscall 0x20) wrapper function entry points - + // functions whose first few instructions set $v1 = 0x20 and execute a syscall. + std::unordered_set wrapperStarts; + for (const auto &[functionStart, instructions] : decodedFunctions) + { + const size_t window = std::min(instructions.size(), kThreadWrapperScanWindow); + bool sawAddiuV1Syscall = false; + bool isWrapper = false; + for (size_t idx = 0; idx < window; ++idx) + { + const Instruction &inst = instructions[idx]; + if (isAddiuV1SyscallImm(inst)) + { + sawAddiuV1Syscall = true; + continue; + } + if (sawAddiuV1Syscall && isSyscallInstruction(inst)) + { + isWrapper = true; + break; + } + } + + if (isWrapper) + { + wrapperStarts.insert(functionStart); + } + } + + // Step 2: find CreateThread invocation sites - either a jal to a wrapper found + // above, or a direct inline syscall preceded (within a small window, with no + // intervening write to $v1) by addiu $v1, $zero, 0x20. + std::vector callSites; + for (const auto &[functionStart, instructions] : decodedFunctions) + { + (void)functionStart; + for (size_t idx = 0; idx < instructions.size(); ++idx) + { + const Instruction &inst = instructions[idx]; + + if (inst.opcode == OPCODE_JAL) + { + const uint32_t target = buildAbsoluteJumpTarget(inst.address, inst.target); + if (wrapperStarts.count(target) != 0u) + { + callSites.push_back(ThreadCreateCallSite{&instructions, idx, true}); + } + continue; + } + + if (!isSyscallInstruction(inst)) + { + continue; + } + + // Note: this back-scan can double-attribute a single `addiu $v1,$zero,0x20` + // write to two syscall instructions if both sit within the scan window with no + // intervening $v1 clobber. That is harmless here: the worst case is one extra + // in-range candidate entry, which is de-duplicated downstream (results are sorted + // and uniqued, and the resume-target map dedups per owner), so no logic change. + const size_t lowerBound = (idx >= kThreadSyscallBackScanWindow) ? idx - kThreadSyscallBackScanWindow : 0u; + bool foundAddiuV1 = false; + for (size_t p = idx; p > lowerBound;) + { + --p; + const Instruction &prior = instructions[p]; + if (isAddiuV1SyscallImm(prior)) + { + foundAddiuV1 = true; + break; + } + if (writesTrackedRegister(prior, kThreadEntryRegV1)) + { + break; + } + } + + if (foundAddiuV1) + { + callSites.push_back(ThreadCreateCallSite{&instructions, idx, false}); + } + } + } + + // Step 3 + 4: recover the static $a0 (ThreadParam*) value at each call site by + // walking a small constant-propagation window forward, then read the embedded + // entry function pointer (word offset +4 in the ThreadParam struct) from ELF data. + for (const ThreadCreateCallSite &site : callSites) + { + const std::vector &instructions = *site.instructions; + + size_t windowEnd; + if (site.isJal) + { + windowEnd = (site.index + 1u < instructions.size()) ? site.index + 1u : site.index; + } + else + { + if (site.index == 0u) + { + continue; + } + windowEnd = site.index - 1u; + } + const size_t windowStart = + (site.index >= kThreadConstantScanWindow) ? site.index - kThreadConstantScanWindow : 0u; + + std::unordered_map resolved; + auto invalidate = [&resolved](uint32_t reg) + { + if (reg != 0u) + { + resolved.erase(reg); + } + }; + + for (size_t idx = windowStart; idx <= windowEnd; ++idx) + { + const Instruction &inst = instructions[idx]; + + if (inst.opcode == OPCODE_LUI) + { + if (inst.rt != 0u) + { + resolved[inst.rt] = (inst.immediate & 0xFFFFu) << 16; + } + continue; + } + + if (inst.opcode == OPCODE_ADDIU) + { + auto srcIt = resolved.find(inst.rs); + if (inst.rs != 0u && srcIt != resolved.end()) + { + const int32_t lo = static_cast(static_cast(inst.immediate & 0xFFFFu)); + if (inst.rt != 0u) + { + resolved[inst.rt] = static_cast(static_cast(srcIt->second) + lo); + } + continue; + } + } + else if (inst.opcode == OPCODE_ORI) + { + auto srcIt = resolved.find(inst.rs); + if (inst.rs != 0u && srcIt != resolved.end()) + { + const uint32_t lo = inst.immediate & 0xFFFFu; + if (inst.rt != 0u) + { + resolved[inst.rt] = srcIt->second | lo; + } + continue; + } + } + + invalidate(writtenRegisterOrZero(inst)); + } + + auto a0It = resolved.find(kThreadEntryRegA0); + if (a0It == resolved.end()) + { + continue; + } + + const uint32_t paramAddress = a0It->second; + if (!isValidAddress(paramAddress) || !isValidAddress(paramAddress + 4u)) + { + continue; + } + + uint32_t entry = 0u; + try + { + entry = readWord(paramAddress + 4u); + } + catch (const std::exception &) + { + // A section can satisfy isValidAddress's byte-range check yet still have + // no backing data (e.g. BSS), in which case readWord throws. Skip this + // candidate rather than losing the whole discovery pass. + continue; + } + + if (entry != 0u) + { + results.push_back(entry); + } + } + + std::sort(results.begin(), results.end()); + results.erase(std::unique(results.begin(), results.end()), results.end()); + return results; + } } diff --git a/ps2xTest/src/ps2_recompiler_tests.cpp b/ps2xTest/src/ps2_recompiler_tests.cpp index 3980bde44..9669a8436 100644 --- a/ps2xTest/src/ps2_recompiler_tests.cpp +++ b/ps2xTest/src/ps2_recompiler_tests.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -37,6 +38,28 @@ static Instruction makeAbsJump(uint32_t address, uint32_t target, uint32_t opcod return inst; } +// Mirrors R5900Decoder::decodeInstruction, which unconditionally populates +// rs/rt/rd/immediate from the raw instruction bits regardless of instruction +// format. For a J-type instruction (j/jal) those bit positions are actually +// part of the 26-bit jump target, not real register fields, so a naive "does +// this instruction write rt" check can alias against the jal's own encoded +// target. Used to reproduce that scenario in tests. +static Instruction makeAbsJumpDecoded(uint32_t address, uint32_t target, uint32_t opcode) +{ + Instruction inst{}; + inst.address = address; + inst.opcode = opcode; + inst.target = (target >> 2) & 0x03FFFFFFu; + inst.hasDelaySlot = true; + inst.raw = (opcode << 26) | inst.target; + inst.rs = RS(inst.raw); + inst.rt = RT(inst.raw); + inst.rd = RD(inst.raw); + inst.immediate = IMMEDIATE(inst.raw); + inst.simmediate = static_cast(SIMMEDIATE(inst.raw)); + return inst; +} + static Instruction makeJrRa(uint32_t address) { Instruction inst{}; @@ -49,6 +72,64 @@ static Instruction makeJrRa(uint32_t address) return inst; } +static Instruction makeLui(uint32_t address, uint32_t rt, uint32_t imm) +{ + Instruction inst{}; + inst.address = address; + inst.opcode = OPCODE_LUI; + inst.rt = rt; + inst.immediate = imm & 0xFFFFu; + inst.raw = (OPCODE_LUI << 26) | (rt << 16) | (imm & 0xFFFFu); + return inst; +} + +static Instruction makeAddiu(uint32_t address, uint32_t rt, uint32_t rs, uint32_t imm) +{ + Instruction inst{}; + inst.address = address; + inst.opcode = OPCODE_ADDIU; + inst.rt = rt; + inst.rs = rs; + inst.immediate = imm & 0xFFFFu; + inst.simmediate = static_cast(static_cast(static_cast(imm & 0xFFFFu))); + inst.raw = (OPCODE_ADDIU << 26) | (rs << 21) | (rt << 16) | (imm & 0xFFFFu); + return inst; +} + +static Instruction makeOri(uint32_t address, uint32_t rt, uint32_t rs, uint32_t imm) +{ + Instruction inst{}; + inst.address = address; + inst.opcode = OPCODE_ORI; + inst.rt = rt; + inst.rs = rs; + inst.immediate = imm & 0xFFFFu; + inst.raw = (OPCODE_ORI << 26) | (rs << 21) | (rt << 16) | (imm & 0xFFFFu); + return inst; +} + +static Instruction makeSyscall(uint32_t address) +{ + Instruction inst{}; + inst.address = address; + inst.opcode = OPCODE_SPECIAL; + inst.function = SPECIAL_SYSCALL; + inst.raw = SPECIAL_SYSCALL; + return inst; +} + +static Instruction makeLw(uint32_t address, uint32_t rt, uint32_t rs) +{ + Instruction inst{}; + inst.address = address; + inst.opcode = OPCODE_LW; + inst.rt = rt; + inst.rs = rs; + inst.isLoad = true; + inst.raw = (OPCODE_LW << 26) | (rs << 21) | (rt << 16); + return inst; +} + static Function makeFunction(const std::string &name, uint32_t start, uint32_t end) { Function fn{}; @@ -155,6 +236,201 @@ static bool writeMinimalMipsElfWithJalFallbackTarget(const std::filesystem::path return writer.save(elfPath.string()); } +// Builds a fixture ELF containing a single "container_fn" function +// [containerStart, containerEnd) that is NOP-filled and ends in jr $ra + delay-slot nop. +// No other code exists anywhere in the ELF, so no jal/j instruction anywhere can ever +// target a mid-body address of this function - the only way a mid-body address can be +// discovered as an entry point is via an ingested external-call-target manifest. The +// ELF entry point is set to containerStart, so bootstrap registration only covers the +// function head. +static bool writeContainerOnlyElf(const std::filesystem::path &elfPath, + uint32_t containerStart, + uint32_t containerEnd) +{ + ELFIO::elfio writer; + writer.create(ELFIO::ELFCLASS32, ELFIO::ELFDATA2LSB); + writer.set_os_abi(ELFIO::ELFOSABI_NONE); + writer.set_type(ELFIO::ET_EXEC); + writer.set_machine(ELFIO::EM_MIPS); + writer.set_entry(containerStart); + + ELFIO::section *text = writer.sections.add(".text"); + text->set_type(ELFIO::SHT_PROGBITS); + text->set_flags(ELFIO::SHF_ALLOC | ELFIO::SHF_EXECINSTR); + text->set_addr_align(4); + text->set_address(containerStart); + + const uint32_t size = containerEnd - containerStart; + const size_t wordCount = size / sizeof(uint32_t); + if (wordCount < 2) + { + return false; + } + std::vector textWords(wordCount, 0x00000000u); // NOP-fill the whole body + textWords[wordCount - 2] = 0x03E00008u; // jr $ra + textWords[wordCount - 1] = 0x00000000u; // nop (delay slot) + text->set_data(reinterpret_cast(textWords.data()), + static_cast(textWords.size() * sizeof(uint32_t))); + + ELFIO::section *strtab = writer.sections.add(".strtab"); + strtab->set_type(ELFIO::SHT_STRTAB); + strtab->set_addr_align(1); + + ELFIO::section *symtab = writer.sections.add(".symtab"); + symtab->set_type(ELFIO::SHT_SYMTAB); + symtab->set_info(1); + symtab->set_link(strtab->get_index()); + symtab->set_addr_align(4); + symtab->set_entry_size(writer.get_default_entry_size(ELFIO::SHT_SYMTAB)); + + ELFIO::symbol_section_accessor symbols(writer, symtab); + ELFIO::string_section_accessor strings(strtab); + symbols.add_symbol(strings, "", 0, 0, ELFIO::STB_LOCAL, ELFIO::STT_NOTYPE, 0, ELFIO::SHN_UNDEF); + symbols.add_symbol(strings, "container_fn", containerStart, size, + ELFIO::STB_GLOBAL, ELFIO::STT_FUNC, 0, text->get_index()); + + ELFIO::segment *textSegment = writer.segments.add(); + textSegment->set_type(ELFIO::PT_LOAD); + textSegment->set_flags(ELFIO::PF_R | ELFIO::PF_X); + textSegment->set_align(0x1000); + textSegment->add_section_index(text->get_index(), text->get_addr_align()); + + return writer.save(elfPath.string()); +} + +// Builds a fixture ELF exercising the data-embedded thread-entry discovery path through +// real ELF/.data plumbing: +// caller @ 0x00100000: nop; lui $a0,hi(P); jal CreateThread; addiu $a0,$a0,lo(P) (delay +// slot); jr $ra; nop +// CreateThread @ 0x00100018 (syscall 0x20 wrapper): addiu $v1,$zero,0x20; syscall; +// jr $ra; nop +// worker_container @ 0x00100028: nop; nop; nop (this is E, the thread entry pointer, +// strictly inside and not the head); nop; jr $ra; nop +// .data @ 0x00200000 (P, the ThreadParam struct): word0 = 0 (unused by this test), +// word1 (P+4) = E = 0x00100030 (PS2 ABI: entry fn ptr is the +// second word of ThreadParam). +static bool writeThreadEntryDataElf(const std::filesystem::path &elfPath) +{ + ELFIO::elfio writer; + writer.create(ELFIO::ELFCLASS32, ELFIO::ELFDATA2LSB); + writer.set_os_abi(ELFIO::ELFOSABI_NONE); + writer.set_type(ELFIO::ET_EXEC); + writer.set_machine(ELFIO::EM_MIPS); + writer.set_entry(0x00100000u); + + ELFIO::section *text = writer.sections.add(".text"); + text->set_type(ELFIO::SHT_PROGBITS); + text->set_flags(ELFIO::SHF_ALLOC | ELFIO::SHF_EXECINSTR); + text->set_addr_align(4); + text->set_address(0x00100000u); + + const std::array textWords = { + // caller @ 0x00100000 + 0x00000000u, // 0x00100000: nop + 0x3C040020u, // 0x00100004: lui $a0, 0x0020 -> $a0 = 0x00200000 (P) + 0x0C040006u, // 0x00100008: jal 0x00100018 (CreateThread) + 0x24840000u, // 0x0010000C: addiu $a0,$a0,0 (delay slot; lo(P) == 0) + 0x03E00008u, // 0x00100010: jr $ra + 0x00000000u, // 0x00100014: nop + // CreateThread @ 0x00100018 + 0x24030020u, // 0x00100018: addiu $v1,$zero,0x20 + 0x0000000Cu, // 0x0010001C: syscall + 0x03E00008u, // 0x00100020: jr $ra + 0x00000000u, // 0x00100024: nop + // worker_container @ 0x00100028 + 0x00000000u, // 0x00100028: nop + 0x00000000u, // 0x0010002C: nop + 0x00000000u, // 0x00100030: nop <- E, the data-embedded thread entry point + 0x00000000u, // 0x00100034: nop + 0x03E00008u, // 0x00100038: jr $ra + 0x00000000u // 0x0010003C: nop + }; + text->set_data(reinterpret_cast(textWords.data()), + static_cast(textWords.size() * sizeof(uint32_t))); + + ELFIO::section *data = writer.sections.add(".data"); + data->set_type(ELFIO::SHT_PROGBITS); + data->set_flags(ELFIO::SHF_ALLOC | ELFIO::SHF_WRITE); + data->set_addr_align(4); + data->set_address(0x00200000u); + const std::array dataWords = { + 0x00000000u, // P + 0: unused by this test + 0x00100030u // P + 4: thread entry function pointer == E + }; + data->set_data(reinterpret_cast(dataWords.data()), + static_cast(dataWords.size() * sizeof(uint32_t))); + + ELFIO::section *strtab = writer.sections.add(".strtab"); + strtab->set_type(ELFIO::SHT_STRTAB); + strtab->set_addr_align(1); + + ELFIO::section *symtab = writer.sections.add(".symtab"); + symtab->set_type(ELFIO::SHT_SYMTAB); + symtab->set_info(1); + symtab->set_link(strtab->get_index()); + symtab->set_addr_align(4); + symtab->set_entry_size(writer.get_default_entry_size(ELFIO::SHT_SYMTAB)); + + ELFIO::symbol_section_accessor symbols(writer, symtab); + ELFIO::string_section_accessor strings(strtab); + symbols.add_symbol(strings, "", 0, 0, ELFIO::STB_LOCAL, ELFIO::STT_NOTYPE, 0, ELFIO::SHN_UNDEF); + symbols.add_symbol(strings, "caller", 0x00100000u, 0x18u, + ELFIO::STB_GLOBAL, ELFIO::STT_FUNC, 0, text->get_index()); + symbols.add_symbol(strings, "CreateThread", 0x00100018u, 0x10u, + ELFIO::STB_GLOBAL, ELFIO::STT_FUNC, 0, text->get_index()); + symbols.add_symbol(strings, "worker_container", 0x00100028u, 0x18u, + ELFIO::STB_GLOBAL, ELFIO::STT_FUNC, 0, text->get_index()); + + ELFIO::segment *textSegment = writer.segments.add(); + textSegment->set_type(ELFIO::PT_LOAD); + textSegment->set_flags(ELFIO::PF_R | ELFIO::PF_X); + textSegment->set_align(0x1000); + textSegment->add_section_index(text->get_index(), text->get_addr_align()); + + ELFIO::segment *dataSegment = writer.segments.add(); + dataSegment->set_type(ELFIO::PT_LOAD); + dataSegment->set_flags(ELFIO::PF_R | ELFIO::PF_W); + dataSegment->set_align(0x1000); + dataSegment->add_section_index(data->get_index(), data->get_addr_align()); + + return writer.save(elfPath.string()); +} + +// Returns every line of `content` containing `needle` - used to inspect the emitted +// register_functions.cpp function-table initializer, whose lines look like: +// g_ps2RecompiledFunctionTable[] = ; // 0x
+static std::vector findLinesContaining(const std::string &content, const std::string &needle) +{ + std::vector matches; + std::istringstream iss(content); + std::string line; + while (std::getline(iss, line)) + { + if (line.find(needle) != std::string::npos) + { + matches.push_back(line); + } + } + return matches; +} + +// Extracts from a "g_ps2RecompiledFunctionTable[] = ; // 0x" line. +static std::string extractOwnerNameFromRegistrationLine(const std::string &line) +{ + const size_t eqPos = line.find("= "); + if (eqPos == std::string::npos) + { + return {}; + } + const size_t start = eqPos + 2; + const size_t semiPos = line.find(';', start); + if (semiPos == std::string::npos) + { + return {}; + } + return line.substr(start, semiPos - start); +} + void register_ps2_recompiler_tests() { MiniTest::Case("PS2Recompiler", [](TestCase &tc) @@ -891,11 +1167,703 @@ void register_ps2_recompiler_tests() }); tc.Run("respect max length for .cpp filenames", [](TestCase& t) { - + t.IsTrue(PS2Recompiler::ClampFilenameLength("ReallyLongFunctionNameReallyLongFunctionNameReallyLongFunctionName_0x12345678",".cpp",50).length() <= 50,"Function name must be max 50 characters"); t.IsTrue(PS2Recompiler::ClampFilenameLength("ReallyLongFunctionNameReallyLongFunctionNameReallyLongFunctionName_0x12345678", ".cpp", 50).rfind("0x12345678") != std::string::npos, "Function name must mantain the function address at the end, if present"); - + + }); + + tc.Run("external call target manifest parsing sorts, dedupes, and ignores comments/blanks", [](TestCase &t) { + std::istringstream manifest( + "0x00462df4\n" + "\n" + "# a comment line\n" + "0x001000A0\n" + "0x00462df4\n" + " \n" + "0x1000a0\n" + " # indented comment\n" + "0x00500000\n"); + + const std::vector parsed = PS2Recompiler::ParseCallTargetManifest(manifest); + + t.Equals(parsed.size(), static_cast(3), + "duplicate and case-variant addresses should collapse to unique targets"); + if (parsed.size() == 3) + { + t.Equals(parsed[0], 0x001000A0u, "targets should be sorted ascending"); + t.Equals(parsed[1], 0x00462df4u, "second target should be the mid-range address"); + t.Equals(parsed[2], 0x00500000u, "third target should be the highest address"); + } + }); + + tc.Run("external call target manifest parsing handles empty input", [](TestCase &t) { + std::istringstream manifest(""); + const std::vector parsed = PS2Recompiler::ParseCallTargetManifest(manifest); + t.Equals(parsed.size(), static_cast(0), "empty manifest should parse to an empty target list"); + }); + + // Tier 1 end-to-end regression test: drives a REAL PS2Recompiler instance through + // initialize() -> recompile() -> generateOutput() over a real ELF fixture and a real + // manifest file, and inspects the actual register_functions.cpp produced by the + // production FunctionTableEmitter. This exercises the full ingestion path - + // loadExternalCallTargetManifests() -> discoverAdditionalEntryPoints()'s + // m_ingestedExternalCallTargets -> owner mapping -> resume-target push (the code + // at ps2_recompiler.cpp around lines 1938-1953) - and nothing else in this fixture + // is capable of discovering the mid-body target, so this test fails if that code + // path is disabled or removed. + tc.Run("full pipeline: manifest-ingested packed jal-only entry registers into its owning unit", [](TestCase &t) { + const auto uniqueSuffix = std::to_string( + static_cast(std::chrono::steady_clock::now().time_since_epoch().count())); + const std::filesystem::path workDir = + std::filesystem::temp_directory_path() / ("ps2recomp-packed-jal-" + uniqueSuffix); + std::error_code mkdirError; + std::filesystem::create_directories(workDir, mkdirError); + t.IsTrue(!mkdirError, "work directory should be created"); + + const std::filesystem::path elfPath = workDir / "fixture.elf"; + constexpr uint32_t containerStart = 0x00100000u; + constexpr uint32_t containerEnd = 0x00100018u; // container_fn: 6 NOP-filled words, jr $ra tail + constexpr uint32_t midBodyTarget = 0x00100008u; // T: not the head, not any jal's target anywhere + + const bool wroteElf = writeContainerOnlyElf(elfPath, containerStart, containerEnd); + t.IsTrue(wroteElf, "container-only fixture ELF should be generated"); + if (!wroteElf) + { + std::error_code cleanupError; + std::filesystem::remove_all(workDir, cleanupError); + return; + } + + const std::filesystem::path manifestPath = workDir / "manifest.txt"; + { + std::ofstream manifestFile(manifestPath); + t.IsTrue(static_cast(manifestFile), "manifest file should be writable"); + manifestFile << "0x00100008\n"; + } + + const std::filesystem::path outWithManifest = workDir / "out_with"; + const std::filesystem::path outWithoutManifest = workDir / "out_without"; + + const std::filesystem::path configWithManifestPath = workDir / "with_manifest.toml"; + { + std::ofstream cfg(configWithManifestPath); + t.IsTrue(static_cast(cfg), "config (with manifest) should be writable"); + cfg << "[general]\n"; + // generic_string(): backslashes from path::string() on Windows are + // escape introducers inside a TOML basic string and break the parse. + cfg << "input = \"" << elfPath.generic_string() << "\"\n"; + cfg << "output = \"" << outWithManifest.generic_string() << "\"\n"; + cfg << "external_call_target_manifests = [\"" << manifestPath.generic_string() << "\"]\n"; + } + + // "Without manifest" case omits external_call_target_manifests entirely. + const std::filesystem::path configWithoutManifestPath = workDir / "without_manifest.toml"; + { + std::ofstream cfg(configWithoutManifestPath); + t.IsTrue(static_cast(cfg), "config (without manifest) should be writable"); + cfg << "[general]\n"; + cfg << "input = \"" << elfPath.generic_string() << "\"\n"; + cfg << "output = \"" << outWithoutManifest.generic_string() << "\"\n"; + } + + std::string headOwnerWithManifest; + std::string targetOwnerWithManifest; + + // --- Run WITH the manifest --- + { + PS2Recompiler recompiler(configWithManifestPath.string()); + t.IsTrue(recompiler.initialize(), "initialize() should succeed for the with-manifest run"); + t.IsTrue(recompiler.recompile(), "recompile() should succeed for the with-manifest run"); + recompiler.generateOutput(); + + const std::filesystem::path registerPath = outWithManifest / "register_functions.cpp"; + std::ifstream registerFile(registerPath); + t.IsTrue(static_cast(registerFile), + "register_functions.cpp should be written for the with-manifest run"); + std::ostringstream contentStream; + contentStream << registerFile.rdbuf(); + const std::string content = contentStream.str(); + + const auto headLines = findLinesContaining(content, "// 0x100000"); + const auto targetLines = findLinesContaining(content, "// 0x100008"); + + t.IsTrue(!headLines.empty(), "container head 0x100000 should be registered (sanity)"); + t.IsTrue(!targetLines.empty(), + "manifest-ingested target 0x100008 must be registered when the manifest is supplied - " + "this is the line that disappears if the ingestion->owner-mapping->resume-target push " + "(ps2_recompiler.cpp ~1938-1953) is disabled"); + + if (!headLines.empty()) + { + headOwnerWithManifest = extractOwnerNameFromRegistrationLine(headLines.front()); + t.IsTrue(headOwnerWithManifest.find("container_fn") != std::string::npos, + "container head should register under a container_fn-derived name"); + } + if (!targetLines.empty()) + { + targetOwnerWithManifest = extractOwnerNameFromRegistrationLine(targetLines.front()); + t.IsTrue(targetOwnerWithManifest.find("container_fn") != std::string::npos, + "0x100008 must be mapped to the container_fn owner, not left unresolved"); + } + + // Test C (boundary/owner-integrity invariant), folded in here so it shares + // this fixture and this pipeline run: the manifest-ingested entry must + // dispatch INTO the owning unit - i.e. resolve to the exact same generated + // owner name as the container's own head - rather than being sliced into a + // truncated standalone function ending at an interior label. The + // resume-mapping path (ps2_recompiler.cpp ~1938-1953) pushes into + // m_resumeEntryTargetsByOwner and creates no per-entry Function::end, so + // there is no "->end" boundary to assert here the way there is for the + // slicer path. That slicer-path End boundary is already covered by the + // existing tests "additional entries split at nearest discovered boundary" + // (~line 280) and "entry reslice trims earlier entries after late + // discovery" (~line 374). + if (!headOwnerWithManifest.empty() && !targetOwnerWithManifest.empty()) + { + t.Equals(targetOwnerWithManifest, headOwnerWithManifest, + "0x100008 must resolve to the SAME owner name as the container head, proving " + "dispatch resumes into the owning unit (which retains its full declared range) " + "rather than being sliced into a separate standalone function"); + } + } + + // --- Run WITHOUT the manifest: reproduces the original bug. Nothing else in + // this fixture can discover 0x100008 (no jal/branch anywhere targets it), so it + // must be left unregistered. + { + PS2Recompiler recompiler(configWithoutManifestPath.string()); + t.IsTrue(recompiler.initialize(), "initialize() should succeed for the without-manifest run"); + t.IsTrue(recompiler.recompile(), "recompile() should succeed for the without-manifest run"); + recompiler.generateOutput(); + + const std::filesystem::path registerPath = outWithoutManifest / "register_functions.cpp"; + std::ifstream registerFile(registerPath); + t.IsTrue(static_cast(registerFile), + "register_functions.cpp should be written for the without-manifest run"); + std::ostringstream contentStream; + contentStream << registerFile.rdbuf(); + const std::string content = contentStream.str(); + + const auto headLines = findLinesContaining(content, "// 0x100000"); + const auto targetLines = findLinesContaining(content, "// 0x100008"); + + t.IsTrue(!headLines.empty(), + "container head 0x100000 should still be registered without a manifest (sanity)"); + t.IsTrue(targetLines.empty(), + "without a manifest, 0x100008 must NOT be registered - this reproduces the original bug"); + } + + std::error_code removeError; + std::filesystem::remove_all(workDir, removeError); + }); + + // Tier 1 end-to-end regression test for data-embedded thread entry discovery: drives + // a REAL PS2Recompiler instance over a real ELF whose .data section contains a + // ThreadParam struct read via the production m_elfParser->readWord/isValidAddress + // path, proving both that the analyzer reads the pointer from real ELF data AND + // that the resulting entry gets registered into its owning unit through the full + // recompile()/generateOutput() pipeline. + tc.Run("full pipeline: data-embedded thread entry (CreateThread ThreadParam) registers into its owning unit", [](TestCase &t) { + const auto uniqueSuffix = std::to_string( + static_cast(std::chrono::steady_clock::now().time_since_epoch().count())); + const std::filesystem::path workDir = + std::filesystem::temp_directory_path() / ("ps2recomp-thread-entry-" + uniqueSuffix); + std::error_code mkdirError; + std::filesystem::create_directories(workDir, mkdirError); + t.IsTrue(!mkdirError, "work directory should be created"); + + const std::filesystem::path elfPath = workDir / "fixture.elf"; + const bool wroteElf = writeThreadEntryDataElf(elfPath); + t.IsTrue(wroteElf, "thread-entry fixture ELF should be generated"); + if (!wroteElf) + { + std::error_code cleanupError; + std::filesystem::remove_all(workDir, cleanupError); + return; + } + + const std::filesystem::path outDir = workDir / "out"; + const std::filesystem::path configPath = workDir / "config.toml"; + { + std::ofstream cfg(configPath); + t.IsTrue(static_cast(cfg), "config should be writable"); + cfg << "[general]\n"; + // generic_string(): see the manifest test above - native Windows + // separators are invalid escapes inside a TOML basic string. + cfg << "input = \"" << elfPath.generic_string() << "\"\n"; + cfg << "output = \"" << outDir.generic_string() << "\"\n"; + } + + PS2Recompiler recompiler(configPath.string()); + t.IsTrue(recompiler.initialize(), "initialize() should succeed"); + t.IsTrue(recompiler.recompile(), "recompile() should succeed"); + recompiler.generateOutput(); + + const std::filesystem::path registerPath = outDir / "register_functions.cpp"; + std::ifstream registerFile(registerPath); + t.IsTrue(static_cast(registerFile), "register_functions.cpp should be written"); + std::ostringstream contentStream; + contentStream << registerFile.rdbuf(); + const std::string content = contentStream.str(); + + const auto entryLines = findLinesContaining(content, "// 0x100030"); + t.IsTrue(!entryLines.empty(), + "data-embedded thread entry 0x100030 (read from the .data ThreadParam struct via " + "the real ELF parser) must be registered"); + if (!entryLines.empty()) + { + const std::string owner = extractOwnerNameFromRegistrationLine(entryLines.front()); + t.IsTrue(owner.find("worker_container") != std::string::npos, + "0x100030 must be mapped to the worker_container owner that actually contains it"); + } + + std::error_code removeError; + std::filesystem::remove_all(workDir, removeError); + }); + + tc.Run("collect external call targets: excludes jal into a local recompiled function, includes jal outside all local functions", [](TestCase &t) { + std::vector
sections = { + {".text", 0x1000u, 0x100000u - 0x1000u, 0u, true, false, false, true, nullptr} + }; + + std::vector functions = { + makeFunction("functionA", 0x1000u, 0x1020u), + makeFunction("functionB", 0x2000u, 0x2020u) + }; + + std::unordered_map> decodedFunctions; + decodedFunctions[0x1000u] = { + makeAbsJump(0x1000u, 0x2010u, OPCODE_JAL), + makeNopLike(0x1004u), + makeAbsJump(0x1008u, 0x50000u, OPCODE_JAL), + makeNopLike(0x100Cu) + }; + + const std::vector targets = + PS2Recompiler::CollectExternalCallTargets(decodedFunctions, functions, sections); + + t.Equals(targets.size(), static_cast(1), + "only the target outside every local function range should be collected"); + if (!targets.empty()) + { + t.Equals(targets[0], 0x50000u, + "jal into functionB's range should be excluded; jal to 0x50000 should be included"); + } + }); + + tc.Run("collect external call targets: excludes targets outside any executable section", [](TestCase &t) { + std::vector
sections = { + {".text", 0x1000u, 0x100000u - 0x1000u, 0u, true, false, false, true, nullptr} + }; + + std::vector functions = { + makeFunction("functionA", 0x1000u, 0x1020u) + }; + + std::unordered_map> decodedFunctions; + decodedFunctions[0x1000u] = { + makeAbsJump(0x1000u, 0x300000u, OPCODE_JAL), + makeNopLike(0x1004u) + }; + + const std::vector targets = + PS2Recompiler::CollectExternalCallTargets(decodedFunctions, functions, sections); + + t.Equals(targets.size(), static_cast(0), + "a jal target past the end of every code section should not be collected"); + }); + + tc.Run("collect external call targets: duplicates collapse and results are sorted", [](TestCase &t) { + std::vector
sections = { + {".text", 0x1000u, 0x100000u - 0x1000u, 0u, true, false, false, true, nullptr} + }; + + std::vector functions = { + makeFunction("functionA", 0x1000u, 0x1020u) + }; + + std::unordered_map> decodedFunctions; + decodedFunctions[0x1000u] = { + makeAbsJump(0x1000u, 0x50000u, OPCODE_JAL), + makeNopLike(0x1004u), + makeAbsJump(0x1008u, 0x50000u, OPCODE_JAL), + makeNopLike(0x100Cu), + makeAbsJump(0x1010u, 0x50000u, OPCODE_J), + makeNopLike(0x1014u), + makeAbsJump(0x1018u, 0x60000u, OPCODE_JAL), + makeNopLike(0x101Cu), + makeAbsJump(0x1020u, 0x40000u, OPCODE_JAL), + makeNopLike(0x1024u) + }; + + const std::vector targets = + PS2Recompiler::CollectExternalCallTargets(decodedFunctions, functions, sections); + + t.Equals(targets.size(), static_cast(3), + "duplicate targets should collapse to unique entries"); + if (targets.size() == 3) + { + t.Equals(targets[0], 0x40000u, "targets should be sorted ascending"); + t.Equals(targets[1], 0x50000u, "second target should be the mid-range address"); + t.Equals(targets[2], 0x60000u, "third target should be the highest address"); + } + }); + + tc.Run("collect external call targets: targets inside skipped or stub local functions are still emitted", [](TestCase &t) { + std::vector
sections = { + {".text", 0x1000u, 0x100000u - 0x1000u, 0u, true, false, false, true, nullptr} + }; + + Function skippedFunction = makeFunction("functionB", 0x2000u, 0x2020u); + skippedFunction.isSkipped = true; + + std::vector functions = { + makeFunction("functionA", 0x1000u, 0x1020u), + skippedFunction + }; + + std::unordered_map> decodedFunctions; + decodedFunctions[0x1000u] = { + makeAbsJump(0x1000u, 0x2010u, OPCODE_JAL), + makeNopLike(0x1004u) + }; + + const std::vector targets = + PS2Recompiler::CollectExternalCallTargets(decodedFunctions, functions, sections); + + t.Equals(targets.size(), static_cast(1), + "a jal landing inside a skipped local function should still be collected"); + if (!targets.empty()) + { + t.Equals(targets[0], 0x2010u, + "only recompiled, non-stub, non-skipped functions should suppress emission"); + } + }); + + tc.Run("collect external call targets: conditional branches are not collected", [](TestCase &t) { + std::vector
sections = { + {".text", 0x1000u, 0x100000u - 0x1000u, 0u, true, false, false, true, nullptr} + }; + + std::vector functions = { + makeFunction("functionA", 0x1000u, 0x1020u) + }; + + // Target (0x50000) is inside the code section and outside every local function + // range, i.e. it would be collected if this were a jal/j - isolating that the + // opcode check, not the address itself, is what excludes it. + Instruction branch = makeAbsJump(0x1000u, 0x50000u, OPCODE_BEQ); + branch.isBranch = true; + + std::unordered_map> decodedFunctions; + decodedFunctions[0x1000u] = { + branch, + makeNopLike(0x1004u) + }; + + const std::vector targets = + PS2Recompiler::CollectExternalCallTargets(decodedFunctions, functions, sections); + + t.Equals(targets.size(), static_cast(0), + "conditional branches are not jal/j and should not be collected even though the address would otherwise qualify"); + }); + + tc.Run("data-embedded thread entries: jal to CreateThread wrapper resolves delay-slot $a0", [](TestCase &t) { + constexpr uint32_t wrapperStart = 0x00100200u; + constexpr uint32_t callerStart = 0x00100000u; + constexpr uint32_t jalAddr = callerStart + 8u; + + std::unordered_map> decoded = { + {wrapperStart, { + makeAddiu(wrapperStart, 3, 0, 0x20), + makeSyscall(wrapperStart + 4u), + makeJrRa(wrapperStart + 8u), + }}, + {callerStart, { + makeNopLike(callerStart), + makeLui(callerStart + 4u, 4, 0x0030), + makeAbsJump(jalAddr, wrapperStart, OPCODE_JAL), + makeAddiu(jalAddr + 4u, 4, 4, 0x1234), + }}, + }; + + const uint32_t paramAddress = 0x00301234u; + std::unordered_map fakeMemory = { + {paramAddress, 0u}, + {paramAddress + 4u, 0x00280000u}, + }; + auto isValid = [&](uint32_t addr) { return fakeMemory.count(addr) != 0u; }; + auto readWord = [&](uint32_t addr) { return fakeMemory.at(addr); }; + + const std::vector result = + PS2Recompiler::DiscoverDataEmbeddedThreadEntries(decoded, isValid, readWord); + + t.Equals(result.size(), static_cast(1), "expected exactly one discovered thread entry"); + if (result.size() == 1) + { + t.Equals(result[0], 0x00280000u, "thread entry pointer should be read from the ThreadParam struct"); + } + }); + + tc.Run("data-embedded thread entries: jal's decoder-populated rt field must not clobber $a0", [](TestCase &t) { + // R5900Decoder::decodeInstruction populates rs/rt/rd unconditionally from the + // raw instruction bits, even for J-type (j/jal) instructions where those bit + // positions are actually part of the 26-bit jump target rather than a real + // register field. For wrapperStart = 0x00100200, the jal's encoded target bits + // happen to alias rt = 4 ($a0). If the constant-propagation walk treated that + // as a real write to $a0, it would wrongly erase the $a0 the lui just set, + // causing the delay-slot addiu to fail to resolve. This must still resolve. + constexpr uint32_t wrapperStart = 0x00100200u; + constexpr uint32_t callerStart = 0x00100000u; + constexpr uint32_t jalAddr = callerStart + 8u; + + std::unordered_map> decoded = { + {wrapperStart, { + makeAddiu(wrapperStart, 3, 0, 0x20), + makeSyscall(wrapperStart + 4u), + makeJrRa(wrapperStart + 8u), + }}, + {callerStart, { + makeNopLike(callerStart), + makeLui(callerStart + 4u, 4, 0x0030), + makeAbsJumpDecoded(jalAddr, wrapperStart, OPCODE_JAL), + makeAddiu(jalAddr + 4u, 4, 4, 0x1234), + }}, + }; + + t.Equals(static_cast(RT(decoded.at(callerStart)[2].raw)), 4u, + "test setup sanity check: this jal's decoder-populated rt must alias $a0"); + + const uint32_t paramAddress = 0x00301234u; + std::unordered_map fakeMemory = { + {paramAddress, 0u}, + {paramAddress + 4u, 0x00280000u}, + }; + auto isValid = [&](uint32_t addr) { return fakeMemory.count(addr) != 0u; }; + auto readWord = [&](uint32_t addr) { return fakeMemory.at(addr); }; + + const std::vector result = + PS2Recompiler::DiscoverDataEmbeddedThreadEntries(decoded, isValid, readWord); + + t.Equals(result.size(), static_cast(1), + "a jal's incidental rt bits must not be treated as a register write"); + if (result.size() == 1) + { + t.Equals(result[0], 0x00280000u, "thread entry pointer should still be read from the ThreadParam struct"); + } + }); + + tc.Run("data-embedded thread entries: direct inline syscall resolves static $a0", [](TestCase &t) { + constexpr uint32_t callerStart = 0x00101000u; + std::unordered_map> decoded = { + {callerStart, { + makeLui(callerStart, 4, 0x0041), + makeAddiu(callerStart + 4u, 4, 4, 0x0100), + makeAddiu(callerStart + 8u, 3, 0, 0x20), + makeSyscall(callerStart + 12u), + }}, + }; + + const uint32_t paramAddress = 0x00410100u; + std::unordered_map fakeMemory = { + {paramAddress, 0u}, + {paramAddress + 4u, 0x00420000u}, + }; + auto isValid = [&](uint32_t addr) { return fakeMemory.count(addr) != 0u; }; + auto readWord = [&](uint32_t addr) { return fakeMemory.at(addr); }; + + const std::vector result = + PS2Recompiler::DiscoverDataEmbeddedThreadEntries(decoded, isValid, readWord); + + t.Equals(result.size(), static_cast(1), "expected the inline syscall call site to resolve"); + if (result.size() == 1) + { + t.Equals(result[0], 0x00420000u, "thread entry pointer should be read via the direct syscall path"); + } + }); + + tc.Run("data-embedded thread entries: clobbered $a0 before call yields no result", [](TestCase &t) { + constexpr uint32_t wrapperStart = 0x00100200u; + constexpr uint32_t callerStart = 0x00100000u; + constexpr uint32_t jalAddr = callerStart + 12u; + + std::unordered_map> decoded = { + {wrapperStart, { + makeAddiu(wrapperStart, 3, 0, 0x20), + makeSyscall(wrapperStart + 4u), + makeJrRa(wrapperStart + 8u), + }}, + {callerStart, { + makeNopLike(callerStart), + makeLui(callerStart + 4u, 4, 0x0030), + makeLw(callerStart + 8u, 4, 5), // clobbers $a0 with an unresolved load + makeAbsJump(jalAddr, wrapperStart, OPCODE_JAL), + makeNopLike(jalAddr + 4u), // delay slot does not re-materialize $a0 + }}, + }; + + std::unordered_map fakeMemory = { + {0x00301234u, 0u}, + {0x00301238u, 0x00280000u}, + }; + auto isValid = [&](uint32_t addr) { return fakeMemory.count(addr) != 0u; }; + auto readWord = [&](uint32_t addr) { return fakeMemory.at(addr); }; + + const std::vector result = + PS2Recompiler::DiscoverDataEmbeddedThreadEntries(decoded, isValid, readWord); + + t.Equals(result.size(), static_cast(0), "a clobbered $a0 with no re-materialization should not resolve"); + }); + + tc.Run("data-embedded thread entries: wrapper with wrong syscall number is not registered", [](TestCase &t) { + constexpr uint32_t wrapperStart = 0x00100200u; + constexpr uint32_t callerStart = 0x00100000u; + constexpr uint32_t jalAddr = callerStart + 8u; + + std::unordered_map> decoded = { + {wrapperStart, { + makeAddiu(wrapperStart, 3, 0, 0x21), // wrong syscall number + makeSyscall(wrapperStart + 4u), + makeJrRa(wrapperStart + 8u), + }}, + {callerStart, { + makeNopLike(callerStart), + makeLui(callerStart + 4u, 4, 0x0030), + makeAbsJump(jalAddr, wrapperStart, OPCODE_JAL), + makeAddiu(jalAddr + 4u, 4, 4, 0x1234), + }}, + }; + + std::unordered_map fakeMemory = { + {0x00301234u, 0u}, + {0x00301238u, 0x00280000u}, + }; + auto isValid = [&](uint32_t addr) { return fakeMemory.count(addr) != 0u; }; + auto readWord = [&](uint32_t addr) { return fakeMemory.at(addr); }; + + const std::vector result = + PS2Recompiler::DiscoverDataEmbeddedThreadEntries(decoded, isValid, readWord); + + t.Equals(result.size(), static_cast(0), "a wrapper using a non-CreateThread syscall number should be ignored"); + }); + + tc.Run("data-embedded thread entries: zero entry pointer is filtered out", [](TestCase &t) { + constexpr uint32_t wrapperStart = 0x00100200u; + constexpr uint32_t callerStart = 0x00100000u; + constexpr uint32_t jalAddr = callerStart + 8u; + + std::unordered_map> decoded = { + {wrapperStart, { + makeAddiu(wrapperStart, 3, 0, 0x20), + makeSyscall(wrapperStart + 4u), + makeJrRa(wrapperStart + 8u), + }}, + {callerStart, { + makeNopLike(callerStart), + makeLui(callerStart + 4u, 4, 0x0030), + makeAbsJump(jalAddr, wrapperStart, OPCODE_JAL), + makeAddiu(jalAddr + 4u, 4, 4, 0x1234), + }}, + }; + + std::unordered_map fakeMemory = { + {0x00301234u, 0u}, + {0x00301238u, 0u}, // entry pointer is zero, should be excluded + }; + auto isValid = [&](uint32_t addr) { return fakeMemory.count(addr) != 0u; }; + auto readWord = [&](uint32_t addr) { return fakeMemory.at(addr); }; + + const std::vector result = + PS2Recompiler::DiscoverDataEmbeddedThreadEntries(decoded, isValid, readWord); + + t.Equals(result.size(), static_cast(0), "a zero entry pointer should never be reported as a thread entry"); + }); + + tc.Run("data-embedded thread entries: multiple call sites to the same struct dedupe", [](TestCase &t) { + constexpr uint32_t wrapperStart = 0x00100200u; + constexpr uint32_t callerAStart = 0x00100000u; + constexpr uint32_t callerBStart = 0x00100100u; + constexpr uint32_t jalAddrA = callerAStart + 8u; + constexpr uint32_t jalAddrB = callerBStart + 8u; + + std::unordered_map> decoded = { + {wrapperStart, { + makeAddiu(wrapperStart, 3, 0, 0x20), + makeSyscall(wrapperStart + 4u), + makeJrRa(wrapperStart + 8u), + }}, + {callerAStart, { + makeNopLike(callerAStart), + makeLui(callerAStart + 4u, 4, 0x0030), + makeAbsJump(jalAddrA, wrapperStart, OPCODE_JAL), + makeAddiu(jalAddrA + 4u, 4, 4, 0x1234), + }}, + {callerBStart, { + makeNopLike(callerBStart), + makeLui(callerBStart + 4u, 4, 0x0030), + makeAbsJump(jalAddrB, wrapperStart, OPCODE_JAL), + makeAddiu(jalAddrB + 4u, 4, 4, 0x1234), + }}, + }; + + std::unordered_map fakeMemory = { + {0x00301234u, 0u}, + {0x00301238u, 0x00280000u}, + }; + auto isValid = [&](uint32_t addr) { return fakeMemory.count(addr) != 0u; }; + auto readWord = [&](uint32_t addr) { return fakeMemory.at(addr); }; + + const std::vector result = + PS2Recompiler::DiscoverDataEmbeddedThreadEntries(decoded, isValid, readWord); + + t.Equals(result.size(), static_cast(1), + "two call sites referencing the same ThreadParam struct should dedupe to one entry"); + if (result.size() == 1) + { + t.Equals(result[0], 0x00280000u, "the deduped entry should still be the correct thread entry pointer"); + } + }); + + tc.Run("data-embedded thread entries: addiu LO with sign bit set is sign-extended, not OR'd", [](TestCase &t) { + constexpr uint32_t wrapperStart = 0x00100200u; + constexpr uint32_t callerStart = 0x00100000u; + constexpr uint32_t jalAddr = callerStart + 12u; + + std::unordered_map> decoded = { + {wrapperStart, { + makeAddiu(wrapperStart, 3, 0, 0x20), + makeSyscall(wrapperStart + 4u), + makeJrRa(wrapperStart + 8u), + }}, + {callerStart, { + makeNopLike(callerStart), + makeLui(callerStart + 4u, 4, 0x0031), + // sign-extended LO: address = 0x00310000 + sign_ext16(0x8000) = 0x00308000 + makeAddiu(callerStart + 8u, 4, 4, 0x8000), + makeAbsJump(jalAddr, wrapperStart, OPCODE_JAL), + makeNopLike(jalAddr + 4u), + }}, + }; + + const uint32_t paramAddress = 0x00308000u; + std::unordered_map fakeMemory = { + {paramAddress, 0u}, + {paramAddress + 4u, 0x00290000u}, + }; + auto isValid = [&](uint32_t addr) { return fakeMemory.count(addr) != 0u; }; + auto readWord = [&](uint32_t addr) { return fakeMemory.at(addr); }; + + const std::vector result = + PS2Recompiler::DiscoverDataEmbeddedThreadEntries(decoded, isValid, readWord); + + t.Equals(result.size(), static_cast(1), + "the sign-extended address should resolve to the correct ThreadParam struct"); + if (result.size() == 1) + { + t.Equals(result[0], 0x00290000u, "entry pointer should come from address 0x00308000, not the OR'd 0x00318000"); + } }); }); }