Step 25 · Graphics and performance
PM4 & the command processor
The CPU never talks to the GPU directly. It writes packets into a ring buffer and rings a doorbell. Everything the graphics half of this emulator does begins by reading those packets back.
Before this page: How a GPU actually draws · Bytes, bits and bounded reads. View the learning route.
The game never calls a draw function Kyty can intercept. It writes packets into a buffer and hands the buffer to the kernel. Understanding that distinction is the key to the whole graphics stack.
What an AGC call actually does
On a PC, vkCmdDraw goes into a driver that eventually produces hardware commands. On the PS5 the game is the driver. AGC functions are thin builders that write dwords into memory the game owns:
uint32_t* KYTY_SYSV_ABI AgcCbDispatch(CommandBuffer* buf, uint32_t thread_group_x,
uint32_t thread_group_y, uint32_t thread_group_z, ...);
uint32_t KYTY_SYSV_ABI AgcCbDispatchGetSize();
uint32_t* KYTY_SYSV_ABI AgcCbSetShRegistersDirect(CommandBuffer* buf, ...);
uint32_t* KYTY_SYSV_ABI AgcCbReleaseMem(CommandBuffer* buf, uint8_t action, ...);
Note the …GetSize() companions. A game asks for a command’s size in bytes so it can lay out its buffer in advance, then calls the builder to fill its dwords. Keep the return type and size unit attached to each API. This is why Kyty's reimplementations must produce byte-identical output to the real driver: the game may compute offsets into the buffer, patch values later, or jump over regions.
uint64_t KYTY_SYSV_ABI AgcDcbSetShRegistersIndirectGetSize() {
PRINT_NAME();
return 5u * sizeof(uint32_t); // 20 bytes, five dwords
}
src/libs/libAgcDriver.cpp registers this function as LIB_FUNC("nNlUtdDDvZ0", Gen5::AgcDcbSetShRegistersIndirectGetSize). Its declaration in agc.h, the returned byte count and the paired command builder are one ABI contract.
The AGC API includes functions such as AgcSetCxRegIndirectPatchSetAddress, AgcJumpPatchSetTarget and AgcWriteDataPatchSetAddressOrOffset. These exist so a game can build a command buffer once and rewrite specific fields each frame. A reimplementation that produced structurally different packets would break them.
Submission
When the buffer is ready the game submits it, and that call reaches GuestGpu::Submit:
// Submitted command memory is borrowed and must remain valid until GPU execution completes.
void Submit(std::span<const uint32_t> draw_commands,
std::span<const uint32_t> constant_commands);
void SubmitCompute(uint32_t queue, std::span<const uint32_t> commands);
Two buffers, not one — the draw stream and the constant stream, which correspond to the two engines described in PM4 & the command processor.
The current implementation does not copy either stream. A submission stores
borrowed std::span views into guest memory. This is closer to the hardware contract:
the submitter owns the memory and must keep it unchanged until execution completes. It also
makes lifetime bugs visible instead of hiding them behind an emulator-only copy.
The queue model
class GuestGpu {
private:
static constexpr uint32_t ComputePipeCount = 7;
static constexpr uint32_t QueuesPerComputePipe = 8;
static constexpr uint32_t ComputeQueueCount = ComputePipeCount * QueuesPerComputePipe; // 56
static constexpr uint32_t QueueCount = 1 + ComputeQueueCount; // 57
...
private:
std::array<std::deque<Submission>, QueueCount> m_queues;
std::deque<Common::UniqueFunction<void>> m_commands;
std::unique_ptr<CommandProcessor> m_gfx_cp;
std::array<std::unique_ptr<CommandProcessor>, ComputeQueueCount> m_compute_cp;
std::jthread m_thread;
};
One graphics queue plus seven compute pipes of eight queues each, mirroring the hardware. Each queue has its own CommandProcessor instance, because each carries independent register state. Games rely on this for asynchronous compute: work on different queues must progress independently.
The submission record
struct Submission {
SubmissionType type = SubmissionType::Graphics;
uint32_t queue_id = 0;
std::span<const uint32_t> commands;
std::span<const uint32_t> constant_commands;
Pm4Execution command_execution; // resumable cursor
Pm4Execution constant_execution;
bool started = false;
bool command_complete = false;
bool constant_complete = false;
bool blocked = false;
uint64_t flip_request_id = 0;
};
The blocked flag and the embedded Pm4Execution cursors are what let a submission that hits a wait packet be suspended and resumed later, while other queues keep running.
Talking to the GPU thread
void SendCommand(Common::UniqueFunction<void>&& command); // fire and forget void SendCommandSync(Common::UniqueFunction<void>&& command); // block until done
Both are re-entrancy aware — if you are already on the GPU thread they run the callable inline rather than deadlocking:
void GuestGpu::SendCommand(Common::UniqueFunction<void>&& command) {
EXIT_IF(!command);
if (IsGpuThread()) {
command();
return;
}
Common::LockGuard lock(m_queue_mutex);
EXIT_IF(!m_accepting);
m_commands.push_back(std::move(command));
m_pending_commands.fetch_add(1, std::memory_order_release);
m_work_available.Signal();
}
Kyty implements, in software, the front end of an AMD GPU: the unit that reads packets from memory and turns them into state changes and work.
Packet format
The stream is a sequence of 32-bit dwords. Each packet begins with a header whose top two bits give the type. Everything Kyty handles is type 3:
#define KYTY_PM4(len, op, r) \
(0xC0000000u | (((static_cast<uint16_t>(len) - 2u) & 0x3fffu) << 16u) | \
(((op) & 0xffu) << 8u) | (((r) & (Pm4::R_NUM - 1u)) << 2u))
#define KYTY_PM4_R(cmd_id) (((cmd_id) >> 2u) & (Pm4::R_NUM - 1u))
#define KYTY_PM4_LEN(cmd_id) ((((cmd_id) >> 16u) & 0x3fffu) + 2u)
And the extraction, from the packet dumper:
const auto packet_type = static_cast<PacketType>(cmd_id >> 30u); const bool sh_gx = (cmd_id & 0x2u) == 0; const uint8_t op = ((cmd_id >> 8u) & 0xffu); const auto r = KYTY_PM4_R(cmd_id); len = ((cmd_id >> 16u) & 0x3fffu) + 1; // body dwords
The colour bands map to the fields: type, count, opcode, then the Sony sub-selector. Note that the handlers assert on exact header values — CpOpDrawIndexAuto requires precisely 0xC0012D00 — so a malformed stream fails loudly rather than silently.
Dispatch by jump table
With around 50 opcodes and thousands of register offsets, a switch would be unmanageable. Kyty builds five function-pointer tables and indexes them directly:
extern const std::array<cp_op_parser_func_t, 256> g_cp_op_func; // by opcode extern const std::array<cp_op_parser_func_t, Pm4::R_NUM> g_cp_op_custom_func; // IT_NOP sub-ops extern const std::array<hw_ctx_parser_func_t, Pm4::CX_NUM> g_hw_ctx_func; // context registers extern const std::array<hw_sh_parser_func_t, Pm4::SH_NUM> g_hw_sh_func; // shader registers extern const std::array<hw_uc_parser_func_t, Pm4::UC_NUM> g_hw_uc_func; // user-config registers void GraphicsInitJmpTables();
A handler receives the header, a pointer to the body and the remaining size, and returns how many dwords it consumed — which is how multi-register writes advance correctly:
KYTY_CP_OP_PARSER(CpOpDrawIndexAuto) {
KYTY_PROFILER_FUNCTION();
EXIT_NOT_IMPLEMENTED(cmd_id != 0xc0012d00); // exact encoding expected
uint32_t index_count = buffer[0];
uint32_t flags = buffer[1];
EXIT_NOT_IMPLEMENTED((flags & ~0x22u) != 0); // only two flag bits are handled
cp.DrawIndexAuto(index_count, 0);
return 2; // consumed 2 body dwords
}
That is the pattern throughout pm4Handlers.cpp: assert on what is not understood, unpack the fields, call into CommandProcessor, report the size.
Mechanisms a real command processor has
| Mechanism | What it is | Why it matters |
|---|---|---|
| DE / CE counters | Two engines walk the stream. The Constant Engine runs ahead prefetching constant data; the Drawing Engine follows. IT_INCREMENT_CE_COUNTER, IT_WAIT_ON_CE_COUNTER and IT_WAIT_ON_DE_COUNTER_DIFF keep them in step. |
Get this wrong and constants are stale by a frame or two — drifting, hard-to-localise corruption. |
| Constant RAM | A 48 KB scratch area — uint32_t m_const_ram[0x3000] — that the CE writes and the DE dumps into memory. |
Heavily used for ring-buffered per-draw constants. |
| End-of-pipe events | IT_RELEASE_MEM / IT_EVENT_WRITE_EOP: "when everything before this has completed, write this value there, and optionally raise an interrupt." |
How the game learns a frame is done. Kyty maps these onto Vulkan timeline semaphores and its own event queues. |
| Indirect buffers | IT_INDIRECT_BUFFER is a call: jump into another buffer, then return. Pm4Execution keeps a stack of cursors. |
Engines split state into reusable chunks. Without this you see a fraction of the commands. |
| Predication | IT_SET_PREDICATION makes subsequent packets conditional on a memory value. |
Occlusion-culled draws must actually be skipped. ShouldSkipPredicatedPackets() gates them. |
| Blocking waits | IT_WAIT_REG_MEM spins until memory matches a condition. |
Kyty returns Pm4ProcessResult::Blocked and suspends the submission rather than busy-waiting, so a stalled graphics queue does not starve compute. |
The resumable cursor
class Pm4Execution {
public:
[[nodiscard]] bool MadeProgress() const noexcept { return m_made_progress; }
private:
friend class CommandProcessor;
struct BufferCursor {
std::span<const uint32_t> commands;
uint32_t offset_dw = 0;
};
std::vector<BufferCursor> m_buffer_stack;
std::span<const uint32_t> m_next_buffer;
bool m_chain = false;
bool m_suspended = false;
bool m_made_progress = false;
};
enum class Pm4ProcessResult { Complete, Blocked };
Pm4ProcessResult Process(Pm4Execution& execution, std::span<const uint32_t> commands);
m_made_progress lets the scheduler detect a queue that is blocked and getting nowhere, so it can move on instead of spinning.
A command buffer is a flat run of 32-bit words with no framing other than what each header declares. Watch the processor walk it: read header, decode opcode, consume the declared length, dispatch, repeat.
And the packets don't all arrive on one path. Graphics, compute and DMA work travel on separate queues, which is where a lot of the ordering complexity comes from.
Build a packet by hand. Pick an opcode, set a length, and watch the 32-bit header assemble bit by bit — then check it against the encoding in the Read section above.
Once the encoding makes sense, run an actual dump through it. Start the emulator with
--command-buffer-dump true, then paste the dwords into the
PM4 decoder in the Playground — it's built from
the same pm4.h constants and will name every packet in your own frame.
Worked example: one draw call, end to end
The shortest useful exercise here is to find a single draw in your own capture and account for every packet that set it up.
- Run a title with
--command-buffer-dump trueand take the first few hundred dwords. - Paste them into the decoder and find the
first
IT_DRAW_INDEX_AUTO(opcode0x2D). - Everything above it is state. Identify at least one
IT_SET_CONTEXT_REGand oneIT_SET_SH_REGand work out what each was configuring. - Find the
IT_RELEASE_MEMthat follows. That's the fence the CPU waits on — in the emulator it becomes a Vulkan timeline semaphore signal.
If a packet comes back as IT_UNKNOWN_…, you've found real work — that's the
starting point for teaching the command processor a new packet.
Checkpoint