Step 26 · Graphics and performance
Registers, sharps & resources
Register-write packets do not draw anything — they accumulate into a shadow copy of the hardware register file. When a draw finally arrives, all of that has to become concrete Vulkan state, and the shader's resources have to be found through a chain of packed descriptors.
Before this page: PM4 & the command processor. View the learning route.
From state to a draw
Register-write packets update stored state; a later draw consumes it. A descriptor describes a resource such as a texture or buffer. Think of a draw as combining the current state, a shader program and its resources. A register value that looks like an address is not enough: its format, extent and lifetime also matter.
First read the register-space table and follow one descriptor in the tools. The resource-plan and specialization sections are reference material for tracing the current implementation.
Why can a draw packet be short?
Earlier packets already established the state. To understand a draw, reconstruct the relevant preceding writes and resources; the draw packet alone is insufficient.
Use the code-example guide: snippets below are source excerpts or labelled schematic sequences, not standalone programs.
A draw packet carries almost no information. Everything that matters — targets, blend modes, shaders, viewports — arrived earlier as register writes. Modelling that register file is a substantial part of the guest GPU code.
Three register spaces
| Space | Packet | Holds | Kyty type |
|---|---|---|---|
| Context (CX) | IT_SET_CONTEXT_REG | Per-draw pipeline state: render targets, depth/stencil, blend, viewports, scissors, rasteriser modes. | HW::Context |
| Shader (SH) | IT_SET_SH_REG | Shader addresses, resource counts, and the user-data registers of this topic. | HW::Shader |
| User config (UC) | IT_SET_UCONFIG_REG | Global settings: primitive type, index type, object IDs, gradient factors. | HW::UserConfig |
Each CommandProcessor owns one of each, so queues never contaminate one another:
HW::Context& GetCtx() { return m_ctx; }
HW::UserConfig& GetUcfg() { return m_ucfg; }
HW::Shader& GetShCtx() { return m_sh_ctx; }
Registers become typed structures
Kyty does not store raw dwords. Each handler unpacks bit fields into a named struct, which is what makes the renderer readable:
struct ColorInfo {
bool fmask_compression_enable = false;
bool fmask_data_compression_disable = false;
bool fmask_one_frag_mode = false;
bool cmask_fast_clear_enable = false;
bool dcc_compression_enable = false;
bool blend_clamp = false;
bool blend_bypass = false;
bool round_mode = false;
Prospero::ChannelLayout format = Prospero::ChannelLayout::kInvalid;
Prospero::ChannelType channel_type = Prospero::ChannelType::kUNorm;
Prospero::ChannelOrder channel_order = Prospero::ChannelOrder::kStandard;
};
struct RenderTarget {
ColorBase base; // 64-bit GPU address
ColorPitch pitch;
ColorSlice slice;
ColorView view; // array slices, mip level
ColorInfo info;
ColorAttrib attrib; // tile mode, samples, fragments
ColorAttrib2 attrib2; // width, height, mip count
ColorAttrib3 attrib3; // depth, dimension, pipe alignment
ColorDccControl dcc;
ColorCmask cmask; // fast-clear metadata
ColorFmask fmask; // MSAA fragment mask
ColorClearWord0 clear_word0;
ColorDccAddr dcc_addr;
...
};
Extraction uses a macro that names the field explicitly:
#define KYTY_PM4_GET(u, r, f) (((u) >> Pm4::r##_##f##_SHIFT) & Pm4::r##_##f##_MASK) r.polymode_front_ptype = KYTY_PM4_GET(buffer[0], PA_SU_SC_MODE_CNTL, POLYMODE_FRONT_PTYPE);
The shift and mask constants come from pm4.h, which defines the RDNA 2 fields the PS5 driver writes — including DCC_ENABLE on CB_COLOR0_INFO at bit 28.
Why agcRegisterDefaults.inc is 213 KB
The largest file in the repository is a table of default register values. It exists because of how the real driver works: at context reset it writes a complete default state, and games then only set what they change. If Kyty's defaults differ anywhere, state leaks between draws in ways that are almost impossible to attribute — a blend mode set by one draw silently applying to the next.
Games can also ask for these defaults directly, which is why AgcGetRegisterDefaults2 is an exported function.
Register decoding versus draw validation
DecodeRenderControl() in pm4Handlers.cpp records DB_RENDER_CONTROL, including copy_depth_to_color and copy_stencil_to_color, without rejecting those bits immediately. A later state write can replace them before a draw uses them. RenderExecutor::ResolveRenderDepthTarget() in src/graphics/host_gpu/renderer/depthRenderTarget.cpp handles inactive or unbound attachments, then rejects unsupported depth-copy state for a draw with a bound attachment. Accepting a register write does not mean the corresponding rendering operation is implemented.
Enumerations from the guest's world
The register values are console enums, and gpu_defs.h declares them all in a Prospero namespace:
enum class PrimitiveType : uint32_t {
kNone = 0, kPointList = 1, kLineList = 2, kLineStrip = 3,
kTriList = 4, kTriFan = 5, kTriStrip = 6, kRectList = 7, kPatch = 9,
kLineListAdjacency = 10, ..., kQuadListLegacy = 19, kPolygon = 21,
};
enum class TileMode : uint32_t {
kLinear = 0x00, kStandard256B = 0x01, kStandard4KB = 0x05, kStandard64KB = 0x09,
kPrt = 0x11, kDepth = 0x18, kRenderTarget = 0x1b,
};
enum class BlendFactor : uint32_t { kZero = 0, kOne = 1, kSrcColor = 2, ... };
enum class BufferFormat : uint32_t { kInvalid = 0, k8UNorm = 1, ..., kBc7Srgb = 182 };
kRectList is worth noting: a three-vertex primitive describing an axis-aligned rectangle, used constantly for full-screen passes. Vulkan has no direct equivalent; Kyty handles the required pipeline path automatically and can synthesize tessellation-control/evaluation shaders with BuildRectListShaders().
Colour component remapping
A neat piece of design worth calling out. The shader exports RGBA, but the render target's channel order may differ. Rather than recompiling for every permutation, Kyty carries a one-byte mapping:
struct ColorComponentMapping {
uint8_t packed = 0xe4u; // RGBA identity: 0, 1, 2, 3 packed 2 bits each
constexpr uint32_t Map(uint32_t component) const {
return component < 4u ? (packed >> (component * 2u)) & 0x3u : component;
}
constexpr bool IsIdentity() const { return packed == 0xe4u; }
};
inline constexpr ColorComponentMapping ColorMappingAbgr {0x1bu}; // 3, 2, 1, 0
inline constexpr ColorComponentMapping ColorMappingBgra {0xc6u}; // 2, 1, 0, 3
One byte, cheap to carry inside shader and pipeline cache keys — which is exactly where it needs to live.
How a shader finds its textures. There is no analogue in any PC graphics API, and it is the reason the shader recompiler needs a dataflow analysis.
User data: the only channel into a shader
A shader stage receives up to 64 dwords in its first scalar registers, written by IT_SET_SH_REG packets. That is the entire parameter-passing mechanism. Inside those dwords a game may place, in any arrangement it chooses:
- An inline descriptor — a V#, T# or S# sitting directly in registers.
- A pointer to a table in memory containing descriptors, which the shader loads with
s_load_dwordx4. Tables may point at further tables, to arbitrary depth. This is the shader resource table, or SRT. - Plain constants — a vertex offset, a flag, an index.
The AGC layer names the well-known slots:
enum class AgcDirectResourceType : uint32_t {
GdsCounterRange = 0,
ShaderResourceTable = 1,
SubPtrFetchShader = 2,
PtrSoBufferTable = 3,
PtrInternalGlobalTable = 4,
PtrExtendedUserData = 5,
GsFlags = 6,
GdsMemoryRange = 7,
PtrVertexBufferTable = 8,
StereoXOffset = 9,
PtrVertexAttribDescTable = 10,
};
The three descriptor types
You decoded these interactively in How a GPU actually draws. Here are the actual accessors, which double as documentation of the bit layout:
struct ShaderBufferResource {
uint32_t fields[4] = {0};
[[nodiscard]] uint64_t Base48() const {
return (fields[0] | (static_cast<uint64_t>(fields[1]) << 32u)) & 0xFFFFFFFFFFFFu;
}
[[nodiscard]] uint16_t Stride() const { return (fields[1] >> 16u) & 0x3FFFu; }
[[nodiscard]] uint32_t NumRecords() const { return fields[2]; }
[[nodiscard]] uint64_t GetSize() const {
// The 14-bit stride and 32-bit record count require a 64-bit product.
return Stride() == 0 ? NumRecords() : static_cast<uint64_t>(Stride()) * NumRecords();
}
[[nodiscard]] uint8_t RawFormat() const { return (fields[3] >> 12u) & 0x7Fu; }
[[nodiscard]] Prospero::BufferFormat Format() const {
return static_cast<Prospero::BufferFormat>(RawFormat());
}
[[nodiscard]] uint8_t OutOfBounds() const { return (fields[3] >> 28u) & 0x3u; }
[[nodiscard]] uint8_t Type() const { return (fields[3] >> 30u) & 0x3u; }
// Other accessors omitted.
};
struct ShaderTextureResource {
uint32_t fields[8] = {0};
[[nodiscard]] uint64_t Base40() const {
return ((fields[0] | (static_cast<uint64_t>(fields[1]) << 32u)) & 0xFFFFFFFFFFu) << 8u;
}
[[nodiscard]] Prospero::BufferFormat Format() const {
return static_cast<Prospero::BufferFormat>((fields[1] >> 20u) & 0x1FFu);
}
[[nodiscard]] uint16_t Width5() const {
return ((fields[1] >> 30u) & 0x3u) | (((fields[2] >> 0u) & 0xFFFu) << 2u);
}
[[nodiscard]] uint16_t Height5() const { return (fields[2] >> 14u) & 0x3FFFu; }
[[nodiscard]] uint8_t BaseLevel() const { return (fields[3] >> 12u) & 0xFu; }
[[nodiscard]] uint8_t LastLevel() const { return (fields[3] >> 16u) & 0xFu; }
[[nodiscard]] Prospero::TileMode TileMode() const {
return static_cast<Prospero::TileMode>((fields[3] >> 20u) & 0x1Fu);
}
[[nodiscard]] Prospero::ImageType Type() const {
return static_cast<Prospero::ImageType>((fields[3] >> 28u) & 0xFu);
}
[[nodiscard]] uint64_t MetaAddr() const {
return ((fields[6] >> 24u) & 0xFFu) | (static_cast<uint64_t>(fields[7]) << 8u);
}
// Other accessors omitted.
};
Notice how much a T# carries: not just address and size but tiling mode, mip range, array slices, swizzle, and the address of a separate compression-metadata surface. All of that has to be translated into a Vulkan image with a compatible format and layout.
The problem this creates
Suppose a pixel shader samples a texture. In the machine code you will see something like:
s_load_dwordx4 s[8:11], s[0:1], 0x20 // load a pointer from user data + 0x20 s_load_dwordx8 s[12:19], s[8:9], 0x40 // load a T# from that table + 0x40 image_sample v[0:3], v[4:5], s[12:19], s[20:23] dmask:0xf
To bind a Vulkan descriptor, Kyty must determine which texture s[12:19] will hold — and that requires evaluating the shader's own address arithmetic before the shader ever runs. Current code does this through three layers:
TranslateProgram creates typed values; RewriteToSsa and simplification expose where descriptor-address inputs came from.ResourceSnapshot: this exact address, this format, this size — plus the ResourceSpecialization the SPIR-V is compiled against.The plan lives on IR::ResourcePlan, the retained analysis part of the program that the shader cache keeps after the translated blocks have been thrown away. IR::Program derives from it and adds the blocks themselves:
struct SrtRead {
Value value; // typed value that performs the read
uint32_t flat_offset = 0; // slot in the flattened buffer
};
// Retained resource analysis; evaluation scratch is mutable and renderer-serialized.
struct ResourcePlan {
ShaderType stage;
uint64_t shader_hash;
uint32_t user_data_base, user_data_count = 64;
std::list<Inst> value_storage; // the values the reads depend on
std::vector<DescriptorSource> descriptor_sources;
std::vector<ResourceBlock> control_flow; // uniform branches around resources
std::vector<SrtRead> srt_reads;
bool requires_specialization_memory, srt_plan_complete, resource_tracking_complete;
ShaderInfo info;
UniformFillPlan uniform_fill;
};
struct Program : ResourcePlan {
BlockList blocks; // only while translating and emitting
bool dispatcher_fallback;
BindingLayout bindings;
...
};
struct DescriptorValue {
std::array<uint32_t, 8> dwords = {}; // a materialised V#, T# or S#
uint32_t dword_count = 0;
};
struct ResourceSnapshot {
std::vector<DescriptorValue> buffers, images, samplers;
std::vector<uint32_t> flattened_srt; // the constant SRT reads, already resolved
std::vector<uint32_t> user_data;
UniformFill uniform_fill; // a whole-buffer fill detected from uniform stores
};
The retained ResourcePlan owns immutable descriptor/SRT analysis and reusable evaluation scratch. TrackResources(ir, decoded, native_cfg) performs scalar-read and indirect-image planning; there is no separate SRT-plan call in the top-level pipeline. ExtractResourcePlan() derives stable clean-slot and specialization policy once. A shared memoized SrtWalker refreshes descriptors, active branches, flattened reads and specialization in place. Bounded indirect image tables, guarded Phis and GPU-selected raw buffers follow this same value graph.
ShaderStageRuntime borrows two pointers: compiled program info and the cache entry's current ResourceSnapshot. The renderer consumes them under its serialization contract; keeping that snapshot as an immutable per-draw copy would describe the former implementation.
Why compilation and binding are separate
The consequence is important: the stable plan analysis is extracted once per source entry, and the cached snapshot is refreshed in place from current user data on each lookup. The recompiler itself is split the same way — TranslateProgram() produces the plan, CompileProgram() emits SPIR-V for one specialization — and the pipeline cache sits between them:
// Schematic ProgramCache::Get flow:
if (source_exists) {
EXIT_IF(!IR::MaterializeResources(plan, runtime, resources, specialization));
if (matching_permutation) return borrowed_stage_runtime;
}
auto translated = ShaderRecompiler::TranslateProgram(params.code, options);
// First source miss: extract plan and materialize resources here.
auto result = ShaderRecompiler::CompileProgram(std::move(translated), options,
specialization, push_data_start_dword);
The renderer borrows the refreshed snapshot through ShaderStageRuntime { const IR::CompiledShaderInfo* program; const IR::ResourceSnapshot* resources; } in shader.h, and binding preparation writes descriptors from it. A descriptor read that cannot be satisfied is an EXIT_IF.
Following a resource from a user-data register, through the shader resource table, to the actual buffer. This indirection is why a wrong pointer here produces garbled geometry rather than a clean crash.
Take a raw register value apart into the fields the hardware defines. Change bits and watch which piece of pipeline state moves.
Use a real capture if you have one — the register values in your own frame are more instructive than any example.
Checkpoint