Skip to content

fix(agc): take an HTILE fill from a value in memory as a depth fast clear - #3816

Merged
oneandonlydean merged 2 commits into
boykopovar:mainfrom
alfalfa-star:fix/agc-htile-memory-value-fill
Oct 12, 2026
Merged

oneandonlydean merged 2 commits into
boykopovar:mainfrom
alfalfa-star:fix/agc-htile-memory-value-fill

Conversation

@alfalfa-star

@alfalfa-star alfalfa-star commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

What

Kena: Bridge of Spirits (PPSA01802) clears its depth HTILE every frame with its own compute kernel, which the driver didn't see as a clear. The depth image kept the first frames' contents. The base pass (greater-or-equal, reversed Z) and the sky then failed their depth test almost everywhere, and the title menu showed stale memory: black, white or striped, depending on the run.

The kernel is a 9-word dword fill, run as 2048x1x1 groups of 64 over the 0x80000-byte HTILE of a 3200x1800 D32S8 target:

v_lshl_add_u32 v0, s8, 6, v0
s_buffer_load_dword vcc_lo, s[4:7], 0x0
s_waitcnt lgkmcnt(0)
v_mov_b32 v1, vcc_lo
buffer_store_format_x v1, v0, s[0:3], 0 idxen
s_endpgm

The value comes from memory (through the V# in user[4..7]), not from user data, so neither the DMA_DATA hook nor the known fill kernels match it.

  • MatchMemoryValueFill (DepthSurface.cpp) matches the kernel exactly and requires:

    • 64x1x1 threads and one row of groups;
    • a stride-4, unswizzled output V# without ADD_TID, with format 32_UINT, 32_SINT or 32_FLOAT, so buffer_store_format_x writes the value unconverted;
    • a non-null, dword-aligned value V# that covers the loaded dword (NUM_RECORDS × stride, or NUM_RECORDS bytes for stride 0); otherwise the hardware load returns 0.

    The range is groups × 64 dwords, clipped to NUM_RECORDS.

  • NoteMemoryValueFill reads the value from guest memory when the dispatch is decoded and passes range and value to NoteDepthMetadataFill. The whole-HTILE coverage and ZMask/SMem rules of fix(agc): depth fast clears through the HTILE metadata #374 apply unchanged.

  • Dispatch.cpp calls both next to the existing HtileDepthClearAddress hook, for direct dispatches with group counts only. The dispatch itself still runs.

  • TechnicalDebt: the HTILE fast clear entry now names the second kernel, and notes that the value is read at decode, so a value that earlier GPU work in the same submission hasn't written back yet is not seen.

Relation to #2053

#2053 (esatturan) covers HTILE written by any kernel, by reading the words when the depth target is bound. On Kena it takes only the first clear. The first bind reads 90000 of 90000 words with ZMask 0. All 299 later binds return at UnchangedSince, because the kernel's GPU stores don't move the write generation. Details are in #2053 (comment).

This PR is narrower and catches the clear at the dispatch. The two can coexist: a CP-side note here, a memory read there. If #2053 learns to see GPU-written HTILE, this matcher is still harmless (it notes the same clear earlier), and I'm happy to drop it if the maintainers prefer one path.

Tested

Arch Linux, KDE Plasma, GCC, Release, RADV (RX 9070 XT, Mesa 26.2.4), on main a11c8f7.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed in TechnicalDebt
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none
  • AI-assisted: yes (Claude Code)

🤖 Generated with Claude Code

@GustavoGamarra95

Copy link
Copy Markdown
Contributor

Beast of Reincarnation (PPSA29343, Unreal Engine) runs the same 9-word kernel: at least 40 matches in the first minutes, mostly with value 0x40404040 over HTILE ranges of 0x1000 to 0x30000 bytes (a few with 0x10101010 and 0). Cherry-picked onto my branch for that title, it removes the stale blocks its title screen showed (Linux, RTX 3050 Laptop).

We are still analyzing the remaining rendering issues of this title and an improvement for GPUs with little VRAM.

@oneandonlydean

Copy link
Copy Markdown
Collaborator

Conventions fails on the checklist: the AI-assisted: line is unticked and has no yes or no. Please fill it in.

@AlxcBTW

AlxcBTW commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Data point from a UE5 title: Sword of the Sea (PPSA25344) runs this exact kernel every frame, and this PR alone fixes its depth.

Setup: Windows 11, RTX 5070 Laptop, MinGW GCC 15.2, Release; current main plus our local menu-boot patches for this title (none touch depth or HTILE), with 69a67ca cherry-picked cleanly on top.

What the title does: before the first depth draw of each frame, it clears its 1920x1080 D32S8 depth target with two compute dispatches over the whole HTILE (0xc000 dwords, 768x1x1 groups of 64, stride-4 V# in user[0..3]):

  • the 9-word kernel from this PR, value loaded through user[4..5] (0 here);
  • a variant that stores an inline constant instead (v_mov_b32 v1, 0 in place of the s_load_dword), which this PR does not match.

Since the matched one already covers the whole HTILE with 0, that's enough here.

Result: 1:1 captures at 1920x1080, 240 s. "Dark" is the share of the sky band with luma < 22; the PS5 footage is 0.000 there.

dark % over the run
without a dispatch-time match (main-based build) 3.5 → 10.2 → 16.0 → 20.3 at frames 140 / 358 / 579 / 781, growing every frame
with this PR 0.0 at all three captures, no growth

Without the clear, the GPU depth image keeps the previous frame's depth/stencil. Particle-shaped black holes then accumulate over the title-screen sky. A frame replayed from a fresh depth surface was clean, which is how we tracked it down.

If the inline-constant variant would be useful for another title, I can send its exact words.

AI-assisted: yes (Claude Code)

@AlxcBTW

AlxcBTW commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

@alfalfa-star a small edge case we hit while testing a buffer-fill version of the same kernel on our side. The kernel reads its value with s_buffer_load through the V# in user[4..7]. When that V# doesn't cover the dword, e.g. num_records (user[6]) is 0, the hardware load returns 0 and the kernel fills with 0. MatchMemoryValueFill only checks user[4..5], so NoteMemoryValueFill would read the value from memory instead. Kena's V# is valid, so nothing changes there. A one-line guard would keep the two in step: return nullopt unless user[6] covers the loaded dword, or note a 0 fill in that case.

One more edge of the same kind. The kernel stores with buffer_store_format_x, so what lands in memory depends on the output V#'s FORMAT (user[3]). With a non-32-bit format, e.g. 0x27 (10_11_11), the hardware writes a converted value or 0, while NoteMemoryValueFill notes the raw dword. Accepting the match only for FORMAT 20–22 (32_UINT / 32_SINT / 32_FLOAT) keeps the note and the GPU in step.

AI-assisted: yes (Claude Code)

@alfalfa-star

Copy link
Copy Markdown
Contributor Author

@AlxcBTW thanks, both are right. I had also misread the kernel: 0xf4201a82 is s_buffer_load_dword vcc_lo, s[4:7] and 0xe0102000 is buffer_store_format_x, not the s_load_dword / buffer_store_dword I wrote here and on #2053. The description is corrected. f801797 adds both guards:

  • Store format: the output V#'s FORMAT (user[3] bits 12-18) must be 20-22 (32_UINT / 32_SINT / 32_FLOAT).
  • Value V# size: it must cover the dword. I first checked user[6] >= 4, and that dropped every Kena clear: Kena's value V# has NUM_RECORDS 1 with stride 16 (user[5] = 0x00100020). So the check now uses NUM_RECORDS × stride, or NUM_RECORDS bytes when the stride is 0, which is the byte range the hardware checks a scalar buffer load against.

I logged Kena's own words in game (user[3..7] = 00014204 …e70c 00100020 00000001 0004dfac): format 32_UINT, one 16-byte value record. The unit tests now use those words, plus cases for the refused formats, an empty value V#, and a raw 4-byte / 3-byte value buffer. With the corrected check, Kena still notes the whole-HTILE clears (13 in the first 60 s of the run). ctest 832/832, conventions pass.

@GustavoGamarra95 and @AlxcBTW, thanks for the Beast of Reincarnation and Sword of the Sea data. Since three UE titles run this kernel, it's likely UE's own HTILE clear. The inline-constant variant would be a separate matcher; I'd leave it out of this PR unless a title needs it alone.

@oneandonlydean the AI-assisted line is filled in.

AI-assisted: yes (Claude Code)

@GustavoGamarra95

Copy link
Copy Markdown
Contributor

Rechecked Beast of Reincarnation (PPSA29343) with f801797: its value V# and output format pass the new guards. In 170 s at 1080p output the driver noted 248 whole-HTILE clears (value 0), and the title screen renders without the stale blocks (Linux, RTX 3050 Laptop).

Follow-up to "still analyzing" above: the blocks that remained on that title had a separate cause, storage results still pending under memory that a later dispatch wrote as a buffer (Unreal's transient allocator reuses it for both). That fix is #4654.

@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Oct 12, 2026
@TideOPL

TideOPL commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Another UE5 data point: Minecraft Dungeons II (PPSA16064) clears its scene depth HTILE with this same 9-word kernel every frame, and this PR fixes its depth.

Without it, the host depth image never clears. The depth pre-pass (greater-or-equal, reversed Z) keeps the nearest depth of the animated foliage from earlier frames. The base pass then fails its depth test, so two-sided masked materials (grass, flowers, leaves, the campfire flame) render solid black on the title screen, and gameplay scenes are mostly dark. The black shapes are larger than the plants because they hold the union of their earlier positions. A constant depth bias of about 10⁶ units on the base pass hides the symptom, which is how the stale depth showed up.

The match: one dispatch per frame over the scene depth's HTILE, 0x60000 bytes, value 0. With 69a67ca and f801797 cherry-picked, the driver notes the clear every frame and the title renders the same as our reference build. That reference build ran this kernel as a buffer fill via an older local patch, which is how I found it.

Tested by reviewer:

alfalfa-star and others added 2 commits October 13, 2026 12:40
…lear

PPSA01802 (Kena: Bridge of Spirits) clears its depth HTILE every frame
with its own compute kernel: a 9-word dword fill that stores, through
the stride-4 V# in user[0..3], a value loaded from the pointer in
user[4..5]. Neither the DMA_DATA hook nor the fill kernels the driver
recognizes saw it, so the depth kept the first frames' contents and the
base pass and sky failed their depth test almost everywhere.

The kernel is matched exactly when the dispatch is decoded, the value is
read from guest memory, and the range is passed to NoteDepthMetadataFill
like a fill, so the whole-HTILE and ZMask rules apply unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…raw value

The kernel reads its value with s_buffer_load_dword through the V# in
user[4..7] and stores it with buffer_store_format_x through the V# in
user[0..3]. When the value V# covers less than a dword (NUM_RECORDS
times its stride, or NUM_RECORDS bytes for stride 0) the load returns 0,
and a store format other than 32_UINT, 32_SINT or 32_FLOAT converts the
value, so in both cases the HTILE does not receive the dword read from
memory. The match now requires both. Kena's own dispatch (format
32_UINT, a value V# of one 16-byte record) still matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AlxcBTW added a commit to AlxcBTW/AnyPS5 that referenced this pull request Oct 13, 2026
The 9-word kernel boykopovar#3816 matches stores one dword per thread, loaded with
s_buffer_load_dword through the descriptor in user[4..7]. Run as a
compute dispatch, its stores are pending writes of unknown value, so a
later read of DCC keys in that range waits for the GPU.

fillBuffer now takes it as a uniform buffer fill when all of these hold:
- the output descriptor's format is 32_UINT, 32_SINT or 32_FLOAT (20-22),
  so buffer_store_format_x stores the loaded dword unchanged;
- the value descriptor covers the loaded dword: num_records times the
  stride (num_records alone when the stride is 0) is at least 4 bytes,
  the range the recompiler gives a scalar buffer load;
- exactly 8 user SGPRs and group id X enabled, so the group id is s8;
- a direct, non-partial dispatch, 64x1x1 threads, one group in Y and Z,
  and a 16-byte aligned range of whole 16-byte units.

The value is read when the fill is recorded, after the labels queued
before it, as the pattern-fill kernel's source is. When the value is one
byte repeated and that byte is a DCC key code, the fill goes into the GPU
key-store memo whatever ClassifyFill reports: the memo answers a key
read only while this fill is still the newest pending write over the
keys, and a fill over a color target's keys is not a storage image's
keys fill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AlxcBTW added a commit to AlxcBTW/AnyPS5 that referenced this pull request Oct 13, 2026
The 9-word kernel boykopovar#3816 matches stores one dword per thread, loaded with
s_buffer_load_dword through the descriptor in user[4..7]. Run as a
compute dispatch, its stores are pending writes of unknown value, so a
later read of DCC keys in that range waits for the GPU.

fillBuffer now takes it as a uniform buffer fill when all of these hold:
- the output descriptor's format is 32_UINT, 32_SINT or 32_FLOAT (20-22),
  so buffer_store_format_x stores the loaded dword unchanged;
- the value descriptor covers the loaded dword: num_records times the
  stride (num_records alone when the stride is 0) is at least 4 bytes,
  the range the recompiler gives a scalar buffer load;
- exactly 8 user SGPRs and group id X enabled, so the group id is s8;
- a direct, non-partial dispatch, 64x1x1 threads, one group in Y and Z,
  and a 16-byte aligned range of whole 16-byte units.

The value is read when the fill is recorded, after the labels queued
before it, as the pattern-fill kernel's source is. When the value is one
byte repeated and that byte is a DCC key code, the fill goes into the GPU
key-store memo whatever ClassifyFill reports: the memo answers a key
read only while this fill is still the newest pending write over the
keys, and a fill over a color target's keys is not a storage image's
keys fill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AlxcBTW added a commit to AlxcBTW/AnyPS5 that referenced this pull request Oct 13, 2026
The 9-word kernel boykopovar#3816 matches stores one dword per thread, loaded with
s_buffer_load_dword through the descriptor in user[4..7]. Run as a
compute dispatch, its stores are pending writes of unknown value, so a
later read of DCC keys in that range waits for the GPU.

fillBuffer now takes it as a uniform buffer fill when all of these hold:
- the output descriptor's format is 32_UINT, 32_SINT or 32_FLOAT (20-22),
  so buffer_store_format_x stores the loaded dword unchanged;
- the value descriptor covers the loaded dword: num_records times the
  stride (num_records alone when the stride is 0) is at least 4 bytes,
  the range the recompiler gives a scalar buffer load;
- exactly 8 user SGPRs and group id X enabled, so the group id is s8;
- a direct, non-partial dispatch, 64x1x1 threads, one group in Y and Z,
  and a 16-byte aligned range of whole 16-byte units.

The value is read when the fill is recorded, after the labels queued
before it, as the pattern-fill kernel's source is. When the value is one
byte repeated and that byte is a DCC key code, the fill goes into the GPU
key-store memo whatever ClassifyFill reports: the memo answers a key
read only while this fill is still the newest pending write over the
keys, and a fill over a color target's keys is not a storage image's
keys fill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AlxcBTW added a commit to AlxcBTW/AnyPS5 that referenced this pull request Oct 13, 2026
…nd answer the keys from them

Sword of the Sea clears DCC keys with two small compute kernels: the dword fill boykopovar#3816 matches (a value
loaded through the descriptor in user[4..7]) and a dword fill of an inline constant through the
descriptor in user[0..3]. Run as compute dispatches, their writes were noted with an unknown value,
so the next refresh of a surface over those keys waited for the dispatch before it could read them:
about 970 waits and 2.4 s per 10 s in the menu, all behind these two kernels.

- Both kernels are stored through fillBuffer (the title-fill path) when the range is 16-byte aligned,
  the constant kernel's tgid is s4 and the value's descriptor covers the loaded dword (a load past it
  returns 0, which the compute dispatch keeps doing). The value is read once at record time, through
  the flush hook, and the fill is noted with its byte value.
- WaitForKeyWriters answers the keys without a wait when the newest pending write over every key byte
  is such a fill and they agree with each other and with the bytes no pending write covers, as it
  already did for fills that leave the keys uncompressed.

Temporary A/B switches (lab only): APS5_EXP_NO_DWORD_FILL_HLE=1 keeps the compute dispatches,
APS5_EXP_NO_FILL_KEYS=1 waits as before. [fill] counts the fills taken this way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alfalfa-star
alfalfa-star deleted the fix/agc-htile-memory-value-fill branch October 13, 2026 16:33
AlxcBTW added a commit to AlxcBTW/AnyPS5 that referenced this pull request Oct 13, 2026
The 9-word kernel boykopovar#3816 matches stores one dword per thread, loaded with
s_buffer_load_dword through the descriptor in user[4..7]. Run as a
compute dispatch, its stores are pending writes of unknown value, so a
later read of DCC keys in that range waits for the GPU.

fillBuffer now takes it as a uniform buffer fill when all of these hold:
- the output descriptor's format is 32_UINT, 32_SINT or 32_FLOAT (20-22),
  so buffer_store_format_x stores the loaded dword unchanged;
- the value descriptor covers the loaded dword: num_records times the
  stride (num_records alone when the stride is 0) is at least 4 bytes,
  the range the recompiler gives a scalar buffer load;
- exactly 8 user SGPRs and group id X enabled, so the group id is s8;
- a direct, non-partial dispatch, 64x1x1 threads, one group in Y and Z,
  and a 16-byte aligned range of whole 16-byte units.

The value is read when the fill is recorded, after the labels queued
before it, as the pattern-fill kernel's source is. When the value is one
byte repeated and that byte is a DCC key code, the fill goes into the GPU
key-store memo whatever ClassifyFill reports: the memo answers a key
read only while this fill is still the newest pending write over the
keys, and a fill over a color target's keys is not a storage image's
keys fill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agc libSceAgc* docs needs rebase Conflicts with main, rebase needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants