Skip to content

fix(runtime): accept non-zero memory-card slots in isValidMcPortSlot#174

Open
smmathews wants to merge 2 commits into
ran-j:mainfrom
smmathews:feature/11-mc-slot-validation
Open

fix(runtime): accept non-zero memory-card slots in isValidMcPortSlot#174
smmathews wants to merge 2 commits into
ran-j:mainfrom
smmathews:feature/11-mc-slot-validation

Conversation

@smmathews

Copy link
Copy Markdown
Contributor

Summary

isValidMcPortSlot(port, slot) in
ps2xRuntime/src/lib/Kernel/Stubs/MemoryCard.cpp hardcodes slot == 0. This
predicate gates the ten port/slot-addressed sceMc* entry points —
sceMcChdir, sceMcDelete, sceMcFormat, sceMcGetDir, sceMcGetInfo,
sceMcMkdir, sceMcOpen, sceMcRename, sceMcSetFileInfo, and
sceMcUnformat — so each of them rejects any call that passes a non-zero slot
with kMcResultNoEntry ("no entry" / card-not-found) even when the requested
port is in range and a virtual card exists for it. The fd-based calls
(sceMcRead, sceMcWrite, sceMcSeek, sceMcFlush, sceMcClose) take an
open descriptor
rather than a (port, slot) and so are not gated directly, and
sceMcGetEntSpace is an unconditional stub; but because a descriptor can only
come from sceMcOpen — which is gated — those fd-based calls are unreachable
once the slot guard blocks open.

Root cause

The HLE keeps backing state per portg_mcPorts is a 2-element array
(port 0 and port 1, the PS2's two physical card connectors) — and none per
slot. slot never indexes any state and getMcRootPath() ignores it;
every caller resolves state from g_mcPorts[port] alone. The slot == 0
clause 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=0 and were unaffected, which is why the bug went
unnoticed. But the PS2 SDK addresses a card via (port, slot), and does not
require slot == 0: Dragon Quest VIII issues its startup memory-card probe,
sceMcGetInfo, with slot=1 at the "Checking memory card..." screen. Under
the current predicate that one call fails, and because the same guard
fronts the other slot-addressed entry points — including sceMcOpen, the only
source 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:

bool isValidMcPortSlot(int32_t port, int32_t slot)
{
    return port >= 0 && port < static_cast<int32_t>(g_mcPorts.size()) &&
           slot >= 0;
}

slot names no backing state, so any non-negative slot resolves to the same
card 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. sceMcGetSlotMax is left at its existing value of 1 — the
hardware-faithful no-multitap count. Two reasons:

  • The title never reads sceMcGetSlotMax; it addresses sceMcGetInfo(port, slot=1) directly, so widening acceptance alone unblocks it. What we can
    actually observe is the title's own behaviour: a retail, no-multitap game
    ships this slot=1 probe and hard-stops unless it succeeds. Since that title
    boots on a bare console, the reasonable inference is that real hardware
    services the directly addressed slot even though GetSlotMax does not
    enumerate it — an inference from the title's fail-stop behaviour, not a
    hardware measurement.
  • Raising GetSlotMax above 1 would advertise a multitap the HLE cannot
    distinctly 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.cpp driving
the public libmc surface:

  • sceMcGetInfo(port=0, slot=1) on a formatted port reports a PS2 card and
    succeeds through sceMcSync (fails under the old slot == 0 predicate,
    pinning the fix).
  • a negative slot (0xFFFFFFFF-1) returns kMcResultNoEntry with card
    type 0, pinning the lower slot bound.
  • an out-of-range port (2) and a negative port (-1) each return
    kMcResultNoEntry, so the port bound cannot be dropped.
  • the save flow end to end: sceMcOpen(port=0, slot=1, …) yields a usable
    descriptor and sceMcWrite/sceMcSeek/sceMcRead/sceMcClose round-trip a
    payload through it — the fd-based path reachable only once open succeeds at
    a non-zero slot (fails at the open under the old slot == 0 predicate).

Regression risk

Low. The slot range only widens (from {0} to all non-negative slots); it
cannot turn a previously-accepted (port, slot) into a rejection, and titles
that pass slot=0 are unaffected. slot is never used to index any array or
select any state (verified: only port indexes g_mcPorts), so accepting
more slot values cannot read or write out of bounds. A negative slot is still
rejected. sceMcGetSlotMax is unchanged, so nothing the guest enumerates
changes. 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 == 0 predicate, have been honestly rejected at
slot=1; it is now instead accepted and silently aliased onto that port's
one 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=1 probe into a hard
fail-stop — dominates.

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
smmathews marked this pull request as ready for review July 21, 2026 15:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant