fix(runtime): accept non-zero memory-card slots in isValidMcPortSlot#174
Open
smmathews wants to merge 2 commits into
Open
fix(runtime): accept non-zero memory-card slots in isValidMcPortSlot#174smmathews wants to merge 2 commits into
smmathews wants to merge 2 commits into
Conversation
isValidMcPortSlot() hardcoded slot == 0. It gates the ten port/slot-addressed
sceMc* entry points (sceMcChdir, sceMcDelete, sceMcFormat, sceMcGetDir,
sceMcGetInfo, sceMcMkdir, sceMcOpen, sceMcRename, sceMcSetFileInfo,
sceMcUnformat), so each of those rejected any call that passed a non-zero slot
with kMcResultNoEntry even when the requested port was in range. The fd-based
calls (sceMcRead, sceMcWrite, sceMcSeek, sceMcFlush, sceMcClose) take an open
descriptor rather than a (port, slot), so the guard never runs there directly
-- but a descriptor can only come from sceMcOpen, which is gated, so they are
unreachable once open fails; sceMcGetEntSpace is an unconditional stub and
never consults the guard. Dragon Quest VIII probes its card with sceMcGetInfo
at slot=1 on the "Checking memory card..." screen, so that one call failed and
the whole memory-card subsystem became unreachable for the title ("a memory
card was not found in slot 1").
This HLE keeps backing state per port (g_mcPorts, one entry per physical PS2
connector) and none per slot: slot never indexes any state, and
getMcRootPath() ignores it. Widen the accept predicate to tolerate the whole
non-negative slot address space and map any slot onto the port's single
virtual card. A negative slot is not a real address and is still rejected; the
port bound is unchanged. This imposes no upper slot bound: a slot beyond the
four a real multitap could physically expose is accepted rather than rejected,
which is harmless because slot indexes no state -- every slot already aliases
the port's single card -- so a real title has no reason to reach one, and a
title that somehow does is served that same card, no worse than the aliasing
described above.
This is deliberate tolerance, not advertisement. sceMcGetSlotMax is left at
its existing no-multitap value of 1: the title addresses the slot directly and
never consults it, and reporting more slots would advertise a multitap the HLE
cannot distinctly back -- every slot aliases the one card, so an enumerator
would see identical cards and a "copy slot 0 to slot 1" would self-overwrite.
Tolerating a slot the guest addresses while advertising only the count actually
modeled is the safe direction. The reasonable inference -- from the title's own
fail-stop behaviour, not a hardware measurement -- is that retail hardware
services this directly addressed slot=1 probe even with no multitap present.
Adds regression tests driving the public libmc surface: sceMcGetInfo with a
non-zero slot succeeds and reports the port's card; a negative slot, and an
out-of-range or negative port, each return kMcResultNoEntry. A further test
drives the save flow end to end -- sceMcOpen at slot 1 yields a usable
descriptor and sceMcWrite/sceMcSeek/sceMcRead/sceMcClose round-trip a payload
through it -- exercising the fd-based path that becomes reachable only once
open at a non-zero slot succeeds.
ps2x_tests 336/336.
ran-j
pushed a commit
that referenced
this pull request
Jul 21, 2026
…176) The "Semaphore poll/signal remains stable under host-thread contention" test created the semaphore full (init == max == 1), so SignalSema could only succeed after PollSema had already freed a slot. With no start barrier, under host-thread contention the signaler thread could run all 64 of its iterations before the poller's first timeslice, making every SignalSema legitimately return KE_SEMA_OVF and leaving signalOkCount at 0 — a false failure of "contended SignalSema should observe successful releases". An earlier investigation of the unfixed test observed this twice in 24 contended (8-way-parallel) full-suite runs and never in 20 serial runs; CI has independently hit the same assertion on the unrelated draft PR #174, confirming the trigger is host scheduling, not the code under review. Seed the semaphore with headroom (init=1, max=2) so the first PollSema and the first SignalSema each succeed regardless of scheduling order, and add a start barrier so both workers start together, maximizing the opportunity to interleave. Widen the final-count range check to the new max. The semaphore implementation is unchanged; both threads still contend concurrently on the same per-semaphore mutex.
smmathews
marked this pull request as ready for review
July 21, 2026 15:51
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
isValidMcPortSlot(port, slot)inps2xRuntime/src/lib/Kernel/Stubs/MemoryCard.cpphardcodesslot == 0. Thispredicate gates the ten port/slot-addressed
sceMc*entry points —sceMcChdir,sceMcDelete,sceMcFormat,sceMcGetDir,sceMcGetInfo,sceMcMkdir,sceMcOpen,sceMcRename,sceMcSetFileInfo, andsceMcUnformat— so each of them rejects any call that passes a non-zero slotwith
kMcResultNoEntry("no entry" / card-not-found) even when the requestedport is in range and a virtual card exists for it. The fd-based calls
(
sceMcRead,sceMcWrite,sceMcSeek,sceMcFlush,sceMcClose) take anopen descriptor
rather than a
(port, slot)and so are not gated directly, andsceMcGetEntSpaceis an unconditional stub; but because a descriptor can onlycome from
sceMcOpen— which is gated — those fd-based calls are unreachableonce the slot guard blocks
open.Root cause
The HLE keeps backing state per port —
g_mcPortsis a 2-element array(port 0 and port 1, the PS2's two physical card connectors) — and none per
slot.slotnever indexes any state andgetMcRootPath()ignores it;every caller resolves state from
g_mcPorts[port]alone. Theslot == 0clause was therefore validating against state that does not exist, acting as
an arbitrary over-strict filter rather than a real bound.
Most titles pass
slot=0and were unaffected, which is why the bug wentunnoticed. But the PS2 SDK addresses a card via
(port, slot), and does notrequire
slot == 0: Dragon Quest VIII issues its startup memory-card probe,sceMcGetInfo, withslot=1at the "Checking memory card..." screen. Underthe current predicate that one call fails, and because the same guard
fronts the other slot-addressed entry points — including
sceMcOpen, the onlysource of a file descriptor — the whole memory-card subsystem becomes
unreachable for the title (it reports "a memory card was not found in slot 1"
and never reaches its save/load flow).
Fix
Widen the accept predicate to tolerate the whole non-negative slot address
space, and map any slot onto the port's single virtual card:
slotnames no backing state, so any non-negative slot resolves to the samecard the port already provides; a negative slot is not a real address and is
rejected. The port bound is unchanged. No signature change, no new includes,
no per-slot state. There is deliberately no upper slot bound: a slot beyond
the four a real multitap could physically expose is accepted rather than
rejected, which is harmless because slot indexes no state — every slot already
aliases the port's single card — so a real title has no reason to reach one,
and a title that somehow does is served that same card, no worse than the
aliasing described below.
Tolerance, not advertisement
This deliberately accepts more slots than the HLE advertises, and that is the
safe direction.
sceMcGetSlotMaxis left at its existing value of 1 — thehardware-faithful no-multitap count. Two reasons:
sceMcGetSlotMax; it addressessceMcGetInfo(port, slot=1)directly, so widening acceptance alone unblocks it. What we canactually observe is the title's own behaviour: a retail, no-multitap game
ships this
slot=1probe and hard-stops unless it succeeds. Since that titleboots on a bare console, the reasonable inference is that real hardware
services the directly addressed slot even though
GetSlotMaxdoes notenumerate it — an inference from the title's fail-stop behaviour, not a
hardware measurement.
GetSlotMaxabove 1 would advertise a multitap the HLE cannotdistinctly back. Every slot aliases the port's one card (same files, same
free space): an enumerator would see identical cards, and a save-manager
doing "copy slot 0 → slot 1" would silently self-overwrite. Tolerating a
slot the guest addresses is safe; advertising backing that isn't there is
not.
Test
Adds four regression cases in
ps2xTest/src/ps2_runtime_io_tests.cppdrivingthe public libmc surface:
sceMcGetInfo(port=0, slot=1)on a formatted port reports a PS2 card andsucceeds through
sceMcSync(fails under the oldslot == 0predicate,pinning the fix).
0xFFFFFFFF→-1) returnskMcResultNoEntrywith cardtype 0, pinning the lower slot bound.
2) and a negative port (-1) each returnkMcResultNoEntry, so the port bound cannot be dropped.sceMcOpen(port=0, slot=1, …)yields a usabledescriptor and
sceMcWrite/sceMcSeek/sceMcRead/sceMcCloseround-trip apayload through it — the fd-based path reachable only once
opensucceeds ata non-zero slot (fails at the
openunder the oldslot == 0predicate).Regression risk
Low. The slot range only widens (from
{0}to all non-negative slots); itcannot turn a previously-accepted
(port, slot)into a rejection, and titlesthat pass
slot=0are unaffected.slotis never used to index any array orselect any state (verified: only
portindexesg_mcPorts), so acceptingmore slot values cannot read or write out of bounds. A negative slot is still
rejected.
sceMcGetSlotMaxis unchanged, so nothing the guest enumerateschanges. The one genuine regression runs the other direction. A hypothetical
multitap title that wrote distinct data to distinct slots on a single port
would, under the old
slot == 0predicate, have been honestly rejected atslot=1; it is now instead accepted and silently aliased onto that port'sone card — a self-overwrite where the old code failed cleanly. I accept that
tradeoff because no such title is known, and the common single-card case —
where the old predicate turned a legitimate
slot=1probe into a hardfail-stop — dominates.