Skip to content

Arcade (COH/SC2): boot on a single reset after a card switch; fix a stuck-core1 hang on card switch; add version/ls/stat serial commands - #104

Open
mathewbeall wants to merge 2 commits into
sd2psXtd:developfrom
mathewbeall:arcade/single-reset-boot
Open

Arcade (COH/SC2): boot on a single reset after a card switch; fix a stuck-core1 hang on card switch; add version/ls/stat serial commands#104
mathewbeall wants to merge 2 commits into
sd2psXtd:developfrom
mathewbeall:arcade/single-reset-boot

Conversation

@mathewbeall

Copy link
Copy Markdown

Summary

Three commits from running sd2psXtd as the security-dongle source on real Namco System 246 / System 256 arcade boards (a Raspberry Pi drives the SD2PSX over its USB-CDC serial port and selects a card per game). All three are on top of 1.4.0 (identical in content to main at the time), and the resulting tree has been running as 1.4.0-coh3 on two units since 2026-09-04.

  1. ps2: arcade variants no longer need a second console reset after a card switch - the reason we started. Variant-gated: only the Arcade (COH) and Conquest (SC2) variants change; Retail and Proto behave exactly as before.
  2. serial: add 'ls [path]' and 'stat <path>' - lets a host check a card image exists on the SD card before switching to it. Optional; harmless on its own.
  3. ps2: make a card switch survive a core1 that is stuck mid-command - a bug in stock 1.4.0 that hits every variant. This one may be worth taking even if you decline the rest; it is the last commit so it can be cherry-picked, though two of its hunks sit in code the first commit touches (ps2_mmceman.c), so it will need a small adjustment on its own.

1. Arcade boards need two resets per card switch (stock behaviour)

On every card switch the firmware refuses the next five card-identifier requests so a PS2's mcman drops its cache (ps2_mmceman.c, the retry counter). An arcade BIOS identifies the card once at boot and gives up, so on a 246/256 the first boot after any switch ends in "Boot Program Error" and a second reset is needed. Those boards have no mcman cache to protect.

The fix keeps the five refusals armed through the switch and the card load - they also guard the load against stray commands - and clears the counter once the card manager reports the new card loaded, on Arcade/Conquest variants only. Our first attempt zeroed the counter at switch time instead; that hung the firmware on a System 256 when switching away from a game that was actively using the card (4 of 5 attempts, versus 0 of 2 on stock), which is how we found the bug in section 3. The commit here is the corrected version.

The same commit adds a version serial command:

> version
Version: 1.4.0-coh3
Commit: 8cbc821

Stock answers Unknown command., which is how the host tells the two apart.

Tested: every game in both libraries, one card switch each, one reset release, judged at the cabinet monitor:

  • System 246: 72 of 72 boot on a single release.
  • System 256: 106 unique card/disc pairs in an automated run (125 switches, every one answered), then the library again through the production load path; the only failures are two disc images that fail identically on stock firmware and have nothing to do with the card.
  • Retail variant untouched by design; not re-tested on a PS2.

2. ls and stat over serial

> stat MemoryCards/COH/Card57/Card57-1.mcd
File: MemoryCards/COH/Card57/Card57-1.mcd size 8388608
> stat MemoryCards/COH/Card99
Missing: MemoryCards/COH/Card99
> ls MemoryCards/COH/Card57
F 8388608 Card57-1.mcd
F 88 Card57.ini
Total: 2

Both answer Busy: card operation in progress while a card switch or card creation is running. The motivation: when a host selects a card index that is not on the SD card the firmware quietly creates a blank one and the arcade board boots into "Boot Program Error" with nothing in any log to say why. With stat the host can refuse the switch instead.

3. Stock bug: a card switch while core1 is mid-command kills the device

Reproducible on stock 1.4.0, seen on two SD2PSX units and two arcade boards: switch cards while the console is mid-transaction and gets reset. core1 sits in receive() waiting for a byte that never comes, watching only the console-reset flag, so it never acknowledges the exit request. The one-second recovery in ps2_memory_card_exit() then hard-resets core1 and relaunches it; the relaunched core re-claims PIO state machines still claimed by the dead run and panics with No PIO state machines are available - once a second, forever. From outside: dead card, dead USB serial, only a power cycle helps. Silent on release builds, which is why it looked like a random hang.

The fix:

  • receive() and mc_respond() also honour the exit request.
  • The recovery path unclaims the PIO, releases the dirty spinlock the dead core may have held for an in-flight PSRAM DMA, and re-arms its timeout instead of resetting core1 on every loop iteration.
  • Each card-switch step prints over USB in DEBUG_USB_UART builds only (that is how the sequence was pinned down; SW1..SW8 and Reset at <cmd>).

Tested: on a System 256, six switches away from a running game - the sequence that hung the firmware four times out of five before - all survived, four of them logged as caught mid-command. Across the two library runs, 125 + ~100 further switches with no hang.

Build

Built with the recipe from .github/workflows/build_nightly.yml on macOS (cmake, ninja, arm-none-eabi-gcc with newlib):

git submodule update --init --recursive --depth 1
cmake -B build -DCMAKE_BUILD_TYPE=Release -DDEBUG_USB_UART:BOOL=OFF -DVARIANT:STRING=SD2PSX -G Ninja
cmake --build build

An unpatched build from the same tree reproduces the upstream 1.4.0 version string, which is how the toolchain was proven before patching. Both the Release and the -DDEBUG_USB_UART=ON variants of this tree have been flashed and run.

Notes for review

  • The -coh<N> suffix in the version string above is just our local tag name (git describe on our tree); nothing in the code depends on it.
  • The commits carry Co-Authored-By: Claude Fable 5.1 because the patches were written with Anthropic's Claude Code in the loop, against hardware, with the results above checked by a person at the cabinet. Happy to reword or squash however you prefer.
  • The full write-up of the arcade side (why the boards behave this way, the card layout the host uses, the run tables) lives in a private project repo; I can paste any part of it here on request.

Thanks for sd2psXtd - it is the reason a 20-year-old Namco board can switch between 137 games from a menu.

@bbsan2k

bbsan2k commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR.😊
Can you please retarget to develop branch, since main is only intended for releases.
I need to do an in depth review in the next days, as unfortunately Sol and Fable sometimes break some edge cases in the code!

EDIT:
Some first remarks

  • Please do not use printf directly - use log, since it will use a buffered print which should not stall the Core too long
  • Please check about additional RAM consumption

@mathewbeall
mathewbeall force-pushed the arcade/single-reset-boot branch from bbb0c37 to 842aa87 Compare September 5, 2026 06:24
@mathewbeall
mathewbeall changed the base branch from main to develop September 5, 2026 06:24
@mathewbeall

Copy link
Copy Markdown
Author

Thanks for the quick look. All three points addressed, branch force-pushed (head 842aa87):

Retargeted to develop. The three commits are now rebased onto develop (5e0d7f0-era, i.e. on top of the history-tracker CRC change); the PR base is switched. Same three commits, same content otherwise.

No more direct printf in the emulation code. Every debug print that the patches had added inside ps2_mmceman.c / ps2_memory_card.c now goes through the file's log() macro, at the level the neighbouring lines use:

  • the card-switch step traces are log(LOG_TRACE, ...), so they are silent at the default file level and appear when LOG_LEVEL_PS2_S2M is raised, like your own After Exit / After Close traces - two of which my earlier patch had displaced; they are restored verbatim.
  • the "core1 did not ack in 1s - hard reset" message is log(LOG_WARN, ...).
  • the retry-counter line goes back to your original log(LOG_WARN, "Ignoring mcman for another %i requests\n", ...).
    The Release binary is logically unchanged by this (all of it compiles out without DEBUG_USB_UART), so it is the same code that ran the two arcade libraries.

The replies of the new serial commands (version, ls, stat) in serial_input.c still use printf, because every existing reply in that file does (Resetting, Channel Up, the prompt, the error text) and they run from the serial handler on core0, not from the card emulation. If you would rather have those on buffered_printf/DPRINTF too, say so and I will switch them.

RAM. Linker memory report, VARIANT=SD2PSX, Arm GNU Toolchain 15.3.1, built from .github/workflows/build_nightly.yml's recipe, stock develop vs this branch:

build RAM flash
develop, Release 191440 B (73.03%) 1180496 B
this PR, Release 191504 B (73.05%), +64 B 1182096 B, +1600 B
develop, DEBUG_USB_UART 196724 B 1190860 B
this PR, DEBUG_USB_UART 196788 B, +64 B 1192532 B, +1672 B

.bss is byte-for-byte the same size; the 64 B of .data are the version/commit/branch strings version prints plus one bool. No heap use. Transient stack only in the serial handler while ls/stat run: one 128-byte path buffer in the parsed-command struct and one 128-byte name buffer in ls, released when the command returns. SCRATCH_X/SCRATCH_Y unchanged.

Take your time with the review - and if it helps I can run any variant you build on the two arcade boards here.

@bbsan2k

bbsan2k commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

I finally had the chance to give the content a good look. I would like to split it into 2 parts

  1. Serial changes: This looks good to me - can you please create another PR only containing this change?

  2. Card Exit logic:

@mathewbeall

Copy link
Copy Markdown
Author

Split the serial commands out into #106 as requested. #104 now only needs to carry the card-exit logic (single-reset boot + core1-hang fix) - working through your other four points on that next.

mathewbeall and others added 2 commits September 7, 2026 00:28
…rd switch

On every card switch the firmware refuses the next five card-identifier
requests so a PS2's mcman drops its cache. Arcade BIOSes (Namco System
246/256: the COH and SC2 variants) identify the card once at boot and
give up, so the first boot after any switch fails with "Boot Program
Error" and a second reset is needed. Those boards have no mcman cache to
protect.

Keep the five refusals armed through the switch and the card load - they
also guard the load against stray commands, and zeroing the counter at
switch time hung the firmware on a System 256 when switching away from a
game that was actively using the card (4 of 5 attempts, 0 of 2 on stock).
Once the card manager reports the new card loaded, an Arcade (COH) or
Conquest (SC2) variant clears the counter. Retail and Proto are
unchanged.

Tested on real Namco System 246 and System 256 hardware with a Raspberry
Pi driving the SD2PSX over USB-CDC: 72 of 72 games on the 246 and the
whole 256 library boot on a single reset release after a card switch.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1gpWeX4ssmodsaa4oUt6x
Reproducible on stock 1.4.0 (seen on two units, two boards): switch cards
while the console is mid-transaction and gets reset - core1 sits in
receive() waiting for a byte that never comes, watching only the
console-reset flag, so it never acknowledges the exit request. The 1s
recovery in ps2_memory_card_exit() then hard-resets core1 and relaunches
it, and the relaunched core re-claims PIO state machines still claimed
by the dead run and panics ('No PIO state machines are available'), once
a second, forever: dead card, dead USB, only a power cycle helps. Silent
on release builds.

- receive() and mc_respond() also honour the exit request
- the recovery path unclaims the PIO, releases the dirty spinlock the
  dead core may have held for an in-flight PSRAM DMA, and re-arms its
  timeout instead of resetting core1 on every loop iteration
- each card-switch step prints over USB in DEBUG_USB_UART builds only

Verified on a Namco System 256: six switches away from a running game
(the sequence that hung the firmware four times out of five before this)
all survived, four of them logged as caught mid-command; 125 switches in
an automated run were all answered.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@mathewbeall
mathewbeall force-pushed the arcade/single-reset-boot branch from 842aa87 to 0bdf96c Compare September 7, 2026 08:43
@mathewbeall

mathewbeall commented Sep 7, 2026

Copy link
Copy Markdown
Author

Thanks for the detailed review, and sorry for the delay getting back to you.

Split, rename, and the unload() call: all done, and the branch has been rebased/force-pushed on top of current develop (it's a clean 2-commit diff now, no more overlap with #106).

  • Serial commands split into serial: add 'version', 'ls' and 'stat' commands #106 - merged, thank you.
  • ps2_mc_data_interface_core1_was_reset renamed to ps2_mc_data_interface_reset.
  • Dropped ps2_memory_card_unload() from the core1-hard-reset recovery path per your note - agreed, the card can stay in place.

The two remaining points (the receive() exit-check, and the retry-counter guard) - I didn't want to just push back with the commit message as the only argument, since you know this codebase far better than I do and there could easily be something about card (de)selection state I'm missing. So instead I built a firmware with both of your suggestions applied literally - the receive() check reverted, and mmceman_mcman_retry_counter = 5 guarded directly by variant instead of the deferred clear-on-idle - on top of the exact same base as our currently-deployed 1.4.0-coh3 (upstream 1.4.0 + the original patch series, unmodified otherwise), and tested it on real Namco System 256 hardware: repeatedly switching away from a game that's actively reading its disc, the same scenario the original commits were written against.

Over 16 switches, it hung 3 times (~19%):

  • Once with the SD2PSX's serial/USB going fully unresponsive mid-switch and a blue "Boot Program Error" on the console - recoverable only with a full power cycle, matching what stock 1.4.0 did before these patches.
  • Twice with a narrower failure: the SD2PSX stayed reachable over USB with a clean step trace through the whole switch (built with DEBUG_USB_UART for this), but the console-facing side locked up completely - no further disc reads, black screen - and a plain console reset (same reset line the switch itself uses) did not recover it, only a full power cycle did. Since the switch bookkeeping traced clean both times, this one points specifically at the reverted receive() check rather than anything else in the switch path.

Given that, I'd like to keep both of these as they are in the PR rather than change them - but I'm very open to a different fix for the underlying concerns you raised (the deselection semantics on receive(), and the readability of the retry-counter clear) if you have one in mind. Happy to test anything else the same way.

@bbsan2k

bbsan2k commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Some steps that may help root causing the issue:

If the sd2psx side becomes unresponsive (like in the OLED menu etc) this typically means that Core 0 has stalled somewhere - probably your error case 1.

If console side becomes unresponsive it usually means that either the PIOs have locked up, or (more often) that card switching has somehow locked up (ping-pong exit logic).

Would it be possible for you to check with a logic analyser, if the SEL line stays LO during a longer period / when this issue happens? You are explicitly checking for receiving an exit (card switch) during the receive function. On deselction of the card, the reset flag is set through an interrupt, so even if the card does not immediately step out of the receive loop on an exit request, it will do after deselection.

Therefore usually the sequence for a card switch should be:

  1. Core 0 calls exit
  2. Core 1 finishes currently running transfer (as long as SEL is LO)
  3. Core 1 acks the exit request

@mathewbeall

Copy link
Copy Markdown
Author

Let me see what I can wire up to the card and get some additional data on the what the SEL line is doing. I will also check if the menu is responsive on the card when an error has occured. Will reply back with more data!

@mathewbeall

Copy link
Copy Markdown
Author

Following up on my last comment with better data.

To be precise about terms: everywhere below, "the shipped fix" means upstream 1.4.0 plus the three original patches from this PR, completely unmodified - that's the exact build that's been running on both cabinets since 2026-09-04. Every build in the table is that same code with only the one named change on top of it; nothing else moves between rows.

Every row below is the shipped fix with only the stated change applied, driven through the real load path (what the web UI/OLED actually triggers), judged by whether the switch completes:

build (all on top of the shipped fix, only the named part changed) switches hangs
both of your suggestions: the mc_exit_request check removed from receive(), and mmceman_mcman_retry_counter = 5 guarded directly by variant (0 for arcade) instead of our clear-on-card-ready callback 26 8 (~31%)
shipped fix, nothing changed (sanity check that it doesn't hang on its own) 30 0
only the mc_exit_request check removed from receive() - the retry-counter clear-on-ready logic and everything else left exactly as shipped 10 4 (~40%)
only the retry-counter guarded directly by variant, your way - the receive()/mc_respond() changes left exactly as shipped 30 0

So it splits cleanly: the retry-counter change is fine - guarding mmceman_mcman_retry_counter = 5 by variant instead of our clear-on-ready callback didn't hang once over 30 switches, and I'm happy to take that change as you suggested. The receive() check is the one that actually matters - removing just that, with the retry-counter logic untouched, reproduces the hang at basically the same rate as removing both.

On the mechanism - I went back and checked with an oscilloscope on the actual SEL line during real hangs (probed at the test point behind the card-edge connector, pulse-width triggered on anything over 50ms). SEL does not stay low for any extended period during a hang - a >50ms low-pulse trigger never once fired across all the hangs I caught, only on some unrelated slow-but-successful selections. So I don't think receive() is parked waiting on a card that's stuck selected, the way the commit message describes it. My best guess now is a short race right at the moment of a switch - the existing reset flag is interrupt-driven off deselection, and there may be a narrow window where that doesn't line up cleanly with a switch happening at nearly the same instant. The polled mc_exit_request check doesn't depend on that interrupt timing at all, which would explain why it closes the gap even though the wire never shows anything as dramatic as a long stall.

Given the retry-counter result, want me to update the PR to use your version there and keep the receive() check as-is? Or if you've got a different idea for the receive() side given the SEL evidence, I'm glad to test it the same way.

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.

2 participants