D386 - The seventh argument is on the stack, and nothing was reading it

assumed - 2026-08-30

A client lists a directory on a PlayStation 5 FTP server running under orbistoun:

CWD /  -> 250 Directory changed.
PASV   -> 227 Entering Passive Mode (127,0,0,1,236,244).
LIST   -> 150 File status okay; about to open data connection.

drwxr-xr-x 1 ftp ftp          0 Jan 01 00:00 app0
drwxr-xr-x 1 ftp ftp          0 Jan 01 00:00 data

       -> 226 Closing data connection. Transfer complete.

Three things stood between the login (D385) and that, and the third is the one that had been quietly wrong for the whole project.

A root that lists its mount points

/app0 and /data are directories a guest can enter and no host directory holds them: they are prefixes this project maps onto host roots that live somewhere else entirely. So resolve("/") answered nothing, opendir("/") found nothing, and CWD / - the first thing an FTP client does - was refused with 550 Not a directory.

The mount table is the only thing that knows those names exist, so it says so: mounts_under("/") answers app0 and data, and a mount at /system_data/priv would make /system_data a directory too, holding priv. Only the next component, never a whole prefix, because that is what a directory entry is.

stat gets the same treatment, with zero size and zero times - it is not a directory on any disk, so there is no modification time to report and inventing one would be a fact about nothing.

This is the answer to a question that had been sitting open as what does a console's directory tree look like. It is not that question. The tree is data with provenance, and what was missing was the shape - and the shape is knowable from the mount table alone.

realpath, which is how a server decides a path is real

CWD / then moved from 550 Not a directory. to 550 Invalid path. - a different refusal, which is what progress looks like. zftpd canonicalises every path a client names, got the placeholder back, and refused it.

The answer is a guest path. Handing back C:\titles\PPSA00000 would be a true fact about this machine and a lie about the platform, and the guest would pass it straight to open. So the components are walked here rather than by std::fs::canonicalize, and .. above the root stays at the root - which is what every filesystem does and what stops a path leaving the mount table by spelling alone.

The seventh argument, which was never being read

With the root browsable, the replies came back mutilated:

PWD  -> 257
PASV -> 227
[FTP][INFO]

Every one truncated at its first conversion. 227 Entering Passive Mode (%d,%d,%d,%d,%d,%d) is a format, a buffer, a size and six numbers - nine arguments, and System V passes six in registers. snprintf spends three of those on the buffer, the size and the format, so three were left for six conversions and the renderer stopped when it ran out.

It stopped quietly, which is correct behaviour for a renderer whose arguments have run out and is exactly why nobody had noticed. The dispatcher's own documentation said so plainly - the seventh argument onwards is on the guest stack and is not captured here - a known gap, written down, and never connected to the truncated output it was causing.

The trampoline already carries entry_rsp, so the overflow area is one addition away: [entry_rsp] is the return address the guest's call pushed, and the arguments that did not fit start at [entry_rsp + 8]. That is the overflow_arg_area the psABI defines, reached from the other side. It is published for the length of the call in a thread-local, saved and restored so a nested call cannot blind the outer one.

What it does not fix: the count is still the format string's word. Reading past what the guest passed gives whatever the stack held - the same risk a real printf has, for the same reason, bounded here at sixty-four words so a wrong format stops rather than walks.

And then the width, which had been wrong all along

The first thing to read a stack argument logged RES=-4294967296. That is 0xFFFF_FFFF_0000_0000: a zero with somebody else's bits above it.

%d is an int. Thirty-two bits. The renderer had been reading sixty-four and discarding the length modifier, with a comment explaining why that was fine - every integer argument arrives as a full register, and the conversion decides how much of it means anything.

The first half was true, and it was doing all the work. A caller storing an int writes edi, and writing a 32-bit register zeroes the upper half of the 64-bit one - so reading all sixty-four bits gave the right answer by accident, on every argument, for as long as every argument was a register. An argument on the stack sits in an eight-byte slot whose upper half is unspecified for anything narrower, and the accident stopped.

So the modifier is tracked rather than discarded: int by default, h/hh narrower, l, z, j, t the whole word, and a pointer always the whole word. %u of -1 is 4294967295 and %lu is 18446744073709551615, which are different questions about the same register and must not answer the same.

The comment was the bug's alibi. It named a real property, drew a conclusion that happened to hold, and stopped anybody asking again for as long as it held. A test asserted the conclusion rather than the property - a length modifier is consumed and changes nothing - which made it doctrine.