D576 - A breakpoint was called stub padding without anything looking at the address

Status: measured Date: 2026-09-07

The message, and what stood behind it

Every guest breakpoint was reported as:

breakpoint - stub padding - at 0x480002e68020 (the title's own modules+0x2e68020)

and the exit status as "execution reached stub padding, so a stub was entered off its start".

Nothing had looked at the address. The fault handler matched the exception code and took a fixed string:

BREAKPOINT => (at_instruction[1], rip),

at_instruction[1] is the constant "breakpoint - stub padding - at". The handler holds rip, the stub table's base and length are in atomics it can read, and locate already existed to name the region containing an address - and none of that was consulted.

The line contradicts itself, in the output, and had done for months. Stubs occupy region slot 1 and the title's modules slot 4; locate returns the first match. So an address the report names as the title's own modules provably is not in the stub table, while the same line calls it stub padding. The two halves came from different places and only one of them had checked anything.

The Unix half of the same table was already honest - "trap - execution reached stub padding, or a debugger interrupted it" - which is the tell: one platform's wording admitted the ambiguity and the other asserted through it.

It cost a wrong finding the same day

Worklog 422 recorded PPSA03416's wall as "the guest then enters the stub table off a thunk's start", and named the next step as a linking question for a session allowed to open the loader. That reading came entirely from this message. It is withdrawn: the address is in the title's own module code, the stub table does not cover it, and no part of the diagnosis was ever measured.

This is the failure principle 3 already names for the tools - a message naming a cause must come from the branch that determined it - arriving in the one place where a reader has least ability to check it, because a fault message is what they read instead of stepping through.

Three answers, because three things can be known

breakpoint_kind_in(address, stub_base, stub_len) is a pure function beside locate, split from the atomics for the reason locate gives: the handler that uses it cannot be stepped through, and a wrong answer is a message asserting a cause at the moment it matters most.

condition kind
the address is inside the registered stub span breakpoint - stub padding - at
the span is known and the address is outside it breakpoint - not stub padding - at
no stub span was registered breakpoint - at

The third is not padding on the design. "Checked, and it is not there" and "there was nothing to check against" are different states, and collapsing them would put the same overstatement back one level down.

The exit-code table keeps no address at all, so it now says "a trap instruction; the fault record says whether it was stub padding" rather than naming a cause it cannot reach.

The test asserts both edges of the span and the unregistered case, and it was watched failing: with the classifier forced to answer "in stubs" it fails on the outside cases, which is the property that makes it a guard rather than a decoration.

What the guest is actually doing

PPSA03416 now reports:

breakpoint - not stub padding - at 0x480002e68020 (the title's own modules+0x2e68020)

A trap instruction in the title's own module code, reached from the executable. An assertion that fired, a deliberate trap on a path the guest decided was wrong, or a jump into data - and a fault record cannot tell those apart, so the message names none of them. What it establishes is where to look next: what the guest checked immediately before trapping. The bytes there answer it.

What the guest checked, measured properly the second time

The bytes at the trap say it outright. Immediately before 0x480002e68020 is a call, and immediately after the int3 is mov ebx, 0x8002000c - the vendor encoding of ENOMEM - followed by a jump back into a common epilogue. That is the shape a compiler emits after a call it believes never returns: the trap catches the case where it does.

The last import before it is sceKernelMprotect, answering 0x80020016 - EINVAL under the same encoding - for 256 MiB of write access starting inside the title's own module. So the guest checked that return, took its failure path, called a fatal routine that was not supposed to come back, and came back.

Forced to answer success, the wall moves:

refused forced to 0
imports 186 192
fault modules+0x2e68020, a trap image+0x1389269, read of 0xa0
verdict - FURTHER

image+0x1389269 is the address this title's compatibility record already held from 2026-09-04. Answering the call puts it back where it was, which is corroboration that the refusal is the whole of the new wall rather than a step on the way to it.

The first attempt at that measurement was void, and said so

It was first run as ORBISTOUN_RETURN=0x366131779b0023bd:0x0, the hash. Matching is by label - library::name, or library::0xhash only where nothing has named it - so a hash for a named import matches nothing. The run said so on its third line:

orbistoun: ORBISTOUN_RETURN matched no import called "0x366131779b0023bd"
orbistoun: no import will answer a forced value

That warning exists because D230 anticipated this exact mistake - a forced return that matched nothing is visible rather than inferred from an unchanged run. It was printed, and the unchanged run was read as the answer anyway, because the grep that pulled the verdict out did not include those words. This entry asserted the refusal was ruled out; it was never tested. The tool was right, the reading was not, and the guard only works if somebody reads the line it prints.

What this does not establish

That the other breakpoint readings in the back catalogue were wrong. Any run whose fault was a breakpoint carried this message, and whether the address was in the stub table was never recorded. Only PPSA03416 was re-run.

Nor that the fatal routine is the only way this title reaches a trap. The bytes and the forced run agree on this path; another failure reaching the same epilogue would look identical, and only one input was varied.

And the premise of that entry was a rounding error, corrected by D595. The "one file read of zero bytes" this reasoning starts from was four hundred and two bytes of boot.config, read completely and successfully, printed as 0 KiB by an integer division. What survives is that the guest never reads globalgamemanagers through its descriptor - which is now measured per read rather than inferred from a summary.

And one reading here was wrong, corrected by D578. The four paths this title probes and does not find - data.unity3d and three globalgamemanagers.res variants - were read as evidence that it never reached the loose-file layout it actually ships. It reaches it: a record of successful opens shows it opening globalgamemanagers, the IL2CPP metadata and the boot config. The four are the archive layout the title does not use, and reasoning from the missing half of the evidence is what made them look like a cause.

Nor what sceKernelMprotect should do about module memory. Refusing it is measured to be the wall, which says the current behaviour is wrong for this guest - not what the right behaviour is. The range belongs to a placement orbistoun-kernel does not own, and the guard that refuses it exists so a typo cannot re-protect this process's own code. Consulting the regions already reported to the crate is the obvious shape, and is not attempted here.