diff --git a/AGENTS.md b/AGENTS.md index c3c9962b89..8c5413b836 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ for it: | [docs/pspautotests.md](docs/pspautotests.md) | Workflow for improving PPSSPP using pspautotests | | [docs/pspautotests-hardware.md](docs/pspautotests-hardware.md) | Writing a new pspautotest, and running it on a real PSP over PSPLink to record its `.expected` | | [docs/frametest.md](docs/frametest.md) | Framedump rendering tests | +| [docs/sceGe.md](docs/sceGe.md) | How the firmware queues display lists, what SIGNAL and FINISH interrupts do and in which order, and how `ProcessDLQueue()` keeps that order | | [docs/sceAudio.md](docs/sceAudio.md) | How the audio output calls block, how deep they buffer, and what each error means | | [docs/WebSocketDebugger.md](docs/WebSocketDebugger.md) | WebSocket debugger protocol reference | | [docs/reverse-engineering.md](docs/reverse-engineering.md) | Disassembling a firmware PRX with `--re-module`, to find out what the hardware actually does | diff --git a/docs/pspautotests-hardware.md b/docs/pspautotests-hardware.md index 67329f07a4..a9369566c5 100644 --- a/docs/pspautotests-hardware.md +++ b/docs/pspautotests-hardware.md @@ -161,6 +161,10 @@ python3 test.py --graphics=software io/shortname/shortname `1.txt` rather than `1.TXT`. Anything testing FAT semantics has to run against `ms0:` - create a scratch directory on the real memory stick and clean it up - and anything reading `host0:` will only reproduce on the OS it was recorded on. +- **Start `usbhostfs_pc` yourself before the first `gentest.py` run in a scripted session.** When + `gentest.py` has to start it, the bridge inherits the script's stdout and keeps running, so + anything waiting for the pipe to close (`gentest.py ... | tail`, or an agent's shell tool) hangs + long after the `.expected` file has been written. With the port already open it returns at once. - **A test that hangs leaves the PSP wedged.** `gentest.py` issues `pspsh -e reset` after a timeout, but if you ran the PRX by hand, do that yourself. Default timeout is 10s; raise it with `-t SECONDS`. diff --git a/docs/sceGe.md b/docs/sceGe.md new file mode 100644 index 0000000000..fd8a2fd363 --- /dev/null +++ b/docs/sceGe.md @@ -0,0 +1,195 @@ +# sceGe: how the PSP's display list queue behaves + +This describes the behavior `GPU/GPUCommon.cpp` (the queue) and `Core/HLE/sceGe.cpp` (the +interrupt side) model. All of it is what a game can observe, and all of it is pinned down on a +real PSP by the tests in `pspautotests/tests/gpu/ge` (`queue`, `queue2`, `break`, `breakwait`, +`enqueueparam`) and `pspautotests/tests/gpu/signals`. + +The headline, because everything else follows from it: **the GE stops at every SIGNAL and every +FINISH, and it takes the interrupt to get it going again.** After a signal it carries on with the +same list. After a FINISH the game's finish callback runs *first*, and only then does the list +leave the queue and the next one start. Nothing the game calls in between makes the GE run. + +## Lists and their states + +Up to 64 lists can exist at once; with all of them in use, enqueueing fails with `0x80000022`. +Ids are opaque, and an id is reused once its list is gone. + +| State | Meaning | +|---|---| +| NONE | Not a list. `sceGeListSync(id, 1)` gives `0x80000100`. | +| QUEUED | On the queue, not executing. | +| RUNNING | The GE is executing it, or is stalled on it. | +| COMPLETED | Hit its FINISH and is off the queue. The id can still be asked about, until `sceGeDrawSync(0)` turns every COMPLETED list into NONE. | +| PAUSED | Stopped by `sceGeBreak(0)`, by a PAUSE signal, or put at the head with `sceGeListEnQueueHead`. | + +Besides its state, a list remembers whether it **has started executing** at some point +(`DisplayList::started`), which several checks below depend on, and whether something is pending +on it: a pause that was requested but hasn't been delivered, a pause that has been, or a SYNC. +`DisplayList::signal` holds that, in the same enum as the behaviors of the SIGNAL command, with +`PSP_GE_SIGNAL_HANDLER_SUSPEND` doubling as "paused, and the game has been told". + +## Enqueueing + +`sceGeListEnQueue` and `sceGeListEnQueueHead` differ only in where the list goes. In order: + +1. The list, stall, context and stack addresses all have to be 4-byte aligned (`0x80000103`), and + a stack depth of 256 or more is `0x80000104`. +2. Games built with SDK version `0x01FFFFFF` or below get a 32-entry stack they didn't ask for, + and the next step can't fail for them. Otherwise the stack is whatever `PspGeListArgs` says, + which may well be none. +3. **The new list is compared against every list on the queue:** + - Same list address: `0x80000021`. What's compared is the address the other list was + *enqueued* with, not where it has got to since, and the uncached mirror of an address + counts as the same. After `sceGeBreak(0)` stops a list, it's the address it stopped at that + collides instead, and the address it was enqueued with is free again. + - Same stack, **and the other list has started**: `0x80000021`. Two lists that are both still + waiting their turn can share a stack without complaint. +4. Then, depending: + - *Tail, queue empty:* RUNNING, and the GE starts on it right away. If it has a context, the + GE state is saved into it first. + - *Tail, queue not empty:* QUEUED at the back. + - *Head, queue not empty:* the current head has to be PAUSED, or it's `0x800001FE`. The new list + becomes PAUSED at the head, and the old head QUEUED behind it. + - *Head, queue empty:* PAUSED, and **not started**. It takes a `sceGeContinue` to run it. + +"Every list on the queue" includes a list whose finish callback is running right now. So a +finish callback that enqueues its own list again gets `0x80000021` (on a newer SDK), and anything +else it enqueues is QUEUED behind it rather than started. + +## SIGNAL + +A SIGNAL followed by END stops the GE and raises an interrupt. It always applies to the list at +the head of the queue. + +| Behavior | What happens | +|---|---| +| `HANDLER_SUSPEND` (1) | The signal callback runs **with the GE stopped**, then the GE carries on. For SDK <= `0x02000010`, the list reads as PAUSED for the duration. | +| `HANDLER_CONTINUE` (2) | The GE carries on, *then* the signal callback runs. | +| `HANDLER_PAUSE` (3) | The list becomes PAUSED **immediately**, the argument is remembered, and the GE carries on. No callback yet. See below. | +| `SYNC` (8) | The GE carries on, and the list's next FINISH is swallowed: no callback, no completion, it just keeps going. | +| `JUMP`, `CALL`, `RET`, and their relative and origin variants (0x10 - 0x16) | Done in software by the interrupt. CALL uses the list's stack; overflowing or underflowing it leaves the GE stopped. | +| 0x20 - 0x2F, 0x30, 0x38 | Set a texture or CLUT address relative to the list or to the offset address. PPSSPP doesn't implement these. | + +Callbacks get the low 16 bits of the SIGNAL (or FINISH) command, the argument they were +registered with, and - for SDK > `0x02000010` only, otherwise 0 - the address after the END. + +### The pause window + +Between a PAUSE signal and the FINISH that delivers it, a list is in an odd place. The GE is +still executing it, but: + +- `sceGeListSync(id, 1)` already says PAUSED. +- `sceGeContinue` returns `0x80000021`. So does `sceGeBreak(0)`. +- `sceGeListUpdateStallAddr` returns 0 but **has no effect on the GE**, because only a RUNNING + list's stall address does. So a list that stalls inside the window is stuck for good, and + `sceGeBreak(1)` is the only way out. Games therefore never put a stall there, and neither + should a test that wants to finish. + +When the FINISH arrives, the **signal** callback (not the finish one) is called, with the +argument from the PAUSE signal. The list stays PAUSED at the head of the queue, and nothing +behind it runs until the game calls `sceGeContinue`, which now works. + +## FINISH + +A FINISH followed by END stops the GE and raises an interrupt. If a SYNC or a pause is pending on +the list, see above. Otherwise, in this order: + +1. The list becomes COMPLETED. It is still at the head of the queue. +2. The **finish callback** runs. The GE is idle. From in here, `sceGeListSync` on the next list + says QUEUED, `sceGeDrawSync(1)` says DRAWING if there is a next list and COMPLETED if not, + `sceGeListDeQueue` on the finished list says `0x80000021`, and `sceGeListUpdateStallAddr` on + it says `0x80000020`. +3. If the list had a context, the GE state is restored from it. +4. The list leaves the queue. +5. The new head, if there is one and it isn't PAUSED, is started. If it has a context and hasn't + started before, the GE state is saved into it. +6. If the queue is empty instead, threads waiting in `sceGeDrawSync(0)` are woken. +7. Finally the threads waiting in `sceGeListSync(id, 0)` for the finished list are woken - after + the ones from step 6, which shows when they have the same priority. + +If a SIGNAL and a FINISH are both pending by the time the interrupt runs, only the FINISH counts. + +### How PPSSPP does this + +PPSSPP doesn't run the GE in parallel with the CPU. `ProcessDLQueue()` executes a list all the way +to whatever stops it - a stall, a SIGNAL, a FINISH - adding up how long that would have taken, and +schedules the interrupt for that point in the future. That's the accepted inaccuracy, and it means +there's a stretch where a list has reached its FINISH but the game hasn't been told, which doesn't +exist on hardware. `DisplayList::pendingInterrupt` marks it. + +What keeps the *sequencing* right in spite of that is one rule in `ProcessDLQueue()`: **while the +head of the queue has an interrupt pending, nothing runs**, whoever asks - an enqueue, a stall +update or a continue. The finished list stays on the queue until `InterruptEnd()`, which takes it +off and lets the next one go, as in steps 4 to 7 above. Before that rule, the next list ran right +away and the finished one left the queue at once, so a finish callback saw an empty queue: a list +it enqueued was started instead of queued, and then couldn't be dequeued. + +Two places deliberately treat a list with its FINISH pending as already gone, since on hardware it +would be: the duplicate check when enqueueing (Exit enqueues the same list again right after it +ends), and `drawCompleteTicks`, which is set when the last list reaches its FINISH rather than +when the interrupt is delivered, so that a `sceGeDrawSync(0)` in between doesn't wait for nothing. + +## Waiting + +`sceGeListSync(id, 0)` doesn't care what state the list is in. It waits for that id to complete or +be dequeued, and for an id that isn't pending - never used, or done - it returns 0 at once. + +`sceGeListSync(id, 1)`: + +| List state | Result | +|---|---| +| RUNNING | STALLING (3) if the GE is at the stall address, else DRAWING (2) | +| QUEUED | PAUSED (4) if it has started before (an EnQueueHead bumped it), else QUEUED (1) | +| COMPLETED | 0 | +| PAUSED | 4 | +| NONE | `0x80000100` | + +`sceGeDrawSync(0)` waits for the queue to drain, and then **turns every COMPLETED list into +NONE**. Nothing else does, short of `sceGeBreak(1)`. `sceGeDrawSync(1)` looks at the head of the +queue, or the one after if the head is COMPLETED, and answers STALLING or DRAWING depending on +whether the GE is at that list's stall address - which it can't be if it isn't on that list. + +## Break, continue, dequeue, stall + +`sceGeBreak(0)`, by the state of the head of the queue: + +| State | Result | +|---|---| +| RUNNING | The GE is stopped, and the list becomes PAUSED, remembering where it got to. If that was in the middle of a SIGNAL/END or FINISH/END pair, it resumes from the start of the pair. Returns the id. | +| QUEUED | Becomes PAUSED, id returned. | +| PAUSED | `0x80000021` for SDK <= `0x02000010` or inside the pause window, otherwise `0x80000020`. | +| no list | `0x80000020` | + +**`sceGeBreak(1)` throws the whole queue away** and resets the GE: every list becomes NONE. It +doesn't wake anybody. A thread in `sceGeListSync(id, 0)` or `sceGeDrawSync(0)` stays there - until +a *new* list happens to get the same id and completes, or the queue next drains, at which point +they return 0 as if nothing had happened. + +`sceGeContinue`: with no list, 0. A PAUSED head resumes where it left off (`0x80000021` inside the +pause window). On a RUNNING head, `0x80000020`; anything else, `0x80000004`. Both of those are -1 +for SDK <= `0x01FFFFFF`. + +`sceGeListDeQueue`: `0x80000100` for NONE, `0x80000021` for a list that has started - which +includes COMPLETED ones, until `sceGeDrawSync(0)` - and otherwise the list leaves the queue, +becomes NONE, and threads waiting for it are woken. + +`sceGeListUpdateStallAddr`: takes effect only if the list is RUNNING, and a GE stalled on that +list resumes by itself. For QUEUED and PAUSED lists it's just remembered for when they run. +COMPLETED is `0x80000020`. + +## Known leftovers in PPSSPP + +- `IgnoreEnqueue` in `compat.ini` (Metal Gear Acid 2, #10906) skips the stack check. It was most + likely hitting the not-started case, which no longer fails. If the game is fine without it, + remove it. +- The "Discarding display list with state NONE" workaround in `ProcessDLQueue()` (Crazy Taxi, + #19894) guards against a NONE list being on the queue. With the finished list now staying on + the queue until its interrupt is done, the known ways for that to happen are gone, but it + hasn't been proven unreachable. +- `sceGeBreak(0)` doesn't back up over a half-done SIGNAL or FINISH pair. With lists executing in + one go, it's rarely in the middle of one. +- Signal CALL and RET save and restore only the pc, the offset address and the base address. On + hardware the list's own CALL depth and return addresses are part of it as well. +- On hardware, a `sceGeListEnQueueHead` that fails with `0x800001FE` still uses up one of the 64 + lists until the next `sceGeBreak(1)`. Not emulated, and not worth it.