IOP: collapse RAM mirrors in the HWADDR domain — cross-alias SMC ran stale code

The recLUT shares BASEBLOCK slots across the four RAM mirrors (guest page i
maps physical page i & 0x1f in the 2MB config), so a block compiled at one
alias stays dispatchable through every other — but psxhwLUT only stripped
the segment base, leaving block registration, coverage, recBlocks and the
clear-path range guard keyed by the un-collapsed address. A store through a
different alias of a compiled page then missed every invalidation structure
while the shared slot kept executing the stale block. Found by
--gtest_shuffle: IopIrxHle leaves a block at canonical 0x14000, and the
RAM-mirror SMC test then loads its victim program through 0x214000 — the
C-path clear missed the stale block and the JIT ran the IRX test's code.

Collapse the whole domain instead: the psxhwLUT entries for the RAM window
fold the mirror bits (identity in the 8MB config), recClearIOP
canonicalizes caller addresses up front so the g_psxMaxRecMem guard and
everything HWADDR-keyed below agree, g_psxMaxRecMem itself tracks
HWADDR(psxpc), and the store stub probes the collapsed offset it already
computed for the store. Cross-alias SMC is pinned in iop_smc_tests.cpp in
both orientations plus the JIT store-stub path.
This commit is contained in:
Brian Degenhardt
2026-07-29 20:28:41 -07:00
parent 5d4184c2c1
commit 9b1f9992b8
2 changed files with 144 additions and 36 deletions
+40 -27
View File
@@ -212,25 +212,19 @@ static const void* _DynGen_EnterRecompiledCode()
// when the granule counter is nonzero, i.e. when psxRecClearMem would not
// have early-outed anyway.
//
// ⚠ Two address domains meet here, and they are NOT the same. The store above
// is mirror-collapsed (addr & (ExposedIopRam-1)), because that is where the
// bytes live. The coverage probe below must instead use HWADDR, the domain
// iopCovAdjust and psxRecClearMem key g_iopCodeCov by — and HWADDR strips the
// KSEG base but does NOT collapse the RAM mirrors, since recLUT_SetPage only
// writes psxhwLUT[page] = -(pagebase << 16) and pagebase is 0 for the whole
// 0x00-0x7f RAM window. Under the default 2MB configuration a block executed
// from a mirror page (0x20-0x7f) therefore registers coverage at granule
// (0x00214000 >> 8) while the collapsed offset would probe (0x00014000 >> 8);
// probing collapsed meant a store to the very address the block was compiled
// at read a zero counter and skipped the clear the C path makes. Above the
// region gate every reachable address satisfies HWADDR == addr & (kIopCovSpan-1)
// — bits 23-28 are zero, and the psxhwLUT subtraction for a KSEG mirror is
// exactly the removal of bits 29-31 — so the two masks are one AND apart, and
// they coincide outright in the 8MB configuration.
//
// This makes the stub exactly as blind as the C path it replaces, no more: a
// store to a *different* mirror of a block's page still misses, because
// recBlocks itself is keyed by HWADDR and psxRecClearMem would miss it too.
// The store above and the coverage probe below share one address domain: the
// mirror-collapsed physical offset (addr & (ExposedIopRam-1)). That is where
// the bytes live, and since recResetIOP writes psxhwLUT so HWADDR collapses
// the RAM mirrors to the canonical physical address, it is also the domain
// every HWADDR-keyed structure (block registration, g_iopCodeCov, recBlocks,
// psxRecClearMem) is keyed by. This took two iterations to get right: the
// probe originally collapsed while registration did not (a store to the very
// address a mirror-page block was compiled at read a zero counter), the
// interim fix made the probe match un-collapsed registration — and that left
// both the stub and the C path blind to a store through a *different* mirror
// of a block's page, even though the recLUT shares the BASEBLOCK slots across
// aliases and keeps such a block dispatchable from every mirror. Collapsing
// the whole domain (cross-alias SMC pinned in iop_smc_tests.cpp) closed that.
//
// Anything else tail-jumps to the C handler, which returns to the store site.
//
@@ -260,14 +254,11 @@ static const void* _DynGen_StoreStub(int size)
case 32: armAsm->Str(a64::w1, a64::MemOperand(a64::x4, a64::x2)); break;
}
// Probe by HWADDR (see the domain note in the header comment). w2 already
// holds it when the RAM mask spans the whole coverage window, i.e. in the
// 8MB configuration; otherwise re-mask into w7.
// Probe by the mirror-collapsed offset — the domain g_iopCodeCov is keyed
// by now that psxhwLUT collapses the RAM mirrors (see recResetIOP). w2
// already holds it from the store above.
armMoveAddressToReg(a64::x5, g_iopCodeCov);
const a64::Register cov_off = (ram_mask == kIopCovSpan - 1) ? a64::w2 : a64::w7;
if (!cov_off.Is(a64::w2))
armAsm->And(cov_off, a64::w0, kIopCovSpan - 1);
armAsm->Lsr(a64::w6, cov_off, kIopCovShift);
armAsm->Lsr(a64::w6, a64::w2, kIopCovShift);
armAsm->Ldrh(a64::w6, a64::MemOperand(a64::x5, a64::x6, a64::LSL, 1));
armAsm->Cbnz(a64::w6, &clearHit);
armAsm->Bind(&swallowed);
@@ -1083,6 +1074,15 @@ static void recClearIOP(u32 Addr, u32 Size)
// (a whole-arena mprotect pair per covered store in iOS Legacy mode) and
// hazardous: Legacy range windows bypass the refcount, so the paired
// EndCodeWrite RX-flipped any emit window open on another thread.
//
// Callers pass the guest address in whatever alias the store used.
// Collapse RAM mirrors (and KSEG bases) to the canonical physical address
// up front: g_psxMaxRecMem and every HWADDR-keyed structure below live in
// the collapsed domain. Bits 23-28 being clear selects exactly the 8MB
// RAM window in every segment (see the store-stub header note), so this
// touches only RAM addresses.
if (!(Addr & 0x1f800000))
Addr &= Ps2MemSize::ExposedIopRam - 1;
u32 end = Addr + Size * 4;
for (u32 i = Addr; i < end; i += PSXREC_CLEARM(i))
;
@@ -1188,6 +1188,19 @@ void recResetIOP()
recLUT_SetPage(psxRecLUT, psxhwLUT, recRAM, 0x0000, i, i & mask);
recLUT_SetPage(psxRecLUT, psxhwLUT, recRAM, 0x8000, i, i & mask);
recLUT_SetPage(psxRecLUT, psxhwLUT, recRAM, 0xa000, i, i & mask);
// recLUT_SetPage collapses the RAM mirrors in the BASEBLOCK domain
// (mappage - page) but derives psxhwLUT from the segment base alone,
// which left HWADDR keeping each mirror distinct while the executable
// slots are shared — so registration, coverage and the clear path
// could not find a block reached through a different alias of the
// same physical page (cross-alias SMC executed stale code; pinned in
// iop_smc_tests.cpp). Rewrite the hwLUT entries so HWADDR collapses
// to the canonical physical address too. In the 8MB configuration
// `i & ~mask` is 0 and this degenerates to the segment-base strip.
psxhwLUT[0x0000 + i] = 0u - ((0x0000u + (u32)(i & ~mask)) << 16);
psxhwLUT[0x8000 + i] = 0u - ((0x8000u + (u32)(i & ~mask)) << 16);
psxhwLUT[0xa000 + i] = 0u - ((0xa000u + (u32)(i & ~mask)) << 16);
}
// Map ROM
@@ -1454,7 +1467,7 @@ StartRecomp:
s_maxBlockBytes = std::max(s_maxBlockBytes, psxpc - startpc);
if (!(psxpc & 0x10000000))
g_psxMaxRecMem = std::max((psxpc & ~0xa0000000), g_psxMaxRecMem);
g_psxMaxRecMem = std::max(HWADDR(psxpc), g_psxMaxRecMem);
if (psxbranch == 2)
{
+104 -9
View File
@@ -316,15 +316,13 @@ TEST(IopSmc, JitStoreThroughRamMirrorInvalidatesBlockCompiledThere)
// In the default 2MB configuration the IOP's RAM appears four times across
// the 8MB window: iopMemReset and the recLUT setup both map guest page `i`
// to physical page `i & 0x1f`. So a block at 0x00214000 shares its bytes
// AND its BASEBLOCK slots with one at 0x00014000 — but not its HWADDR,
// because psxhwLUT only subtracts the KSEG base (recLUT_SetPage writes
// `-(pagebase << 16)`, and pagebase is 0 here). HWADDR(0x00214000) is
// 0x00214000, granule 0x2140; the store stub's mirror-collapsed offset is
// 0x14000, granule 0x140.
//
// Store and block are both at the mirror address, so the C clear path
// agrees with itself and invalidates. The JIT store stub does not: it
// probes the collapsed granule, reads zero, and returns without clearing.
// AND its BASEBLOCK slots with one at 0x00014000 — and, since the hwLUT
// collapse in recResetIOP, its HWADDR too: registration, coverage and the
// clear path all key the canonical 0x14000. Historically HWADDR did NOT
// collapse, and this same-alias case was the first casualty: the store
// stub probed the collapsed granule 0x140 while the block's coverage sat
// at the un-collapsed 0x2140, so the stub read zero and returned without
// clearing — store and block at the very same guest address.
ASSERT_EQ(Ps2MemSize::ExposedIopRam, 2u * 1024u * 1024u)
<< "this test is about mirroring that only exists in the 2MB config";
@@ -355,6 +353,103 @@ TEST(IopSmc, JitStoreThroughRamMirrorInvalidatesBlockCompiledThere)
EXPECT_EQ(h.GetGprInterp(reg::v0), 0x1337u);
}
// The three tests below pin CROSS-alias SMC: the store and the compiled block
// name *different* mirrors of the same physical RAM. The recLUT shares the
// BASEBLOCK slots across the four RAM mirrors (guest page i maps physical page
// i & 0x1f), so a block compiled at one alias stays dispatchable through every
// other — which means the clear path must find it from every other, too. The
// shuffle suite found this for real: IopIrxHle leaves a block compiled at
// canonical 0x14000, and JitStoreThroughRamMirrorInvalidatesBlockCompiledThere
// then loads its victim program through the 0x214000 mirror; the C clear keyed
// on the un-collapsed address missed the stale block and the JIT executed the
// IRX test's code. These are the deterministic forms.
TEST(IopSmc, CStoreThroughRamMirrorInvalidatesBlockAtCanonicalAddress)
{
ASSERT_EQ(Ps2MemSize::ExposedIopRam, 2u * 1024u * 1024u)
<< "this test is about mirroring that only exists in the 2MB config";
constexpr u32 kMirrorBlock2Pc = 0x00200000u + kBlock2Pc;
JitTestHarness h;
h.LoadProgramAt(kBlock2Pc, {
ADDIU(reg::v0, reg::zero, 0x0BAD), // original opcode; overwritten
}, /*append_jr_ra_term=*/true);
h.SetPc(kBlock2Pc);
h.Run();
ASSERT_EQ(h.GetGprInterp(reg::v0), 0x0BADu);
// C-path store through the +2MB mirror: same physical word the block was
// compiled from, different guest alias.
iopMemWrite32(kMirrorBlock2Pc, ADDIU(reg::v0, reg::zero, 0x1337));
h.SetPc(kBlock2Pc);
h.SetRa(kParkingPc);
h.RunResume();
EXPECT_EQ(h.GetGprJit(reg::v0), 0x1337u);
EXPECT_EQ(h.GetGprInterp(reg::v0), 0x1337u);
}
TEST(IopSmc, CStoreAtCanonicalAddressInvalidatesBlockCompiledAtMirror)
{
// Reverse orientation: the block is compiled while executing at the
// mirror alias, the new code is stored at the canonical address.
ASSERT_EQ(Ps2MemSize::ExposedIopRam, 2u * 1024u * 1024u)
<< "this test is about mirroring that only exists in the 2MB config";
constexpr u32 kMirrorBlock2Pc = 0x00200000u + kBlock2Pc;
JitTestHarness h;
h.LoadProgramAt(kMirrorBlock2Pc, {
ADDIU(reg::v0, reg::zero, 0x0BAD), // original opcode; overwritten
}, /*append_jr_ra_term=*/true);
h.SetPc(kMirrorBlock2Pc);
h.Run();
ASSERT_EQ(h.GetGprInterp(reg::v0), 0x0BADu);
iopMemWrite32(kBlock2Pc, ADDIU(reg::v0, reg::zero, 0x1337));
h.SetPc(kMirrorBlock2Pc);
h.SetRa(kParkingPc);
h.RunResume();
EXPECT_EQ(h.GetGprJit(reg::v0), 0x1337u);
EXPECT_EQ(h.GetGprInterp(reg::v0), 0x1337u);
}
TEST(IopSmc, JitStoreThroughRamMirrorInvalidatesBlockAtCanonicalAddress)
{
// Same cross-alias geometry as the C-path test above, but the store is a
// guest SW compiled by the JIT, so it exercises the store stub's coverage
// probe rather than iopMemWrite32.
ASSERT_EQ(Ps2MemSize::ExposedIopRam, 2u * 1024u * 1024u)
<< "this test is about mirroring that only exists in the 2MB config";
constexpr u32 kMirrorBlock2Pc = 0x00200000u + kBlock2Pc;
JitTestHarness h;
h.SetGpr(reg::a0, kMirrorBlock2Pc);
h.SetGpr(reg::a1, ADDIU(reg::v0, reg::zero, 0x1337));
h.LoadProgramAt(kProgramPc, {
SW(reg::a1, 0, reg::a0), // store through the mirror alias
J(kBlock2Pc), // enter through the canonical one
NOP,
}, /*append_jr_ra_term=*/false);
h.LoadProgramAt(kBlock2Pc, {
ADDIU(reg::v0, reg::zero, 0x0BAD), // original opcode; overwritten
}, /*append_jr_ra_term=*/true);
// Compile the victim first at its canonical address.
h.SetPc(kBlock2Pc);
h.Run();
ASSERT_EQ(h.GetGprInterp(reg::v0), 0x0BADu);
h.SetPc(kProgramPc);
h.SetRa(kParkingPc);
h.RunResume();
EXPECT_EQ(h.GetGprJit(reg::v0), 0x1337u);
EXPECT_EQ(h.GetGprInterp(reg::v0), 0x1337u);
}
TEST(IopSmc, JitStoreToUnmappedLowRegionIsDropped)
{
// 0x00900000 has bit 28 clear but no WLUT mapping — iopMemWrite32 drops