413. libSceUlt, and the quiet four gigabytes

2026-09-04 - directed

What was done

Implemented the five libSceUlt setup calls PPSA28061 makes - sceUltInitialize, both GetWorkAreaSize functions and both constructors - after chasing why that title stops at 25 imports where its record claimed 47.

The chase found this:

sceUltWaitingQueueResourcePoolGetWorkAreaSize(16, 16) -> 0x7fff0001
malloc(0x7fff0001)                                    -> a pointer
sceUltUlthreadRuntimeGetWorkAreaSize(16, 3)           -> 0x7fff0001
malloc(0x7fff0001)                                    -> a pointer

0x7fff_0001 is the unimplemented placeholder. As a size it is 2 GiB, and this emulator's malloc served both requests. Four gigabytes, and nothing said a word.

Sizes are now 4,352 and 2,688 bytes, both constructors succeed, and the runtime and pool are in a table with the guest's own names for them - "sample runtime" and "waiting queue".

The rule this sharpens

The placeholder exists to be loud: no vendor function returns it, so a guest acting on it goes visibly wrong. That reasoning assumes the return is a status. Where a function answers a size, the placeholder is a number the caller spends - and spending it succeeds.

D125 already covers the placeholder answered where a pointer was wanted. This is the same mistake one type over and it is quieter, because nothing crashes. The general form is worth having: a function whose answer is arithmetic - a size, a count, a length, an offset - cannot be left unimplemented safely.

What it did not buy

No reach. PPSA28061 still stops at 25 imports, because the abort is Agc's: it goes on to sceAgcDriverRegisterDefaultOwner and sceAgcCreateShader, both answer the placeholder, and it calls abort. That was visible before implementing any of this and is unchanged by it - said here plainly because a worklog that led with "five functions implemented" would imply otherwise.

Guards

Six, each watched failing: the sizer answering the placeholder again (the original bug); the sizer answering zero; the sizer ignoring the request; the constructor writing nothing into the object; the constructor inventing a handle instead of issuing one; and a null destination accepted.

One break needed two edits to fire, and that is in the test. Removing the out == 0 check alone does not fail the null guard, because write_word refuses null too. The two are not the same guarantee - one says this call refuses null, the other says this process will not write through null - so both are kept and the test says why.

Surprise

A gate I had not met before caught me: every_implementation_is_also_declared_here_or_says_why_not. The Ult functions are declared in the ult module and implemented beside libkernel's table, which needs an entry in that gate's exception list. The existing Ult mutexes were already there, so the pattern was written down - I just had not added to it.