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.

Read

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.

src/libs/agc.cpp — byte size of an indirect shader-register command
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.

Patch functions prove the point

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:

src/graphics/guest_gpu/graphicsRun.h
// 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

src/graphics/guest_gpu/graphicsRun.h — selected private fields
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:

src/graphics/guest_gpu/pm4.h
#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:

src/graphics/guest_gpu/pm4.cpp
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
PM4 type-3 header decoderedit the hex, or pick a preset

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:

src/graphics/guest_gpu/command_processor/pm4Dispatch.h
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:

…/pm4Handlers.cpp — a complete handler
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

MechanismWhat it isWhy 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

…/command_processor/commandProcessor.h
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.

Watch

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.

Try

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.

Then use it on real data

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.

Do

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.

  1. Run a title with --command-buffer-dump true and take the first few hundred dwords.
  2. Paste them into the decoder and find the first IT_DRAW_INDEX_AUTO (opcode 0x2D).
  3. Everything above it is state. Identify at least one IT_SET_CONTEXT_REG and one IT_SET_SH_REG and work out what each was configuring.
  4. Find the IT_RELEASE_MEM that 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