PlayStation 2 GS Local Memory: PSM Swizzles, Pages, and CLUT Coherency
Trace PS2 GS pixels through 4 MiB local memory, format-dependent swizzles, indexed textures, palette loads, and render-target aliasing.
The PlayStation 2 Graphics Synthesizer (GS) does not store every framebuffer or texture as a host-style row-major image. Its 4 MiB local-memory array is addressed through format-dependent pixel-storage modes, page geometry, block ordering, and intra-block swizzles. Indexed textures add another address space in the form of a color lookup table (CLUT), whose source location and load policy are controlled separately from the texture’s index bytes. The same physical GS memory can also be viewed through different formats by later operations.
This makes GS memory a coherence problem as much as a texture-decoding problem. A render target may be sampled as a texture, a texture upload may overlap a framebuffer, and a 4-bit or 8-bit indexed image may be decoded through palette state cached from an earlier draw. A renderer that copies everything into separate linear buffers can look correct on ordinary scenes and fail when software intentionally aliases memory, changes a palette, uses a nonstandard buffer width, or wraps an allocation around local memory.
Local memory is a shared address space
PCSX2’s GSLocalMemory allocates a 4 MiB virtual memory array and associates format-specific operations with pixel-storage modes. The implementation distinguishes color, depth, 32-bit, 24-bit, 16-bit, 8-bit, and 4-bit pathways rather than forcing every access through one generic pixel routine. Some packed formats address a byte or nibble within a larger storage unit; depth formats can also use distinct swizzle tables.
The GS registers describe where an image begins, its buffer width, pixel-storage mode, and dimensions. These are not interchangeable with a host texture’s width and stride. A base pointer is expressed in GS memory units, while the buffer-width encoding is interpreted with format-specific rules. Page and block boundaries determine where rows continue. As a result, an address helper should take (x, y, base, buffer_width, PSM) as explicit inputs instead of relying on a global linear pitch.
PCSX2 models each format with a GSSwizzleInfo, page and block masks, lookup tables, and address transforms. GSOffset uses those structures to calculate a block number or pixel address and wraps block addresses across the finite local-memory space. This is a useful architecture for an emulator: keep format geometry in a table, and make all framebuffer, texture, transfer, and cache-overlap code call the same address contract.
The term “swizzle” is not a single permutation. There is page selection, block selection within a page, and pixel selection within a block. A 32-bit color layout and a 16-bit layout do not share the same pixel ordering; depth layouts can use XOR adjustments that differ from their color counterparts. A coordinate that is adjacent in screen space may therefore not be adjacent in memory. Converting one format to another requires reading through the source PSM’s mapping and writing through the destination PSM’s mapping.
Pages and blocks are format dependent
PCSX2’s local-memory format table provides page geometry by PSM. The default 32-bit page is 64 by 32 pixels, with a 4-byte pixel size, giving 8 KiB. A 16-bit page can hold twice as many pixels in the same storage, while 8-bit and 4-bit indexed modes pack still more coordinates into one page. The byte budget stays fixed while the pixel-coordinate dimensions change. Code that assumes all formats have a 64 by 32 page will use incorrect overlap boundaries for indexed textures.
Within that page, blocks are reordered using a table. The implementation stores a block lookup and pixel-row lookup separately, then uses page shifts and block shifts to identify which table entries apply. For depth formats, block-number XOR masks can redirect addresses. This is why a rectangle’s bottom-right pixel is not always the highest memory block in that rectangle: in a swizzled layout, traversal order can move backward inside a page.
Memory wrapping also matters. The address result is reduced modulo the supported GS memory space, so allocations near the end can alias the beginning. An overlap detector must account for that wrap rather than representing each image as one monotonically increasing host interval. PCSX2’s texture-cache code has explicit wrap and page-alignment handling; a simplistic start <= address < start + width*height*bpp test will not be sufficient for all PSMs.
When implementing an address decoder, validate it against more than one origin. For each PSM, test origin block alignment, coordinate increments that cross a block, a page, and the configured row width, then test a buffer that crosses the end of local memory. Include both color and depth variants. Assert round trips only where the format provides a meaningful one-to-one mapping; 24-bit storage, high-byte indexed modes, and sub-byte texels require byte- or nibble-aware expectations.
Indexed textures require a separate palette lifecycle
An indexed texture stores color indices, not final RGBA values. TEX0 describes the texture’s base pointer and PSM and also carries CLUT-related fields such as the palette base and palette storage mode. TEXCLUT contributes parameters for the CSM2 palette layout. The GS loads palette data into its CLUT state through a separate operation; the texture index then selects an entry from that loaded palette.
PCSX2’s GSClut source keeps this lifecycle explicit. It distinguishes indexed PSMs and color-storage modes, handles 4-bit and 8-bit index formats separately, and has separate CSM1 and CSM2 loading paths. The 8-bit palette has up to 256 entries and the 4-bit palette has 16 entries; palette subset selection is affected by CSA. A 16-bit CLUT entry must be expanded according to TEXA alpha rules before host rendering. A cache that keys only on the texture’s base pointer can therefore return the wrong colors when CBP, CPSM, CSA, palette mode, or alpha expansion state changes.
Palette load control (CLD) determines whether the CLUT should be reloaded or whether a saved palette base can be reused. The exact register semantics are encoded in the GS register definitions, while PCSX2’s dirty-state logic illustrates the consequences: not all register changes imply an immediate palette fetch, and not every draw needs a new palette upload. A renderer should separate the CLUT memory snapshot from the decoded host palette and invalidate the appropriate state when the source memory or load-control fields change.
The source palette is in the same local-memory space as render targets and textures. A draw can overwrite palette bytes that are later read as a CLUT. Conversely, an indexed texture can point at memory that was previously used as color data. Track the physical address and PSM interpretation, not an assumed ownership category. Cache invalidation must react to writes from drawing, image transfers, and copies, including page wrap and partial overlap.
A bounded memory-wrap fixture
The following fixture tests only the physical 4 MiB wrap rule used by a byte-addressed view of GS local memory. It deliberately does not compute a swizzled pixel location. That calculation still needs the selected PSM’s page, block, and pixel tables, plus the GS unit conversion for register base pointers.
GS_LOCAL_MEMORY_BYTES = 4 * 1024 * 1024
def wrap_gs_byte_address(address):
if address < 0:
raise ValueError("GS byte address must be non-negative")
return address % GS_LOCAL_MEMORY_BYTES
assert wrap_gs_byte_address(0) == 0
assert wrap_gs_byte_address(GS_LOCAL_MEMORY_BYTES - 1) == GS_LOCAL_MEMORY_BYTES - 1
assert wrap_gs_byte_address(GS_LOCAL_MEMORY_BYTES) == 0
assert wrap_gs_byte_address(GS_LOCAL_MEMORY_BYTES + 7) == 7
Use this fixture to test the storage boundary, then add table-driven tests that call the actual PSM-specific address calculator. Never treat the modulo operation as a substitute for the internal swizzle. A stronger test suite should compare a small matrix of pixel writes and reads to PCSX2’s GSOffset results for PSMCT32, PSMCT16, PSMT8, PSMT4, and corresponding depth formats.
Diagnosing texture and render-target corruption
When a texture appears scrambled, first log the TEX0 fields and the coordinates passed to the address decoder: base pointer, buffer width, PSM, texture dimensions, and palette state. Verify the pixel address for the top-left texel and for points on both sides of each block and page boundary. A pattern with unique adjacent colors makes permutation errors obvious. Do not begin by changing texture filtering or scaling; those are later stages and can hide an address mismatch.
For wrong colors in an indexed image, compare the raw index byte or nibble before investigating the palette. Then log CBP, CPSM, CSM, CSA, CLUT load control, and TEXA; verify the palette entry after loading and after alpha expansion. Test 4-bit and 8-bit paths separately. A 4-bit index selects a nibble and has a smaller palette domain, so using the 8-bit offset logic can produce plausible but consistently wrong colors.
For stale render-target textures, record writes that overlap a sampled rectangle in GS block space. Include both the producer PSM and consumer PSM, and invalidate at block or page granularity where safe. Test overlap at ordinary alignment and across the final page of GS memory. The renderer’s host cache may be linear or tiled, but it still needs a reliable mapping back to the shared GS address space.
Acceptance criteria
A reliable GS local-memory model provides one PSM-aware address source for raster writes, depth access, texture reads, image transfers, CLUT loads, and cache-overlap tests. It handles page and block swizzle, packed index formats, depth-specific layouts, finite-memory wrap, and palette invalidation as separate concerns. Its test traces make a mismatch reproducible from register state and coordinates instead of relying on a screenshot alone.
Related:
- PlayStation 2 VIF and GIF: DMA Paths, Vector Unpacking, and Graphics Handoffs
- PlayStation 2 IPU: Bitstream FIFO, Macroblock Decode, and DMA
Sources: