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.

First pass

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.

Implementation reference

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

SpacePacketHoldsKyty type
Context (CX)IT_SET_CONTEXT_REGPer-draw pipeline state: render targets, depth/stencil, blend, viewports, scissors, rasteriser modes.HW::Context
Shader (SH)IT_SET_SH_REGShader addresses, resource counts, and the user-data registers of this topic.HW::Shader
User config (UC)IT_SET_UCONFIG_REGGlobal 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:

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

src/graphics/shader/shaderBindings.h
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:

src/graphics/shader/shaderBindings.h — V#, buffer resource
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.
};
shaderBindings.h — T#, image resource (8 dwords)
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:

1 · Typed SSA valueskytyTranslateProgram creates typed values; RewriteToSsa and simplification expose where descriptor-address inputs came from.
↓
2 · TrackResourceskytyWalks those values and records descriptor-table reads. Constant offsets get flattened slots; genuinely dynamic reads stay explicit.
↓ at draw time, every draw
3 · MaterializeResourceskytyExecutes the recipe against the current user data and guest memory, producing a concrete 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:

src/graphics/shader/recompiler/ir/ShaderIR.h — SrtRead, ResourcePlan, Program
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;
    ...
};
src/graphics/shader/recompiler/ir/ResourceSnapshot.h
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:

src/graphics/host_gpu/renderer/pipeline/pipelineCache.cpp — ProgramCache::Get()
// 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.

Watch

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.

Try

Take a raw register value apart into the fields the hardware defines. Change bits and watch which piece of pipeline state moves.

Do

Use a real capture if you have one — the register values in your own frame are more instructive than any example.

Checkpoint