D458 - Execute breakpoints, so a guest-computed value can be read where it is used
measured - 2026-09-01 (user-directed /loop: build the watchpoint feature and use it to crack the walls)
Every remaining wall is a guest-computed value that was wrong and
unchecked - the size a guest hands tlsf_add_pool (D449,
three titles), a base that was zero (D457). None can be read out of the
load-transformed eboots by disassembly, and this project refuses to
disassemble the guest anyway (D277). The register dump names
which value was wrong (D457); this answers what it was
where it was used.
The feature. orbistoun-worker's
watchpoints already arm x86 debug registers and trap through the #DB
handler for data accesses (D276). This adds a third kind,
Execute (spelled <addr>:x in
ORBISTOUN_WATCHPOINT): a hardware breakpoint on an
instruction fetch (R/W=00, LEN=00). It is
one-shot - the first hit snapshots all sixteen
registers into statics (allocation-free, on the guest's stack) and then
the handler clears that slot's Dr7 enable bit, so the
instruction runs and the guest carries on. No resume-flag or single-step
machinery, and no infinite re-trap. The summary prints the snapshot: the
register state a function was entered with, i.e. its arguments
(rdi, rsi, rdx, rcx, r8, r9). Provenance-clean - it reads
registers, never guest code.
Verified. Armed at image+0xafcc08
(PPSA02664's null-write site), it fired once, printed the full register
state, disarmed, and the guest ran on - the summary reads
execute breakpoint at ... hit 1 time(s); registers the first time: rax=0x0 ... rbx=0x4000 ... r14=0x20 ....
fmt/clippy/cargo test pass for
the additions (44 tests); the pre-existing enter
too_many_lines in lib.rs is untouched debt,
not from here.
What the tool then corrected, which is the point of
it. The first guess from the snapshot was that
r14 = 0x20 (below tlsf's 0x28 minimum) was the
rejected pool size. Reading the instruction stream the report captures
just before 0xafcc08 killed that guess. The faulting
instruction is vmovdqu [r12], xmm0, and two instructions
earlier r12 is explicitly zeroed
(xor r12d, r12d) - not a stale return value. That
xor is the target of a je taken when the
preceding call [rax+0x10] - an indirect call through an
object's table - returns zero: on that branch the guest
zeroes r12 and writes through it. So 0xafcc08
is a virtual call returning null, not the tlsf size
path at all, and r14 = 0x20 was a coincidence. (The tlsf
message still prints elsewhere in the run - whether the null method
is the tlsf allocation wrapper is the open question.) The next
step the tool enables: an execute breakpoint at
0xafcc08 - a few bytes to capture rax, read
[rax+0x10] for the method's address, then break there to
see why it answers zero. This decision is the tool; that it overturned
its own first lead in one run rather than after an afternoon is the
argument for it.
Note the D450 non-determinism still applies: a run reaches
0xafcc08 or 0xb14be3 by a thread race, so the
breakpoint fires only on the runs that take its branch - which is why
the snapshot says how many times it hit.