2026-08-26 - The loader learned to read the other half of the world

A question about running the open-toolchain payloads turned into a correction of a recorded measurement and then a working loader path. D305, D306.

D163 said this route was closed, and it was wrong

"No dynamic segment at all. Payloads resolve everything through the loader's own function table at run time, so there is no import list."

Twenty-three payload ELFs re-measured, and every one carries PT_DYNAMIC, DT_NEEDED, a real .dynsym, and between 3 and 207 named undefined symbols. orbistoun's own refusal had already changed - from "no PT_DYNAMIC segment" to "lacks a string table, symbol table, or hash table" - and nobody read the new message.

A stale measurement kept a whole class of guest out for six days. Worth more than the fix: the error was not in the reasoning, which was sound from what it had, but in nobody re-running it when the tool's answer changed underneath it.

The two blockers, one of them silent

DT_GNU_HASH states no symbol count, and twenty-one of twenty-three carry only that. Walking the bucket and chain arrays gets it.

The other was worse. imports_from_symbols ended in if let Some(decoded) = decode_symbol_name(name) and dropped anything that was not NID#lib#mod without a word. So the two payloads carrying DT_HASH got past the first gate and reported:

entry 0x0
0 imports, 0 unresolved

A module needing eighty-five things, reporting that it needs none. That is the claim principle 3 forbids an import list from making, and Container::imports has a guard for exactly it which did not cover this path.

Hashing the name is not a second scheme

The tempting design is two resolvers - NIDs for vendor modules, strings for homebrew. There is no need: libSceNet.sprx exports socket, and the NID it publishes is the hash of that name. A plain name is a NID nobody hashed yet.

Checked before it was built, because everything rested on it: socket, bind, listen, accept, malloc, pthread_create, memcpy, sysctl, kqueue and getifaddrs are all in this repository's own hash-confirmed database, so each already hashes to a NID that appears in real vendor modules. The relation was established by the naming loop long ago; this only stops throwing it away.

payload imports nameable registry answers
elfldr 0.25 24 24 8
klogsrv 0.9 34 33 7
shsrv 0.20 41 39 10
ftpsrv 0.21.1 85 84 24
pldmgr 0.5.1 160 159 42

readelf agrees on all five exactly. The first pass here was one too high on every row - a trailing blank line counted as an import - which is a reminder that a number produced by the thing being tested is not a measurement until something else has said it too.

The naming problem was already solved for these. One or two per payload are unnamed.

Then it linked, entered, and stopped somewhere much later

klogsrv now parses, resolves, maps, relocates completely and enters. Six entry-setting combinations later (D306): the stack convention makes no difference at all, and the first argument register decides everything - the entry point calls through a pointer it takes from rdi. Handed the image address it jumps to 1, which is the argument count read as a function pointer.

Stopped there rather than inventing the structure it wants.

Two things I got wrong on the way

Told the user sceKernelDlsym was "the item that decides whether this is native or a hack" and offered to start with it. No payload in the corpus imports it - or any dynamic-loading function. Measured after asserting, which is the wrong order.

And the entry fault was read as a relocation problem for a while. It was not: reached Linked only fires when the tally is complete, so the evidence that relocation was fine was already on screen in the first run.

And reading them turned up something the vendor corpus never had

Imports that name data. __stderrp, optarg, __isthreaded - STT_OBJECT symbols reached through GLOB_DAT, two per payload in the small ones, and every one of them was being handed a function thunk.

That is the worst shape of wrong answer available: the guest loads the slot, dereferences what it found, reads x86 instruction bytes as a FILE *, and carries on. Nothing faults. st_info states the difference outright, so it is now read and carried through to the report (D307). Answering it correctly - a zeroed guest-owned block, the shape process_argument_block already argues for - is left as its own decision, because it is the first time the HLE layer would own state rather than functions.

The gate, and why it never finishes

Two separate things, and one of them was me.

Runaway orphans. Two orbistoun_propose test binaries from earlier killed gate runs were still alive at 5.2 and 1.2 CPU-hours, pegging six cores and starving the gate that was actually running. That is also what made the earlier run take 1.21 hours - it was competing with its own orphans.

The vocabulary suite is a production-scale sweep in an unoptimised build. Tens of millions of SHA-1 candidates per round, and the code says so itself - "the sweep beside it takes under a second in release". But check runs cargo nextest run --workspace in debug, and [profile.dev.package."*"] optimises dependencies only - the sweep loop is workspace code. Measured:

cargo test --release -p orbistoun-propose --lib    35 passed in 67.88s
cargo test          -p orbistoun-propose --lib     still running at 17 minutes

A [profile.test] line would fix it. Not applied - optimising deps but not workspace crates looks like a deliberate line somebody drew, and this is a build-config decision rather than part of the loader work.

And the gate failure was mine. orbistoun_propose exited 0xffffffff - an external kill, not a test failure. Clearing the runaways caught the live one too. Re-run in release: all 35 pass. The other two failures were real and are fixed: cargo fmt, and a [experiment] doc link left dangling by the D293 crate split, pointing at a module that now lives in orbistoun-turn.