Files
Dean 27bcd6a29e Merge pull request #8162 from savvaniss/fix/windows-runtime-module-start
fix(relinker): start Windows runtime modules the executable needs when the title loads them
2026-10-22 02:10:11 +13:00

18 KiB

relinker technical debt

Functional

  • Executable guest imports - on Linux and macOS, a relocated executable import resolved to one guest provider requires that module and its dependencies at startup, even when it was discovered through --module-dir without a DT_NEEDED entry. Unreferenced symbols and no-op relocations do not require an otherwise deferred module. Console module-start ordering is unmeasured.

  • Runtime-loaded modules (--module-dir) that another module needs are initialized at startup with no start arguments, before the module that needs them is loaded; the console is assumed to start them when that module is loaded.

  • Runtime-loaded modules the executable needs (--module-dir) are mapped at startup on Windows and started when the title loads them with sceKernelLoadStartModule, with their start arguments, as a Unity player loads mono-ps4.sprx. A title that calls such a module before loading it finds its initializers not run; whether the console starts it earlier is not measured. A module the executable only imports from and the title never loads itself gets no module_start and no initializers at all under this deferral, and nothing reports it. Linux and macOS still start it at startup.

  • The executable file that relinker generates opens a console when launched, unless it is relinked with --windows-gui, which selects the GUI subsystem. A GUI build started from Explorer has no standard output or error handle; the entry stub gives each missing one anyps5.log beside the executable, replacing the previous run's file (#3282). Errors about the executable path or about waiting for the previous LoadExec process are written before the log is opened and are lost when the standard error handle is missing. If the log cannot be created, output to the missing handles is lost.

  • Guest import registries - declared host imports of bundled modules are recorded with their module name and relocation target, even when another guest module exports the same stripped NID. No-op relocations are omitted. Registries describe import bindings, not implementation correctness or runtime reachability.

  • Converted Windows main executables omit DYNAMIC_BASE by default, not only with --windows-fixed-base: on the console the executable lies inside the 40-bit window that the title's own sceLibcMspaceCreate checks (#3043). Relocations are kept, so if 0x140000000 is taken Windows relocates the image, possibly above that window. Verified only on Windows 10.

  • SDK memory-management syscall traps under --trap-sdk-syscall-stubs recognize three exact instruction sequences from ps5-payload-dev SDK libc/mman.c, observed in Nuvio Maxd 0.5.15 (PPSA99288): syscall 74 and the two syscall 477 paths, with their argument preparation and carry-flag error handling. Matching requires a decoded instruction boundary and the complete sequence inside the scanned code range. Other SDK/compiler output remains rejected. The matched syscall becomes UD2; this does not implement mmap or mprotect and a call still faults.

  • The instruction decoder reads a 16-bit displacement for 66 E8, 66 E9 and 66 0F 80-8F without REX.W, as AMD processors, the console included, do (binutils objdump -M amd64; REX.W selects the 32-bit form). AMD also truncates the target of every near relative branch with that prefix, rel8, LOOP and JRCXZ forms included, to 16 bits. Instruction metadata marks these branches as truncated and reports no branch target for them, and stubs refuse to move them. Compilers do not emit these forms.

  • --to-intel lowers MCOMMIT to STC plus NOP padding and RDPRU through a runtime-checked stub in the matcher: AMD APM Vol. 3 defines RDPRU ECX 0 as MPERF and ECX 1 as APERF, neither readable on Intel hosts, so both selectors return the host TSC as a disclosed monotonic-counter approximation (their ratio always reads 1.0) while other selectors yield zero EDX:EAX. STC asserts CF=1 because under x86-TSO prior stores are already ordered and guest memory is host-DRAM-backed, leaving the commit-success case as the only observable one. Non-zero RDPRU selectors, the TSC-for-MPERF/APERF substitution, and any console path reporting MCOMMIT CF=0 are unverified.

  • Unscoped imports on Windows - an import without a module that no searched library exports under its name is searched again under the NID of that name, because homebrew built with the ps5-payload-dev SDK imports by symbol name (Quake, from the websrv homebrew launcher; Mari0, #6239) while the libraries in libs/ export NIDs. A dependency named with .sprx, .suprx or .debug_prx loads the .prx library of the same stem from libs/, and libkernel_web also loads libkernel.prx, as libSceLibcInternal already loads libc.prx: AnyPS5's libkernel_web.prx exports only two functions, where the console's libkernel_web.sprx is a libkernel variant. A name that matches a library's export as written still binds to that export first. Linux and macOS resolve these imports through the host loader by name and are unchanged.

  • The length-changing path of the instruction rewriter is not used by the converter: it does not adjust data-to-code references (relocations, FDEs, jump tables) or segment sizes, so every substitution keeps the instruction length.

  • The converter takes the branch targets that keep an instruction in place from a linear scan of the code segment, so data in that segment decodes as branches too: OpenSSL's x86-64 assembly keeps strings such as "SHA1 block transform for x86_64, CRYPTOGAMS by appro@openssl.org" in its code, and in SuperTuxKart (PPSA69421) and Mortalfin (PPSA99141) the jae read from "sf" points into the VEX prefix of a 256-bit store. A direct branch target inside a scanned instruction counts only where every byte before it is a legacy prefix or REX, which a real branch can skip (a lock prefix, for example); a target in a VEX prefix, opcode or operand is ignored, as a jump-table entry that starts no instruction already is. A real branch to such a target, overlapping instructions as hand-written code can, is not seen, and the instruction it enters can be moved.

  • A PT_SCE_RELRO segment that no PT_LOAD covers, the PS4 layout, is loaded as a read-write PT_LOAD and stays writable after relocation; FreeBSD rtld makes PT_GNU_RELRO read-only once relocation is done, and what the console does with PT_SCE_RELRO is not checked.

  • In Windows output, a guest instruction that addresses memory through GS becomes UD2 and NOP padding of its own length (TLS builder): the host owns GS, and the guest GS base that FreeBSD's sysarch(AMD64_SET_GSBASE) would set is not emulated. A converted module runs until such an instruction is reached, which then faults. Prospero Win (PPSA99995) has six in its Wine ntdll.

  • In executables with a PT_SCE_DYNLIBDATA segment, the PS4 layout, the executable segment also holds read-only data after the code (CUSA00900: 37 MiB after its last function). The relinker pipeline therefore scans for syscalls and import call sites only up to the end of the PLT that follows the last function of the unwind table; code after the PLT without an unwind entry is not scanned. --to-intel and the unused-filter=1 and unused-filter=2 analyses are not limited this way.

  • The syscall scan of sce_module modules with a PT_SCE_DYNLIBDATA segment stops at the same end, or, when the PLT comes before the code, at the end of the last function of the unwind table; code after it is not scanned.

  • In the same executables and modules, the bytes between unwind functions are 16-byte slots of function tails and padding, and a tail can begin inside an instruction. The instruction scanner of the syscall and call-site scans therefore resumes at the next byte when bytes outside every unwind function do not decode, and ends a decoding outside them at the start of the next unwind function. Code without an unwind entry that does not decode from its first byte is skipped by these scans instead of failing the relink; inside an unwind function a decode failure still fails it.

  • Read-only data can also sit between the unwind functions of such images. There, a syscall, sysenter or int 0x80 pattern fails the syscall scan only when a constant syscall number is set before it; the others, and every sysret, are skipped and counted in one message. The number is looked for in the decoded instructions and, whatever alignment the decoding took, in the bytes before the pattern: a mov eax, imm32, mov rax, imm32, mov rax, imm64 or xor eax, eax, optionally followed by mov al, imm8 or mov ax, imm16, then by any number of register moves or zeroings that leave rax alone. A real syscall without an unwind entry whose number is set otherwise is not refused and runs on the host: from memory or another register (mov eax, ecx), by push/pop or arithmetic (or eax, imm32), or with any other instruction between the load and the pattern.

  • In the code analysis of the Windows and macOS outputs for such images, a relocation or lea target outside every unwind function is followed as code only when its linear decoding, up to the first jump, return or trap, the next unwind function or 256 instructions, holds an fs:[disp32] access, or when it is 16-byte aligned, starts less than 256 bytes after an unwind function and ends in a jump, return or trap. Every other such target is taken as data and counted in one message, and the Windows TLS rewrite fails instead of overwriting one. A TLS access of another form, such as mov rax, fs:[rax], or a gs instruction in code reached only through such a target is therefore not rewritten or trapped. Other fs and gs instructions do not tell code from data there: string bytes decode as these prefixes, and in two measured titles about 2,500 of about 5,500 such targets decode to one before their first jump.

  • In executables and guest modules with a PT_SCE_DYNLIBDATA segment, the code analysis of the Windows and macOS outputs follows code only from the end of PT_INTERP, or of the /libexec/ld-elf.so.1 string that begins the segment when no PT_INTERP covers it, as in OpenOrbis modules, to the end of the PLT after the last function of the unwind table, or to the end of that function when the PLT comes before the code, as in the libSceFios2.prx and libc.prx that CUSA02636 bundles (PLT at 0xc8); the pipeline's scans of executables above stop at this same end, also when the PLT comes before the code. A guest module with PLT relocations and no PLT before or after its unwind functions, or with a PLT that overlaps one, fails as the executable does. Code after that end is not analyzed, such as the DT_FINI routine without an unwind entry that follows the last unwind function in both modules and that the Windows and macOS outputs call when the program exits: a TLS access in it is not rewritten, the ELF entry point, DT_INIT, DT_FINI, a branch or a pointer into it is ignored, and a function symbol or init/fini array entry pointing into it fails the conversion.

  • A relocation against a dynamic symbol entry that is all zero, like the null symbol, takes the value 0 plus its addend, as the gABI defines for STN_UNDEF (pipeline). The Linux output imports such an entry as an unnamed weak symbol, which the host loader resolves to 0; the Windows and macOS outputs write the value into the slot (relocation builder). What the console's loader does with such an entry is not measured. OliSe Player (PPSA01153) has seven of them, and its code calls through those slots only when they are not 0 (#4437).

  • In macOS output, a guest instruction that addresses memory through GS becomes UD2 and NOP padding of its own length (macOS image writer): the host owns GS, which holds the pthread TSD base the guest TLS stubs read, and the guest GS base that FreeBSD's sysarch(AMD64_SET_GSBASE) would set is not emulated. A converted module runs until such an instruction is reached, which then faults. Prospero Win (PPSA99995) has six in its Wine ntdll.

  • The Linux placement of the --to-intel stubs in the ELF patcher is covered only by a synthetic test.

  • --to-rosetta lowers what Rosetta lacks on top of --to-intel (matcher). AVX2, FMA, BMI1/2, F16C and MOVBE were observed to execute on an M5 Max without being reported through CPUID; this is not a guarantee for other Rosetta hosts. ANDN and RDRAND fault on an M1 running macOS 15.3.1 and execute on an M1 running macOS 26.6.2, so on the M1 the difference follows the macOS version; on that host BEXTR, BLSI, BLSMSK, BLSR, TZCNT, LZCNT, POPCNT, BZHI, MULX, PDEP, PEXT, RORX, SARX, SHLX, SHRX, MOVBE, FMA, AVX2 and F16C execute as well, its CPUID leaf 1 reports none of MOVBE, FMA, AVX or F16C, and RDSEED, RDPID, CLWB, ADCX and ADOX raise SIGILL. They also execute on an M5 running macOS 26.4, where the rest of BMI1 and BMI2, LZCNT, PREFETCHW, CLFLUSHOPT, AVX, AVX2, FMA, F16C and MOVBE execute as well although CPUID leaf 7 reads zero and leaves 1 and 0x80000001 report none of them, and where RDSEED, CLWB, RDPID, ADCX and ADOX raise an illegal instruction. ANDN is lowered, but RDRAND and other BMI instructions still depend on the host. RDSEED becomes RDRAND and CLWB a NOP. RDPID returns processor 0, and RDTSCP runs unchanged with ECX set to 0 after it, because Rosetta's TSC_AUX is the constant 0xAAAAAAAB (also measured on an M5, macOS 27.0.1). An RDPID or RDTSCP site with no movable instruction after it for the jump is kept; a kept RDTSCP reports 0xAAAAAAAB. ADCX/ADOX fault under Rosetta and are not converted: compilers do not emit them, and code that checks CPUID sees no ADX. The conversions are covered by the converter tests. ANDN lowering is also executed by an x86-64 test build under Rosetta on the M1; the other conversions were originally checked on an M5 Max, outside this repository's CI.

  • --to-intel replaces the approximate legacy SSE and VEX.128 register forms of RSQRTPS/RCPPS and VRSQRTPS/VRCPPS with correctly rounded 1/sqrt(x) and 1/x, not with the AMD approximation: its tables are not public. Intel's approximation of 1/sqrt(1.0) is 0x3F7FF000, and two Newton-Raphson steps on it settle on 0.99999994 instead of 1.0; a renormalised identity quaternion then turns into a NaN axis in PPSA21564, which the console does not show. Refined results now reach the exact value; unrefined ones can still differ from the console in the low bits. Each site costs an out-of-line stub (two jumps, a spill below the red zone, SQRTPS/DIVPS). Legacy SSE forms preserve the destination's upper YMM bits; VEX forms clear them. The 256-bit, scalar and memory forms keep the native instruction, as does a site shorter than 5 bytes with no movable instruction after it for the jump (5 sites in PPSA21564, counted before a following RIP-relative instruction could move into the stub; not recounted).

  • --trace-imports (Windows entry stub, Linux entry stub) exists for Windows and Linux output, not macOS. Only imports bound through a PLT slot, or through a GOT slot whose symbol is a function, are traced; a function reached through any other relocation, or looked up at run time with sceKernelDlsym, is not, and neither are weak imports or the imports of modules loaded at run time. On Linux the entry stub makes the page of every traced slot readable, writable and executable with mprotect before it stores the thunk, so a slot inside PT_GNU_RELRO can be replaced; those pages stay writable, and data pages become executable, while tracing is on

  • ANDN lowering uses scalar NOT and AND for 32- and 64-bit register and memory operands under --to-rosetta, following the ANDN entry in Intel SDM volume 2A. SF and ZF follow the result, CF and OF clear, and AF and PF are undefined. The scratch register is saved below the 128-byte red zone. A 64-bit stack-relative displacement that exceeds signed disp32 after reserving the spill frame throws; other BMI instructions are not lowered. The M1 test covers register overlap, RSP operands, memory addressing, RIP relocation, flags and red-zone preservation; precise fault delivery inside a spill sequence has not been verified.