How a PS5 executable becomes a native Linux or Windows program. Nothing is emulated: the converted executable runs as a normal process and calls native implementations of the system libraries.
flowchart LR
subgraph input["PS5 game"]
elf["input.elf"]
mods["sce_module/*"]
end
subgraph relinker["relinker (core/relinker)"]
direction TB
intel["--to-intel:<br/>lower AMD-only instructions<br/>(codegen)"]
pipeline["RelinkerPipeline:<br/>read imports by NID,<br/>check syscalls, filter unused NIDs,<br/>build SysV dynamic section"]
guest["GuestModuleBuilder:<br/>convert bundled modules"]
patcher["LinuxElfPatcher / WindowsPePatcher"]
intel -.-> pipeline --> guest --> patcher
end
subgraph build["build (core/libs)"]
direction TB
prx["core/libs/prx/*<br/>shared libraries"]
nid["nid_patcher:<br/>rename exports to their NIDs"]
prx --> nid
end
subgraph out["Native program"]
app["app.elf / app.exe"]
app0["app0/sce_module/*"]
libs["libs/*.prx"]
end
elf --> intel
mods --> guest
patcher --> app
guest --> app0
nid --> libs
app -- "OS loader binds imports by NID" --> libs
app0 -- "OS loader binds imports by NID" --> libs
core/relinker/main.cppruns the steps in this order.--to-intelis optional, see USAGE.md.- Each library in
core/libs/prxbuilds as a shared library. After the build,nid_patcher(core/libs/nid) renames every export to its NID, computed from the function name.APS5_EXPORT("<nid>", func)sets the NID directly when the name is unknown. Every other library keeps the names libc exports; it reads them fromlibc.exports, whichnid_patcher --write-export-listrewrites only when that list changes. On Windows the link writes each import library withSOURCE_DATE_EPOCH=0andranlib -Dand replaces it only when its bytes change. A source edit that keeps the exports therefore relinks and re-patches only its own library.
Runtime-loaded Windows modules export __aps5_guest_initialize: the entry RVA, the initializer count and the RVAs of initializer pointer slots. DynamicLoader.cpp consumes the first two words in the mapped image after initialization, so another reference to that image does not run it again. Unloading the image discards this state; a later load reads the original table from the file, including when Windows reuses the same base address. Native references outside the guest handle table retain the same initialized image.
While initialization runs, the count is UINT32_MAX and the entry word holds the initializing Windows thread ID. A different thread waits on the loader's condition variable; the owning thread can recursively open its module. Completion clears both words, restores the table's original page protection and wakes waiters. An exception leaves the count at UINT32_MAX with thread ID 0, so another load rejects that image until it is unloaded. The table's file format is unchanged and its initializer slots remain intact.
GuestDeferredModule.cpp checks repeated loads at the same base, multiple guest references, an external native reference, concurrent initialization and recursive loading. The Windows fixture uses the same table layout as the relinker and reports its entry and constructor calls separately.
Thread.cpp records cancellation requests on the target guest thread. New threads start with cancellation enabled and deferred. A disabled request stays pending; enabling deferred cancellation returns before the next cancellation point acts on it. Repeated requests do not add cleanup invocations.
Cancel.hpp connects condition, semaphore and join waits to the request. A canceled condition waiter reacquires its guest mutex before cleanup. Semaphore and join waits release their internal locks before exiting. A request pending on entry to a semaphore wait is acted on before consuming an available token; a canceled join leaves its target joinable and its result output untouched.
Cancellation exits through scePthreadExit with result pointer 1. It runs cleanup handlers in reverse push order before thread-specific destructors; cancellation points inside cleanup must not start another exit. The host thread then terminates through the existing Windows or Linux lifecycle, and join waits for that termination. Host thread termination is not used to inject cancellation into a running guest frame.
The reference contract and constants come from FreeBSD libthr, pinned at 5ed7eb0d, with cleanup and exit ordering in thr_exit.c. These are reference semantics, not console measurements. The supported cancellation points and incomplete asynchronous behavior are listed in TechnicalDebt.
GuestPthreadCancelRecovery.cpp checks pending requests before ordinary and timed semaphore acquisition, cleanup reentrancy and destructor ordering, enabled and disabled timed condition waits, canceled join output, and subsequent resource and thread reuse. Host atomic gates establish pending requests; acquiring the condition mutex after the worker releases it establishes wait entry without a scheduling sleep. Both guest_pthread_cancel_recovery and guest_pthread_cancel_recovery_coarse retain checks in Release and have a 20-second timeout, shorter than the 60-second guest waits. The latter sets APS5_COARSE_TIMED_WAITS=1 to exercise the Windows fallback; Linux uses that condition-variable path by default.
Apr.cpp executes APR and AMM command buffers. A buffer runs on the submitting thread until a WaitOnAddress or WaitOnCounter that is not yet satisfied; the rest of it, and every buffer submitted after it, is copied and runs on a worker thread, so a submitter that satisfies the wait after the submit returns does not hang. Buffers without such a wait still complete before the submit returns.
APR and AMM each have their own queue and worker thread. Order is kept inside a queue; a wait in one does not hold up the other, because AMM WriteAddress commands advance the counters that APR buffers wait on. Submission ids are per queue, and sceKernelAprWaitCommandBuffer and sceAmprAmmWaitCommandBufferCompletion block until the queue has completed that id. A command that throws on a worker aborts the process.
GuestAmpr.cpp checks both cases: a submit whose wait is satisfied afterwards returns and keeps buffer order, and an AMM buffer submitted after a waiting APR buffer still completes.
flowchart LR
game["Game:<br/>command buffers"] --> submit["libSceAgcDriver/Submit<br/>DCB / ACB"]
submit --> pm4["Execution/Pm4:<br/>state, draws,<br/>dispatches"]
pm4 -- "shader + state" --> cache{"Compiled variant<br/>in memory or<br/>on disk?"}
cache -- yes --> vk
cache -- no --> dec
subgraph recompiler["core/shader/recompiler"]
dec["RdnaDecoder"] --> cf["ControlFlow:<br/>graph + structurize"]
cf --> tr["Translation:<br/>RDNA to IR"]
tr --> opt["Optimization:<br/>SSA, resources,<br/>bindings"]
opt --> spv["SpirvBackend:<br/>emit SPIR-V"]
end
spv --> vk["libSceAgcDriver/Graphics:<br/>Vulkan pipeline"]
Recompiler.cppruns the stages in this order. WithANYPS5_ENABLE_SPIRV_TOOLS, the SPIR-V is also validated and optimized with SPIRV-Tools.ShaderRecompiler::Recompilekeeps compiled variants in memory, andShaderDiskCachestores them on disk so later runs reuse them. Before the writable cache, a miss is also looked up in read-only prebuilt packs and loose entry folders undershader_cache/prebuilt; pack entries are ordinary encoded cache entries behind a sorted key-hash index, validated by magic, compiler source version and index checksum when mapped, and a miss or rejected entry falls through to the writable cache and the compiler. Cache format 35 also stores the finite stencil-reference set, its replay-safety proof and reserved push-data word in the artifact and binding layouts. Entries from formats 31 to 34 are rejected.- The Vulkan pipelines are journaled as well (
PipelineJournal.cpp): a graphics pipeline the store builds (CachedPipeline) or a compute pipeline a dispatch builds is appended topipelines.journalnext to the shader cache entries, as the materialized result of each stage (the specialized SPIR-V, bindings and push constants, throughShaderDiskCache::EncodeResult) plus the recipe the pipeline was built from (GraphicsPipelineRecipe: the stages' identities, vertex input layout, descriptor set layout keys and the state fields thePipelineconstructor reads,CanonicalPipelineState). When aVulkanDeviceis created, the journal is loaded and its pipelines are built on a pool ofhardware_concurrency() / 2threads (APS5_PIPELINE_JOURNAL_THREADS) while the title loads, so the first draw or dispatch of a configuration seen on an earlier run finds its pipeline resident. The pipeline stores key onRecompileResult::Identity, a hash of the module words, specialization constants, subgroup width and bindings that is the same on every run (SharedSpirv::ContentHash, computed once per shared module), rather than on the process-lifetime variant ids, and a graphics pipeline is built outside the store's mutex, so the replay threads and the queue workers build in parallel. A draw that arrives before its replayed entry builds the pipeline itself; the first inserted wins. A replayed entry enters the store at its least recently used end and a full store takes none, so the title's own pipelines are never evicted for a replayed one, and the optimized relink of a replayed pipeline (OptimizedPipeline::Schedule) waits for the first draw that binds it, so the relink thread serves the title's pipelines first. A device created without a window starts its replay at its first draw or dispatch rather than at creation, sinceDriver::AttachWindowusually replaces it before the title draws. The journal records a pipeline once (modules and recipes are deduplicated by content), keeps at mostAPS5_PIPELINE_JOURNAL_MAX_MIB(1024), replays the firstAPS5_PIPELINE_JOURNAL_ENTRIESrecorded (1024, the pipeline store's bound,APS5_PIPELINE_STORE_ENTRIES; a run reaches those first), and is disabled withAPS5_NO_PIPELINE_JOURNAL=1or without a shader cache directory. The file header names the device (PipelineJournalDeviceFingerprint, the properties the driver's pipeline cache file is named by), so a journal written for another GPU or driver is started over rather than replayed; appends and reads holdpipelines.journal.lockagainst another process sharing the directory, and a device cancels its replay before it is replaced (PrepareForReplacement) or destroyed, so a replaced headless device never inserts entries the window's device then sweeps.APS5_PROFILE_DRAW=1prints a[pipeline-journal]summary when the replay finishes and at teardown. - A compute shader whose LDS plus its lock dword exceeds the host's
maxComputeSharedMemorySizeis not given aWorkgrouparray.RecompilesetsShaderInfo::sharedMemoryBytesfor it and the SPIR-V backend addresses the LDS in a coherent storage buffer at descriptor set 1 (WorkgroupMemoryDescriptorSet), with a slice ofRecompileResult::workgroupMemoryDwordsper workgroup, indexed byWorkgroupIDandNumWorkGroups.VulkanDevicekeeps one such buffer, sized from the dispatch dimensions, and binds it after the resource set; programs that fit keep using shared memory. Driver::RegisterShadersplits shader preparation in two.PlanRegisteredruns on the registering guest thread: it validates the registers and entry point, decodes the code and builds the recompile request, so invalid registrations still fail there. The RDNA to IR translation and resource plan (PrepareShader) run on a background pool ofhardware_concurrency() / 2detached threads (at least 2, 64 MiB stacks).- Queue 0's packet worker decodes, captures and prepares a draw and hands its recording (
Graphics::Drawunder the GPU lock) to a committer thread inDrawPipeline.cpp, so the next packet is decoded while the previous draw is recorded. Each queued item carries the guest ranges it will write (DrawWriteRanges: written buffers and storage images, color targets with their DCC and CMASK, depth and stencil). A later packet that reads or writes one of those ranges, or that the pipeline can't order, drains it first; labels, submission ends, buffer fills and (withAPS5_PIPELINE_DMA=1) DMA packets are queued as items instead. WithAPS5_PIPELINE_DISPATCH=1(off by default), aDISPATCH_DIRECTwhose dispatch-cache entry hits with a recipe is queued as an item too, carrying the dispatch's written buffers and storage images (DispatchWriteRanges); the capture and the cache lookup stay on the worker and the recipe is recorded on the committer. A dispatch still drains when its lookup misses, when what it reads overlaps a write still pending, when the hit is verified against data or when its writes cannot be ranged (bindless access, GDS, fault buffer). An exception thrown by a commit is rethrown on the worker at its next enqueue or drain, as an inline draw error would be. On by default;APS5_PIPELINED_DRAWS=0records every draw inline, andAPS5_EXACT_DRAW_WRITES=0widens the color and depth ranges to estimates. - The shader registry maps a code address to every header registered for that code, in registration order. Static, graphics and rectangle ABI resolution pick the header at the bound address (or one equal to it,
SameHeader, which also takes a hull header fused bysceAgcFuseShaderHalves(type 3) as the registered back half (type 7) it copies, the fused registers aside; graphics ABI resolution then uses the fused header's shader registers); draws and dispatches pick the program by the stage's binary type (RegisteredProgram), and a front entry matching both type 2 and type 4 throws. Within a type the last registration wins; re-registering an identical earlier header makes its snapshot current again without preparing it again, and changed code drops the older headers. - The snapshot is published as pending.
SourceHandleFor,InvocationForandShaderPreparationTransaction::Edit/Readwait for the pending job before readingPreparedShaders, and rethrow its failure.APS5_SYNC_SHADER_PREPARE=1prepares on the registering thread instead. APS5_LAZY_SHADER_PREPARE=1retains registration validation and decoding but discards the temporary plan without queueing preparation. The snapshot is deferred, not pending; draws and dispatches prepare and cache it on first use. Explicit ABI resolution still prepares its requested stages. This opt-in mode takes precedence over synchronous registration preparation and trades background CPU/memory use for possible first-use stalls; disk caches and shader output are unchanged.- On devices with graphics pipeline libraries (
SpirvTarget::registrationModules), a vertex or pixel artifact is emitted in registration form: the buffer V# words, image modes, swizzles and mip ranges, depth-compare state, export mappings and vertex component selectors it used to specialize on per draw are read at run time (imageResourceMetadataand a few state dwords in the shader data), descriptor heaps keep every element in place (an unused one gets a null or placeholder descriptor), and the pixel stage's bindings live in descriptor set 1 so each stage's library depends only on its own set layout (INDEPENDENT_SETS). An artifact aboveregistrationWordLimitSPIR-V words is re-emitted specialized, andCompiledShaderArtifact::registrationModulerecords which form it has. For artifacts in registration form,RegistrationResultmaterializes the module once, andPrebuildStageLibrarybuilds its pre-rasterization or fragment-shader library on a separate one-thread pool (APS5_PREBUILD_THREADS) with a fixed default state, pairing it with a stub stage. A later draw with that state finds both libraries in the store and only links. - A draw or dispatch whose state matches no artifact prepared at registration (a title that builds its static ABI state itself, without
sceAgcDriverResolve*Abi) gets one prepared at use bySourceHandleFororInvocationFor, logged once per artifact and kept in the snapshot for the next use. - A compute shader whose
s_swappc_b64calls a user-data pointer cannot be linked at registration, because the callee's address is only known at dispatch.InvocationForinShaderRegistry.cppreads each callee from guest memory, appends one copy per call after the caller (LinkUserDataCalls) and prepares that linked code, so a different callee gets its own prepared entry. - A draw's
Graphics::Pipelinecomes fromCachedPipeline. When the device hasVK_EXT_graphics_pipeline_librarywith fast linking,VK_EXT_extended_dynamic_stateandVK_KHR_dynamic_rendering, draws render withvkCmdBeginRenderingKHRinstead of render passes and the pipeline is linked from four libraries (vertex input, pre-rasterization shaders, fragment shader, fragment output) kept per device inPipelineLibrary.cpp. A library built for one pipeline is reused by the next: another blend, vertex layout or target format builds only that part (the shader libraries name no attachment format), and cull mode, front face, depth and stencil state are dynamic, so changing them only links. The fast link is what the draw waits for; a background thread then links the same libraries with link-time optimization and the pipeline binds that one from the next draw on. Push constants then use a 256-byte block with a 128-byte slot per side: the pre-rasterization stages stack from byte 0, the pixel shader always starts at byte 128 (StagePushOffset), so its library does not depend on how much push data its vertex shader uses. Pipelines with per-face depth bias, a separate alpha blend into an 8_8 target with the alternate component order (both draw twice), rect lists, geometry shaders or a stage without a variant id are created whole, still with dynamic rendering; devices without the extensions (or withAPS5_NO_GPL=1) keep render passes and whole pipelines. - A draw whose
PA_SC_AA_CONFIGselects 2, 4 or 8 samples renders into host-only multisampled images.MultisampleTarget.cppkeeps one image per device, surface address, format, extent and sample count (MultisampleTargetView); the pipeline, render pass and library keys carryState::samples, and a multisampled depth target is a multisampled depth surface. The guest sample planes and FMASK are never read or written, so the registry also guards the target's guest range: a CPU read (GuestMemory::SetReadGuard), a DMA copy, and a sampled or storage image read of it throw. ACB_COLOR_CONTROLresolve mode over a multisampled CB0 runs as a render pass whose resolve attachment is CB1's resident image (ResolveMultisampleTarget). A zero fill of the target's CMASK (NoteColorMetadataFill, from fill kernels and CP DMA fills) clears the image at its next use. - A texture over a depth surface samples the depth image itself when it views one 2D slice of the plane's own format (
DepthSurfaceTextureinDepthSurface.cpp). A view of several slices (a 2D array or a cube) or an integer view of a D16 plane is served by aDepthPlaneCopy, an image the slices are copied into at every use: from the depth surface bound at each slice's address, else from guest memory. Such a texture is never kept in the resource templates (RefreshedPerUse). - Guest memory reaches the GPU through host imports (
VK_EXT_external_memory_host) where the driver accepts them, and through copies otherwise (GuestBufferMemory.cpp). A registered range larger than the import window (64 MiB) is imported per window for buffer and fill accesses and whole for an address-based build. Live imports stay under a budget, with the least recently used import retired first (see TechnicalDebt, Host import windows and budget). - On Windows, a host import of private
MEM_WRITE_WATCHmemory (singly mapped direct memory) stays onGetWriteWatchwhenProbeImportWriteProtectionshows the driver reports nothing written after the second GPU read and a GPU write, and a CPU store is collected (decideImportWatch). The driver marks every page of such an import written, asynchronously, until its first GPU use completes, sosettleImportinGuestBufferMemory.cppcopies 4 bytes out of the new import on a second queue of the graphics family, or on a queue of another copy-capable family when the graphics family has only one (Context::settleQueue, own pool and mutex) insideImportWatched's window and waits for it, only for imports of 1 MiB or more (a smaller import is compared, so a title that maps, imports and unmaps a small buffer around every submission does not block its mapping thread); the main queue can't be used there becauseimportAllocationholds the imports lock andGpuMutexcomes before it in the lock order. Imports through a section alias keep tracking on the guest views, and every other Windows host import is compared. A device without such a queue keeps the comparisons. On Linux the settle queue is made when the device imports dma-buf memory, and every dma-buf import of 1 MiB or more is settled right after it is made, for another reason: the kernel maps an import's pages for the GPU at the first submission that uses it, which took about 2 s for a 3 GiB import on RADV and held up the title's frame. The import's buffer is shared with the settle queue's family instead of aliased, since a sparse import is bound to many memory objects.
flowchart LR
callers["Any thread:<br/>WriteLogLine,<br/>AGC_LOG_DEFERRED"] --> queue["Queue, 16 MiB:<br/>a full queue blocks the caller"]
queue --> logger["Logger thread:<br/>format, join lines per stream,<br/>fwrite and flush"]
logger --> streams["stdout, stderr"]
exits["Abort, terminate, crash report,<br/>_Exit"] -- "bounded flush, 2 s" --> queue
WriteLogLine (LogMacros.hpp, behind APS5_LOG_OUT and APS5_LOG_ERR) formats a line on the calling thread and hands it to the module's LogSink() when one is installed; without a sink it writes and flushes it there. Driver installs AsyncLog as the sink of libSceAgcDriver, so the queue workers no longer wait for the console or the log file for those lines. APS5_LOG_SYNC (any value) skips the installation and restores the synchronous path.
- Queue. One logger thread drains a queue that holds at most 16 MiB of waiting lines; the batch the logger is writing comes on top. A caller that finds the queue full blocks until the logger has taken the pending batch, so no line is dropped. The logger writes entries in queue order and joins consecutive entries of one stream; it writes them with one
fwriteand flush when the text reaches 64 KiB, the stream changes or the batch ends.AGC_LOG_DEFERREDqueues the format string with numeric, enum andLogLiteralarguments and formats them on the logger thread;APS5_LOG_TIMESTAMPSstamps a line when it is queued. - Order. Lines that pass through the sink, on stdout and stderr alike, appear in call order. A direct
fprintf(stderr, ...)orprintfdoes not pass through it and is written at once, so it can appear before older queued lines; the coalesceddeferred write-back failednotes are of this kind.APS5_LOG_SYNC=1keeps one order for all of them. - Flush.
Flushwaits without a limit until every line queued before the call has been written, including while another thread holds the stream. It runs when the driver fails and inDriver::stop. At exitAsyncLogruns a bounded flush (FlushFor, 2 s) instead: on Windows theatexithandlers of a DLL run after the process has ended its other threads, so the logger thread may be gone with lines still queued, and aFlushthere would wait forever. It also registers that bounded flush withLibcSetEndFlush_nid_no_patch; libc runs it where the process ends withoutatexit:abort()andstd::terminate(aSIGABRThandler that the first registration chains in front of the previous one, soraise(SIGABRT)still ends the process), guestabort(), the Windows crash report of a fatal exception and guest_Exit. A title that installs its ownSIGABRTdisposition replaces that handler. The bounded flush polls two counters and takes no lock, so a thread that dies holding the queue lock or a stream cannot stall it. After 2 s it gives up, later calls return at once, and the lines still queued are lost. - Statistics. A
[log]line every 10 s in which lines were logged reports lines, bytes, the time spent by the callers and by the logger, and the queue peak;APS5_LOG_STATS=0hides it. Identicaldeferred write-back failednotes print three times per 10 s and are then counted in onerepeated N more timesline.
agc_async_log checks the order of deferred and written lines, long lines, the repeat limit, a Flush that waits for a held stream, a bounded flush that gives up, and a full queue that blocks its caller. agc_async_log_fatal starts the test executable as a child that queues lines behind a held stream and then calls abort(), calls std::terminate(), raises an access violation (Windows) or SIGABRT (elsewhere), or aborts without ever releasing the stream; the first four must leave every line in the file in order, the last must still end after the bound.
A T# or S# that a shader loads from a buffer at a runtime offset (key * stride + addend, or (key << n) & mask) is not a direct descriptor. ResourceTracker.cpp plans it as a table column (TableColumn): the heap V#'s descriptor source, the record stride and addend, the descriptor's offset in the record, and the key domains that may narrow it. Accesses with the same heap and record shape share one column. The shader keeps computing the offset and indexes a table binding: images use one variable-count SAMPLED_IMAGE array (RuntimeAbi::ImageTableBinding, in the fragment set when pipeline libraries split the sets), samplers a SAMPLER array (Binding::SamplerTable). Devices without runtimeDescriptorArray, descriptorBindingPartiallyBound and descriptorBindingVariableDescriptorCount refuse such shaders.
At every invocation, TableSnapshotter in ResourceMaterializer.cpp reads each column's records from guest memory into ResourceSnapshot::tables: heap base and size, the record count (at most ImageTableAbi::MaxKeys), one code per record (an index into the distinct descriptor words, or a poison code) and the ranges it read. A column narrows to the records its keys select, plus record 0, when every access has a key domain: a value the SRT walker evaluates from user data, SRT reads and constants, or a key buffer of at most 256 positions (APS5_IMAGE_TABLE_KEY_SCAN). A loop-carried, lane-reduced or masked key keeps the whole column, so the cost of an invocation grows with the column's records and its distinct T#s. The snapshot is part of the invocation, not of the compiled variant: a changed table changes neither the SPIR-V nor the variant's disk key.
ResolveImageTables turns the snapshot into the map buffer (DescriptorRole::ImageTableMap, layout in ImageTableAbi.hpp): a header, eight words per table (map start, key count, heap base and size, the code for keys past the snapshot, a table fault) and one record per key (flags with the decode mode, the swizzle dword and the element for images; flags and element for samplers). The shader reads map[key] and switches over the image's prepared runtime modes. Each distinct T# becomes one element, up to ImageElementBudget and the device's capacity. An entry that does not decode, is multisampled or over budget, pairs a reducing sampler with a point-only mode, is an unnormalized or reducing S#, or whose surface has no mapped byte is poisoned: it reads zeros and reports a fault through the BDA fault buffer only when a lane selects it (APS5_IMAGE_TABLE_STRICT=1 throws at the snapshot instead). The driver's resolveImageTables in ShaderResources.cpp looks up or creates a texture per element and keeps the last four resolved tables per thread for reuse within one collect epoch while their memory is unchanged. Cached objects grow with their tables, so the resource cache keeps at most ResourceCache::TableElementBudget() table elements (65,536, APS5_RESOURCE_CACHE_TABLE_ELEMENTS) and evicts its least recently used table objects past that, and the dispatch cache evicts its least recently used entries while their captured words exceed Driver::DispatchCacheBytes() (256 MiB, APS5_DISPATCH_CACHE_BYTES).
The disk cache (FormatVersion 33) stores the table and table operation of each image and sampler, the image shapes of the table binding, the invocation's table poison list, read ranges and fault count, and the table column of each descriptor source in the front-end plan, with its key domains.
A storage image over guest memory that the write watch does not cover (a host-pointer import on a driver that drops its write protection, or an unwatched range) has no block stamps to tell a CPU edit apart, so StorageTexture::compareUntracked in Texture.cpp compares the guest bytes with the image's snapshot. A whole-surface comparison that found no change is remembered under a stamp of:
- the calling queue worker's collect epoch (
GuestMemory::CollectEpoch), which advances at every ordering point where a CPU store may have landed (submission start, a satisfiedWAIT_REG_MEM, a drain); a thread that never bumps it gets epoch 0 and is never memoized; GuestMemory::NewestStampof the surface: the newest block stamp of its watched part, and the generation of the newest driver store (MarkWritten,StoreOwnBytes) into its unwatched part;GuestMemory::ForgetSerial, which changes when guest memory is released or remapped;- the recorder's newest pending GPU write note over the surface.
The comparison runs again when any of them changes. A CPU store into unwatched memory is therefore seen at the next ordering point, the same bound the collect memo gives watched memory. Driver stores into unwatched memory are recorded per 64 KiB block in a fixed table of 65,536 slots indexed by block number modulo the table size, each slot holding the newest generation stored to any of its blocks. A collision can only raise a surface's stamp, which costs one comparison and never skips one. agc_driver_dcc_keys_first_write (RunUntrackedReuse) checks that a CPU edit to an unwatched image is uploaded after the next epoch, and agc_write_tracking (CheckNewestStamp) checks which stores advance NewestStamp.
A cached shader keeps an IrResourcePlan (ResourceMaterializer::ExtractPlan in ResourceMaterializer.cpp) for as long as it is registered: a copy of the IR values its descriptor sources, SRT reads, uniform fill and control-flow conditions depend on, which every capture walks to read the shader's resources from guest memory. The copy is sized to what the walk can use:
- A control-flow condition is kept only when
Detail::Evaluator::MayEvaluatefinds a runtime it could be evaluated for. A condition that depends on a vector register, or on an opcode the evaluator does not evaluate, takes both successors whatever the runtime holds, so the plan stores it as no condition and does not copy its operands. Such conditions are most of a shader's IR when the masked-lane code feeds its branches. - The copied values are allocated in one
IrValueArenaand hold no use lists: the walk only follows arguments.
GuestContext::floatMode carries the initial shader mode through Recompile and TranslateOptions into each block's TranslationContext. s_getreg_b32 reads confined to MODE bits 0–3 return the selected initial rounding bits in the destination's low bits. Bits 0–1 select f32 rounding; bits 2–3 select f16/f64 rounding. Missing mode metadata retains the legacy round-to-nearest-even assumption. Reading other MODE fields or hardware registers throws.
An immediate MODE write confined to the rounding fields is accepted only when its selected replacement bits equal the initial state. Source bits above the selected width are ignored. s_round_mode similarly compares its low four immediate bits with the initial rounding fields. A state-changing write or an SGPR-sourced MODE write throws with its instruction and program counter. Runtime transitions require control-flow-aware mode tracking and matching arithmetic lowering. Writes to other hardware registers retain their existing behavior and remain technical debt.
The register layout and instruction contract follow AMD RDNA2 ISA sections 5.8 and 6.4, Table 24, cross-checked against LLVM 20.1.8's hardware-register encoding and MODE state analysis. This contract does not establish arithmetic support for every initial mode or console-specific behavior.
agc_shader_mode_register decodes instruction words, builds the control-flow graph and checks production translation without a Vulkan device. It covers rounding subfield reads, masked constant writes, branches, unsupported runtime changes and diagnostics, plus mode propagation through full recompilation. The requirements remain active in Release; CTest bounds execution to 20 seconds.