2 Commits
Author SHA1 Message Date
LibretroAdmin 9b89193fd8 GS: an erase from an empty FastList wrote 128 KB past the buffer
The crash, from the Dr. MinGW trace: heap corruption (0xC0000374)
reported by free, inside ~Source under InvalidateTemporarySource, under
CleanupDraw - Tomb Raider Legend, scope view, and it happens on Vulkan
as well as D3D, so it is not a backend. A heap-corruption status at free
means the allocator found its bookkeeping damaged; the damage was done
earlier, by something else, and this free is only where it was noticed.

FastList::EraseIndex is the something else:

	m_free_indexes_stack[--m_free_indexes_stack_top] = index;

m_free_indexes_stack_top is a u16 and is zero when the list holds
nothing, so the pre-decrement takes it to 65535 and the store lands 128
KB past the buffer, in whatever the allocator has there. ListRemove on
the line above has already written two more through a stale index.
Nothing fails at that moment. The next free of a block whose header the
store landed on is where it surfaces, and that free can be anywhere -
here it was a Source being deleted at the end of a draw.

Getting there needs a stale index, which the texture cache hands out:
SourceMap keeps one m_erase_it per page in every Source, RemoveAt hands
them all back later, and anything that emptied those lists in between
makes every one of them stale. A resolution change purges the texture
cache, which is what the report started with; the scope view is a
channel shuffle, which is the path that creates and drops sources
hardest.

EraseIndex and MoveFront now ignore an index that cannot be theirs - no
buffer, index zero, past the capacity, or nothing held. Build with
-DFASTLIST_CHECKS=1 to have them say so, with the list, the index and
the count, which is how to find the caller that is still handing over a
stale one. That caller is a bug of its own and this does not fix it; it
stops it corrupting the heap.

tests/fastlist covers it: erase and move on a cleared list, on an
allocated-but-empty one, index zero, and an out-of-range index, then
checks the list still works. Without the guard that test is an
AddressSanitizer report; with it the suite passes plain and under
ASan/UBSan.

The guard is worth having whatever the list's allocation strategy: the
same store ran off the end of the four-element buffer before lists
allocated lazily (3bf33f90e), 128 KB past it either way.
2026-09-20 01:23:38 +02:00
LibretroAdmin 3bf33f90e8 GS: an empty FastList owns no memory, and the suite pins what it does
FastList's clear(), which its constructor calls, allocated a four-element
buffer: one 64-byte-aligned allocation per list whether anything was ever
put in it. The texture cache keeps one list per GS page - GS_MAX_PAGES of
them, five hundred and twelve - and a frame touches a handful, so a
texture cache paid five hundred-odd allocations to hold nothing, and the
pages a frame does walk are spread across them.

It allocates on the first insertion instead, in an Allocate() that is
what clear() used to do. An empty list has no buffer, so the three reads
that went through m_buffer[0] answer without one: index 0 is both the
auxiliary element and "past the end", which is what an empty list's
first and last already were. size(), empty(), begin(), end(), rbegin()
and rend() are unchanged in what they return.

tests/fastlist is new and run-all.sh picks it up: insertion order, both
iteration directions, back/pop_back, erase through an iterator,
MoveFront, reuse after clear, and the same on a list that has never held
anything. It passes against the version before this change as well -
that is the point of it - and plain and under ASan after.

GSTextureCache.cpp, GSDevice.cpp and GSState.cpp compile with the
Makefile's flags, GSRendererSW.cpp with its multi-ISA ones. Not run in
the emulator.
2026-09-20 00:57:22 +02:00