summaryrefslogtreecommitdiff
path: root/Source/Core
diff options
context:
space:
mode:
authorJMC47 <JMC4789@gmail.com>2026-03-21 23:57:05 -0400
committerGitHub <noreply@github.com>2026-03-21 23:57:05 -0400
commit85e63dc32f1bbf1d5442387839118ec0ed175aa9 (patch)
tree7cdfc5c16b94ce9870af34dde3cb7e19b091ab24 /Source/Core
parent0e92a147036fa9ab8a953b40723f43142c2e5d7b (diff)
parent680ee9b65f1eb3870954a20cf112cae7f75f0553 (diff)
Merge pull request #14482 from jordan-woyak/triforce-ic-card
Triforce: ICCardReader and DeckReader improvements.
Diffstat (limited to 'Source/Core')
-rw-r--r--Source/Core/Core/CMakeLists.txt6
-rw-r--r--Source/Core/Core/HW/MagCard/MagneticCardReader.cpp15
-rw-r--r--Source/Core/Core/HW/SI/SI_DeviceAMBaseboard.cpp730
-rw-r--r--Source/Core/Core/HW/SI/SI_DeviceAMBaseboard.h79
-rw-r--r--Source/Core/Core/HW/Triforce/DeckReader.cpp449
-rw-r--r--Source/Core/Core/HW/Triforce/DeckReader.h41
-rw-r--r--Source/Core/Core/HW/Triforce/ICCardReader.cpp774
-rw-r--r--Source/Core/Core/HW/Triforce/ICCardReader.h110
-rw-r--r--Source/Core/Core/HW/Triforce/IOPorts.cpp226
-rw-r--r--Source/Core/Core/HW/Triforce/IOPorts.h183
-rw-r--r--Source/Core/Core/HW/Triforce/SerialDevice.cpp32
-rw-r--r--Source/Core/Core/HW/Triforce/SerialDevice.h3
-rw-r--r--Source/Core/Core/State.cpp2
-rw-r--r--Source/Core/DolphinLib.props6
14 files changed, 1953 insertions, 703 deletions
diff --git a/Source/Core/Core/CMakeLists.txt b/Source/Core/Core/CMakeLists.txt
index 821080afca..eb11771fa0 100644
--- a/Source/Core/Core/CMakeLists.txt
+++ b/Source/Core/Core/CMakeLists.txt
@@ -294,6 +294,12 @@ add_library(core
HW/StreamADPCM.h
HW/SystemTimers.cpp
HW/SystemTimers.h
+ HW/Triforce/DeckReader.cpp
+ HW/Triforce/DeckReader.h
+ HW/Triforce/ICCardReader.cpp
+ HW/Triforce/ICCardReader.h
+ HW/Triforce/IOPorts.cpp
+ HW/Triforce/IOPorts.h
HW/Triforce/SerialDevice.cpp
HW/Triforce/SerialDevice.h
HW/Triforce/Touchscreen.cpp
diff --git a/Source/Core/Core/HW/MagCard/MagneticCardReader.cpp b/Source/Core/Core/HW/MagCard/MagneticCardReader.cpp
index 89ae1534d5..80013e3a29 100644
--- a/Source/Core/Core/HW/MagCard/MagneticCardReader.cpp
+++ b/Source/Core/Core/HW/MagCard/MagneticCardReader.cpp
@@ -676,18 +676,9 @@ bool MagneticCardReader::ReceivePacket(std::span<const u8> packet)
return false;
}
- // Checksum is XOR of the previous bytes.
- // This includes the count byte that we already chopped off.
- const u8 read_checksum = packet.back();
-
- // TODO C++23:
- // std::ranges::fold_left(packet.first(packet.size() - 1), u8(packet.size()), std::bit_xor{});
- const auto range_to_checksum = packet.first(packet.size() - 1);
- const auto proper_checksum = std::accumulate(range_to_checksum.begin(), range_to_checksum.end(),
- u8(packet.size()), std::bit_xor{});
-
- // Verify checksum. Skip packet if invalid.
- if (read_checksum != proper_checksum)
+ // The final byte is an XOR checksum of all previous bytes,
+ // including the count byte that we already chopped off.
+ if (std::accumulate(packet.begin(), packet.end(), u8(packet.size()), std::bit_xor{}) != 0)
{
WARN_LOG_FMT(SERIALINTERFACE_CARD, "ReceivePacket: Bad checksum!");
return false;
diff --git a/Source/Core/Core/HW/SI/SI_DeviceAMBaseboard.cpp b/Source/Core/Core/HW/SI/SI_DeviceAMBaseboard.cpp
index 4651454321..2bd7455b8a 100644
--- a/Source/Core/Core/HW/SI/SI_DeviceAMBaseboard.cpp
+++ b/Source/Core/Core/HW/SI/SI_DeviceAMBaseboard.cpp
@@ -27,6 +27,8 @@
#include "Core/HW/SI/SI.h"
#include "Core/HW/SI/SI_Device.h"
#include "Core/HW/SystemTimers.h"
+#include "Core/HW/Triforce/DeckReader.h"
+#include "Core/HW/Triforce/ICCardReader.h"
#include "Core/HW/Triforce/Touchscreen.h"
#include "Core/Movie.h"
#include "Core/System.h"
@@ -112,21 +114,6 @@ void JVSIOMessage::End()
}
}
-static constexpr u8 CheckSumXOR(const u8* data, u32 length)
-{
- return std::accumulate(data, data + length, u8{}, std::bit_xor());
-}
-
-static constexpr char s_cdr_program_version[] = {" Version 1.22,2003/09/19,171-8213B"};
-static constexpr char s_cdr_boot_version[] = {" Version 1.04,2003/06/17,171-8213B"};
-static constexpr u8 s_cdr_card_data[] = {
- 0x00, 0x6E, 0x00, 0x00, 0x01, 0x00, 0x00, 0x06, 0x00, 0x00, 0x07, 0x00, 0x00, 0x0B,
- 0x00, 0x00, 0x0E, 0x00, 0x00, 0x10, 0x00, 0x00, 0x17, 0x00, 0x00, 0x19, 0x00, 0x00,
- 0x1A, 0x00, 0x00, 0x1B, 0x00, 0x00, 0x1D, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x20, 0x00,
- 0x00, 0x22, 0x00, 0x00, 0x23, 0x00, 0x00, 0x24, 0x00, 0x00, 0x27, 0x00, 0x00, 0x28,
- 0x00, 0x00, 0x2C, 0x00, 0x00, 0x2F, 0x00, 0x00, 0x34, 0x00, 0x00, 0x35, 0x00, 0x00,
- 0x37, 0x00, 0x00, 0x38, 0x00, 0x00, 0x39, 0x00, 0x00, 0x3D, 0x00};
-
const constexpr u8 s_region_flags[] = "\x00\x00\x30\x00"
// "\x01\xfe\x00\x00" // JAPAN
"\x02\xfd\x00\x00" // USA
@@ -137,30 +124,10 @@ CSIDevice_AMBaseboard::CSIDevice_AMBaseboard(Core::System& system, SIDevices dev
int device_number)
: ISIDevice(system, device, device_number)
{
- // Card ID
- m_ic_card_data[0x20] = 0x95;
- m_ic_card_data[0x21] = 0x71;
-
- if (AMMediaboard::GetGameType() == KeyOfAvalon)
- {
- m_ic_card_data[0x22] = 0x26;
- m_ic_card_data[0x23] = 0x40;
- }
- else if (AMMediaboard::GetGameType() == VirtuaStriker4)
- {
- m_ic_card_data[0x22] = 0x44;
- m_ic_card_data[0x23] = 0x00;
- }
-
- // Use count
- m_ic_card_data[0x28] = 0xFF;
- m_ic_card_data[0x29] = 0xFF;
-
// Magnetic Card Reader
m_mag_card_settings.card_path = File::GetUserPath(D_TRIUSER_IDX);
m_mag_card_settings.card_name = fmt::format("tricard_{}.bin", SConfig::GetInstance().GetGameID());
- // TODO: Do any other games use the Magnetic Card Reader ?
switch (AMMediaboard::GetGameType())
{
case FZeroAX:
@@ -169,31 +136,57 @@ CSIDevice_AMBaseboard::CSIDevice_AMBaseboard(Core::System& system, SIDevices dev
case MarioKartGP:
case MarioKartGP2:
+ m_io_ports.AddIOAdapter(std::make_unique<Triforce::MarioKartGPCommon_IOAdapter>());
m_serial_device_b = std::make_unique<MagCard::C1231LR>(&m_mag_card_settings);
break;
- case KeyOfAvalon:
- m_serial_device_b = std::make_unique<Triforce::Touchscreen>();
+ case VirtuaStriker4:
+ {
+ auto slot_a = std::make_unique<Triforce::ICCardReader>(0);
+ auto slot_b = std::make_unique<Triforce::ICCardReader>(1);
+ m_io_ports.AddIOAdapter(
+ std::make_unique<Triforce::VirtuaStriker4Common_IOAdapter>(slot_a.get(), slot_b.get()));
+ m_serial_device_a = std::move(slot_a);
+ m_serial_device_b = std::move(slot_b);
break;
+ }
+ case VirtuaStriker4_2006:
+ {
+ auto slot_a = std::make_unique<Triforce::ICCardReader>(0);
+ auto slot_b = std::make_unique<Triforce::ICCardReader>(1);
+ m_io_ports.AddIOAdapter(
+ std::make_unique<Triforce::VirtuaStriker4Common_IOAdapter>(slot_a.get(), slot_b.get()));
+ m_io_ports.AddIOAdapter(
+ std::make_unique<Triforce::VirtuaStriker4_2006_IOAdapter>(slot_a.get(), slot_b.get()));
+ m_serial_device_a = std::move(slot_a);
+ m_serial_device_b = std::move(slot_b);
- default:
break;
}
-}
-
-void CSIDevice_AMBaseboard::ICCardSendReply(ICCommand* iccommand, u8* buffer, u32* length)
-{
- iccommand->status = Common::swap16(iccommand->status);
-
- const auto iccommand_data = reinterpret_cast<const u8*>(iccommand);
- const u8 crc = CheckSumXOR(iccommand_data + 2, iccommand->pktlen - 1);
+ case GekitouProYakyuu:
+ {
+ auto slot_a = std::make_unique<Triforce::ICCardReader>(0);
+ auto slot_b = std::make_unique<Triforce::ICCardReader>(1);
+ m_io_ports.AddIOAdapter(
+ std::make_unique<Triforce::GekitouProYakyuu_IOAdapter>(slot_a.get(), slot_b.get()));
+ m_serial_device_a = std::move(slot_a);
+ m_serial_device_b = std::move(slot_b);
- for (u32 i = 0; i <= iccommand->pktlen; ++i)
+ break;
+ }
+ case KeyOfAvalon:
{
- buffer[(*length)++] = iccommand_data[i];
+ auto deck_reader = std::make_unique<Triforce::DeckReader>();
+ m_io_ports.AddIOAdapter(
+ std::make_unique<Triforce::KeyOfAvalon_IOAdapter>(deck_reader->GetICCardReader()));
+ m_serial_device_a = std::move(deck_reader);
+ m_serial_device_b = std::make_unique<Triforce::Touchscreen>();
+ break;
}
- buffer[(*length)++] = crc;
+ default:
+ break;
+ }
}
int CSIDevice_AMBaseboard::RunBuffer(u8* buffer, int request_length)
@@ -452,6 +445,13 @@ int CSIDevice_AMBaseboard::RunBuffer(u8* buffer, int request_length)
if (!validate_data_in_out(length, 0, "SerialA"))
break;
+ if (m_serial_device_a != nullptr)
+ {
+ m_serial_device_a->WriteRxBytes({data_in, length});
+ data_in += length;
+ break;
+ }
+
INFO_LOG_FMT(SERIALINTERFACE_AMBB, "GC-AM: Command 0x31, length=0x{:02x}, hexdump:\n{}",
length, HexDump(data_in, length));
@@ -512,462 +512,6 @@ int CSIDevice_AMBaseboard::RunBuffer(u8* buffer, int request_length)
data_in += length;
break;
}
-
- // Serial - Unknown
- if (AMMediaboard::GetGameType() == GekitouProYakyuu)
- {
- if (!validate_data_in_out(sizeof(u32), 0, "SerialA (Unknown)"))
- break;
- const u32 serial_command = Common::BitCastPtr<u32>(data_in);
-
- if (serial_command == 0x00001000)
- {
- if (!validate_data_in_out(0, 5, "SerialA (Unknown)"))
- break;
- data_out[data_offset++] = gcam_command;
- data_out[data_offset++] = 0x03;
- data_out[data_offset++] = 1;
- data_out[data_offset++] = 2;
- data_out[data_offset++] = 3;
- }
-
- if (!validate_data_in_out(length, 0, "SerialA (Unknown)"))
- break;
- data_in += length;
- break;
- }
-
- // Serial IC-CARD / Serial Deck Reader
- if (AMMediaboard::GetGameType() == VirtuaStriker4 ||
- AMMediaboard::GetGameType() == VirtuaStriker4_2006 ||
- AMMediaboard::GetGameType() == KeyOfAvalon)
- {
- if (!validate_data_in_out(2, 0, "SerialA (IC-CARD)"))
- break;
- u32 serial_command = data_in[1];
-
- ICCommand icco;
-
- // Set default reply
- icco.pktcmd = gcam_command;
- icco.pktlen = 7;
- icco.fixed = 0x10;
- icco.command = serial_command;
- icco.flag = 0;
- icco.length = 2;
- icco.status = 0;
- icco.extlen = 0;
-
- // Check for rest of data from the write pages command
- if (m_ic_write_size && m_ic_write_offset)
- {
- const u32 size = data_in[1];
-
- if (!validate_data_in_out(size + 2, 0, "SerialA (IC-CARD)"))
- break;
- DEBUG_LOG_FMT(SERIALINTERFACE_CARD, "Command: {}", HexDump(data_in, size + 2));
-
- INFO_LOG_FMT(SERIALINTERFACE_CARD,
- "GC-AM: Command 25 (IC-CARD) Write Pages: Off:{:x} Size:{:x} PSize:{:x}",
- m_ic_write_offset, m_ic_write_size, size);
-
- if (u64{m_ic_write_offset} + size > sizeof(m_ic_write_buffer))
- {
- ERROR_LOG_FMT(SERIALINTERFACE_CARD,
- "GC-AM: Command 25 (IC-CARD) m_ic_write_buffer overflow:\n"
- " - m_ic_write_buffer(offset={}, size={})\n"
- " - size={}\n",
- m_ic_write_offset, sizeof(m_ic_write_buffer), size);
- data_in = data_in_end;
- break;
- }
- memcpy(m_ic_write_buffer + m_ic_write_offset, data_in + 2, size);
-
- m_ic_write_offset += size;
-
- if (m_ic_write_offset > m_ic_write_size)
- {
- m_ic_write_offset = 0;
-
- const u16 page = m_ic_write_buffer[5];
- const u16 count = m_ic_write_buffer[7];
- const u32 write_size = u32(count) * 8;
- const u32 write_offset = u32(page) * 8;
-
- if ((write_size + write_offset) > sizeof(m_ic_card_data) ||
- (10 + write_size) > sizeof(m_ic_write_buffer))
- {
- ERROR_LOG_FMT(SERIALINTERFACE_CARD,
- "GC-AM: Command 25 (IC-CARD) Write Pages overflow:\n"
- " - m_ic_card_data(offset={}, size={})\n"
- " - m_ic_write_buffer(offset={}, size={})\n"
- " - size={}, page={}, count={}\n",
- write_offset, sizeof(m_ic_card_data), 10, sizeof(m_ic_write_buffer),
- write_size, page, count);
- data_in = data_in_end;
- break;
- }
- memcpy(m_ic_card_data + write_offset, m_ic_write_buffer + 10, write_size);
-
- INFO_LOG_FMT(SERIALINTERFACE_CARD,
- "GC-AM: Command 25 (IC-CARD) Write Pages:{} Count:{}({:x})", page,
- count, size);
-
- icco.command = WritePages;
-
- if (!validate_data_in_out(0, icco.pktlen + 2, "SerialA (IC-CARD)"))
- break;
- ICCardSendReply(&icco, data_out.data(), &data_offset);
- }
- if (!validate_data_in_out(length, 0, "SerialA (IC-CARD)"))
- break;
- data_in += length;
- break;
- }
-
- switch (ICCARDCommand(serial_command))
- {
- case ICCARDCommand::GetStatus:
- icco.status = m_ic_card_state;
-
- INFO_LOG_FMT(SERIALINTERFACE_CARD, "GC-AM: Command 0x31 (IC-CARD) Get Status:{:02x}",
- m_ic_card_state);
- break;
- case ICCARDCommand::SetBaudrate:
- INFO_LOG_FMT(SERIALINTERFACE_CARD, "GC-AM: Command 0x31 (IC-CARD) Set Baudrate");
- break;
- case ICCARDCommand::FieldOn:
- m_ic_card_state |= 0x10;
- INFO_LOG_FMT(SERIALINTERFACE_CARD, "GC-AM: Command 0x31 (IC-CARD) Field On");
- break;
- case ICCARDCommand::InsertCheck:
- icco.status = m_ic_card_status;
- INFO_LOG_FMT(SERIALINTERFACE_CARD,
- "GC-AM: Command 0x31 (IC-CARD) Insert Check:{:02x}", m_ic_card_status);
- break;
- case ICCARDCommand::AntiCollision:
- icco.extlen = 8;
- icco.length += icco.extlen;
- icco.pktlen += icco.extlen;
-
- // Card ID
- icco.extdata[0] = 0x00;
- icco.extdata[1] = 0x00;
- icco.extdata[2] = 0x54;
- icco.extdata[3] = 0x4D;
- icco.extdata[4] = 0x50;
- icco.extdata[5] = 0x00;
- icco.extdata[6] = 0x00;
- icco.extdata[7] = 0x00;
-
- INFO_LOG_FMT(SERIALINTERFACE_CARD, "GC-AM: Command 0x31 (IC-CARD) Anti Collision");
- break;
- case ICCARDCommand::SelectCard:
- icco.extlen = 8;
- icco.length += icco.extlen;
- icco.pktlen += icco.extlen;
-
- // Session
- icco.extdata[0] = 0x00;
- icco.extdata[1] = m_ic_card_session;
- icco.extdata[2] = 0x00;
- icco.extdata[3] = 0x00;
- icco.extdata[4] = 0x00;
- icco.extdata[5] = 0x00;
- icco.extdata[6] = 0x00;
- icco.extdata[7] = 0x00;
-
- INFO_LOG_FMT(SERIALINTERFACE_CARD, "GC-AM: Command 0x31 (IC-CARD) Select Card:{}",
- m_ic_card_session);
- break;
- case ICCARDCommand::ReadPage:
- case ICCARDCommand::ReadUseCount:
- {
- if (!validate_data_in_out(8, 0, "SerialA (IC-CARD)"))
- break;
- const u16 page = Common::swap16(data_in + 6) & 0xFF; // 255 is max page
-
- icco.extlen = 8;
- icco.length += icco.extlen;
- icco.pktlen += icco.extlen;
-
- memcpy(icco.extdata, m_ic_card_data + page * 8, 8);
-
- INFO_LOG_FMT(SERIALINTERFACE_CARD, "GC-AM: Command 31 (IC-CARD) Read Page:{}", page);
- break;
- }
- case ICCARDCommand::WritePage:
- {
- if (!validate_data_in_out(10, 0, "SerialA (IC-CARD)"))
- break;
- const u16 page = Common::swap16(data_in + 8) & 0xFF; // 255 is max page
-
- // Write only one page
- if (page == 4)
- {
- icco.status = 0x80;
- }
- else
- {
- if (!validate_data_in_out(18, 0, "SerialA (IC-CARD)"))
- break;
- memcpy(m_ic_card_data + page * 8, data_in + 10, 8);
- }
-
- INFO_LOG_FMT(SERIALINTERFACE_CARD, "GC-AM: Command 0x31 (IC-CARD) Write Page:{}",
- page);
- break;
- }
- case ICCARDCommand::DecreaseUseCount:
- {
- if (!validate_data_in_out(8, 0, "SerialA (IC-CARD)"))
- break;
- const u16 page = Common::swap16(data_in + 6) & 0xFF; // 255 is max page
-
- icco.extlen = 2;
- icco.length += icco.extlen;
- icco.pktlen += icco.extlen;
-
- auto ic_card_data = Common::BitCastPtr<u16>(m_ic_card_data + 0x28);
- ic_card_data = ic_card_data - 1;
-
- // Counter
- icco.extdata[0] = m_ic_card_data[0x28];
- icco.extdata[1] = m_ic_card_data[0x29];
-
- INFO_LOG_FMT(SERIALINTERFACE_CARD,
- "GC-AM: Command 31 (IC-CARD) Decrease Use Count:{}", page);
- break;
- }
- case ICCARDCommand::ReadPages:
- {
- if (!validate_data_in_out(10, 0, "SerialA (IC-CARD)"))
- break;
- const u16 page = Common::swap16(data_in + 6) & 0xFF; // 255 is max page
- const u16 count = Common::swap16(data_in + 8);
-
- const u32 offs = page * 8;
- u32 cnt = count * 8;
-
- // Limit read size to not overwrite the reply buffer
- const std::size_t reply_buffer_size = sizeof(icco.extdata) - 1;
- if (data_offset > reply_buffer_size)
- {
- ERROR_LOG_FMT(SERIALINTERFACE_CARD,
- "GC-AM: Command 31 (IC-CARD) Read Pages overflow:"
- " offset={} > buffer_size={}",
- data_offset, reply_buffer_size);
- data_in = data_in_end;
- break;
- }
- if (cnt > reply_buffer_size - data_offset)
- {
- cnt = 5 * 8;
- }
-
- icco.extlen = cnt;
- icco.length += icco.extlen;
- icco.pktlen += icco.extlen;
-
- if (offs + cnt > sizeof(icco.extdata))
- {
- ERROR_LOG_FMT(SERIALINTERFACE_CARD,
- "GC-AM: Command 31 (IC-CARD) Read Pages overflow:"
- " offset={} + count={} > buffer_size={}",
- offs, cnt, sizeof(icco.extdata));
- data_in = data_in_end;
- break;
- }
- memcpy(icco.extdata, m_ic_card_data + offs, cnt);
-
- INFO_LOG_FMT(SERIALINTERFACE_CARD,
- "GC-AM: Command 31 (IC-CARD) Read Pages:{} Count:{}", page, count);
- break;
- }
- case ICCARDCommand::WritePages:
- {
- if (!validate_data_in_out(10, 0, "SerialA (IC-CARD)"))
- break;
- const u16 pksize = length;
- const u16 size = Common::swap16(data_in + 2);
- const u16 page = Common::swap16(data_in + 6) & 0xFF; // 255 is max page
- const u16 count = Common::swap16(data_in + 8);
- const u32 write_size = u32(count) * 8;
- const u32 write_offset = u32(page) * 8;
-
- // We got a complete packet
- if (pksize - 5 == size)
- {
- if (page == 4) // Read Only Page, must return error
- {
- icco.status = 0x80;
- }
- else
- {
- if (write_size + write_offset > sizeof(m_ic_card_data))
- {
- ERROR_LOG_FMT(
- SERIALINTERFACE_CARD,
- "GC-AM: Command 0x31 (IC-CARD) Data overflow: Pages:{} Count:{}({:x})",
- page, count, size);
- }
- else
- {
- if (!validate_data_in_out(13 + write_size, 0, "SerialA (IC-CARD)"))
- break;
- memcpy(m_ic_card_data + write_offset, data_in + 13, write_size);
- }
- }
-
- INFO_LOG_FMT(SERIALINTERFACE_CARD,
- "GC-AM: Command 0x31 (IC-CARD) Write Pages:{} Count:{}({:x})", page,
- count, size);
- }
- // VirtuaStriker 4 splits the writes over multiple packets
- else
- {
- if (!validate_data_in_out(2 + pksize, 0, "SerialA (IC-CARD)"))
- break;
- memcpy(m_ic_write_buffer, data_in + 2, pksize);
- m_ic_write_offset += pksize;
- m_ic_write_size = size;
- }
- break;
- }
- default:
- // Handle Deck Reader commands
- if (!validate_data_in_out(1, 0, "SerialA (DECK READER)"))
- break;
- serial_command = data_in[0];
- icco.command = serial_command;
- icco.flag = 0;
- switch (CDReaderCommand(serial_command))
- {
- case CDReaderCommand::ProgramVersion:
- INFO_LOG_FMT(SERIALINTERFACE_CARD,
- "GC-AM: Command 0x31 (DECK READER) Program Version");
-
- icco.extlen = (u32)strlen(s_cdr_program_version);
- icco.length += icco.extlen;
- icco.pktlen += icco.extlen;
-
- memcpy(icco.extdata, s_cdr_program_version, icco.extlen);
- break;
- case CDReaderCommand::BootVersion:
- INFO_LOG_FMT(SERIALINTERFACE_CARD,
- "GC-AM: Command 0x31 (DECK READER) Boot Version");
-
- icco.extlen = (u32)strlen(s_cdr_boot_version);
- icco.length += icco.extlen;
- icco.pktlen += icco.extlen;
-
- memcpy(icco.extdata, s_cdr_boot_version, icco.extlen);
- break;
- case CDReaderCommand::ShutterGet:
- INFO_LOG_FMT(SERIALINTERFACE_CARD, "GC-AM: Command 0x31 (DECK READER) Shutter Get");
-
- icco.extlen = 4;
- icco.length += icco.extlen;
- icco.pktlen += icco.extlen;
-
- icco.extdata[0] = 0;
- icco.extdata[1] = 0;
- icco.extdata[2] = 0;
- icco.extdata[3] = 0;
- break;
- case CDReaderCommand::CameraCheck:
- INFO_LOG_FMT(SERIALINTERFACE_CARD,
- "GC-AM: Command 0x31 (DECK READER) Camera Check");
-
- icco.extlen = 6;
- icco.length += icco.extlen;
- icco.pktlen += icco.extlen;
-
- icco.extdata[0] = 0x23;
- icco.extdata[1] = 0x28;
- icco.extdata[2] = 0x45;
- icco.extdata[3] = 0x29;
- icco.extdata[4] = 0x45;
- icco.extdata[5] = 0x29;
- break;
- case CDReaderCommand::ProgramChecksum:
- INFO_LOG_FMT(SERIALINTERFACE_CARD,
- "GC-AM: Command 0x31 (DECK READER) Program Checksum");
-
- icco.extlen = 4;
- icco.length += icco.extlen;
- icco.pktlen += icco.extlen;
-
- icco.extdata[0] = 0x23;
- icco.extdata[1] = 0x28;
- icco.extdata[2] = 0x45;
- icco.extdata[3] = 0x29;
- break;
- case CDReaderCommand::BootChecksum:
- INFO_LOG_FMT(SERIALINTERFACE_CARD,
- "GC-AM: Command 0x31 (DECK READER) Boot Checksum");
-
- icco.extlen = 4;
- icco.length += icco.extlen;
- icco.pktlen += icco.extlen;
-
- icco.extdata[0] = 0x23;
- icco.extdata[1] = 0x28;
- icco.extdata[2] = 0x45;
- icco.extdata[3] = 0x29;
- break;
- case CDReaderCommand::SelfTest:
- INFO_LOG_FMT(SERIALINTERFACE_CARD, "GC-AM: Command 0x31 (DECK READER) Self Test");
- icco.flag = 0x00;
- break;
- case CDReaderCommand::SensLock:
- INFO_LOG_FMT(SERIALINTERFACE_CARD, "GC-AM: Command 0x31 (DECK READER) Sens Lock");
- icco.flag = 0x01;
- break;
- case CDReaderCommand::SensCard:
- INFO_LOG_FMT(SERIALINTERFACE_CARD, "GC-AM: Command 0x31 (DECK READER) Sens Card");
- break;
- case CDReaderCommand::ShutterCard:
- INFO_LOG_FMT(SERIALINTERFACE_CARD,
- "GC-AM: Command 0x31 (DECK READER) Shutter Card");
- break;
- case CDReaderCommand::ReadCard:
- INFO_LOG_FMT(SERIALINTERFACE_CARD, "GC-AM: Command 0x31 (DECK READER) Read Card");
-
- icco.fixed = 0xAA;
- icco.flag = 0xAA;
- icco.extlen = sizeof(s_cdr_card_data);
- icco.length = 0x72;
- icco.status = Common::swap16(icco.extlen);
-
- icco.pktlen += icco.extlen;
-
- memcpy(icco.extdata, s_cdr_card_data, sizeof(s_cdr_card_data));
-
- break;
- default:
- if (!validate_data_in_out(14, 0, "SerialA (DECK READER)"))
- break;
- WARN_LOG_FMT(SERIALINTERFACE_CARD,
- "GC-AM: Command 0x31 (IC-Card) {:02x} {:02x} {:02x} {:02x} {:02x} "
- "{:02x} {:02x} {:02x} {:02x} {:02x} {:02x} {:02x}",
- data_in[2], data_in[3], data_in[4], data_in[5], data_in[6], data_in[7],
- data_in[8], data_in[9], data_in[10], data_in[11], data_in[12],
- data_in[13]);
- break;
- }
- break;
- }
-
- if (!validate_data_in_out(0, icco.pktlen + 2, "SerialA (IC-CARD)"))
- break;
- ICCardSendReply(&icco, data_out.data(), &data_offset);
-
- if (!validate_data_in_out(2, 0, "SerialA (IC-CARD)"))
- break;
- data_in += length;
- break;
- }
}
u32 command_offset = 0;
@@ -1279,50 +823,46 @@ int CSIDevice_AMBaseboard::RunBuffer(u8* buffer, int request_length)
message.AddData("\x00\x00\x00\x00", 4);
break;
case VirtuaStriker3:
- // 2 Player (13bit), 2 Coin slot, 4 Analog-in, 1 CARD, 8 Driver-out
+ // 2 Player (13bit), 2 Coin slot, 4 Analog-in, 8 Driver-out
message.AddData("\x01\x02\x0D\x00", 4);
message.AddData("\x02\x02\x00\x00", 4);
- message.AddData("\x10\x01\x00\x00", 4);
message.AddData("\x12\x08\x00\x00", 4);
message.AddData("\x00\x00\x00\x00", 4);
break;
case GekitouProYakyuu:
- // 2 Player (13bit), 2 Coin slot, 4 Analog-in, 1 CARD, 8 Driver-out
+ // 2 Player (13bit), 2 Coin slot, 4 Analog-in, 8 Driver-out
message.AddData("\x01\x02\x0D\x00", 4);
message.AddData("\x02\x02\x00\x00", 4);
message.AddData("\x03\x04\x00\x00", 4);
- message.AddData("\x10\x01\x00\x00", 4);
message.AddData("\x12\x08\x00\x00", 4);
message.AddData("\x00\x00\x00\x00", 4);
break;
case VirtuaStriker4:
case VirtuaStriker4_2006:
- // 2 Player (13bit), 1 Coin slot, 4 Analog-in, 1 CARD
+ // 2 Player (13bit), 1 Coin slot, 4 Analog-in, 22 Driver-out
message.AddData("\x01\x02\x0D\x00", 4);
message.AddData("\x02\x01\x00\x00", 4);
message.AddData("\x03\x04\x00\x00", 4);
- message.AddData("\x10\x01\x00\x00", 4);
+ message.AddData("\x12\x16\x00\x00", 4);
message.AddData("\x00\x00\x00\x00", 4);
break;
case KeyOfAvalon:
- // 1 Player (15bit), 1 Coin slot, 3 Analog-in, Touch, 1 CARD, 1 Driver-out
+ // 1 Player (15bit), 1 Coin slot, 3 Analog-in, Touch, 1 Driver-out
// (Unconfirmed)
message.AddData("\x01\x01\x0F\x00", 4);
message.AddData("\x02\x01\x00\x00", 4);
message.AddData("\x03\x03\x00\x00", 4);
message.AddData("\x06\x10\x10\x01", 4);
- message.AddData("\x10\x01\x00\x00", 4);
message.AddData("\x12\x01\x00\x00", 4);
message.AddData("\x00\x00\x00\x00", 4);
break;
case MarioKartGP:
case MarioKartGP2:
default:
- // 1 Player (15bit), 1 Coin slot, 3 Analog-in, 1 CARD, 1 Driver-out
+ // 1 Player (15bit), 1 Coin slot, 3 Analog-in, 1 Driver-out
message.AddData("\x01\x01\x0F\x00", 4);
message.AddData("\x02\x01\x00\x00", 4);
message.AddData("\x03\x03\x00\x00", 4);
- message.AddData("\x10\x01\x00\x00", 4);
message.AddData("\x12\x01\x00\x00", 4);
message.AddData("\x00\x00\x00\x00", 4);
break;
@@ -1379,6 +919,11 @@ int CSIDevice_AMBaseboard::RunBuffer(u8* buffer, int request_length)
{
std::array<u8, 3> player_data{};
+ const auto io_ports_player_data = m_io_ports.GetSwitchInputs(i);
+ std::copy_n(io_ports_player_data.data(),
+ std::min(io_ports_player_data.size(), player_data.size()),
+ player_data.data());
+
// Service button
if (pad_status.switches & SWITCH_SERVICE)
player_data[0] |= 0x40;
@@ -1549,15 +1094,6 @@ int CSIDevice_AMBaseboard::RunBuffer(u8* buffer, int request_length)
// Tactics (D)
if (pad_status.button & PAD_BUTTON_RIGHT)
player_data[0] |= 0x04;
-
- if (i == 0)
- {
- player_data[0] |= 0x10; // IC-Card Switch ON
-
- // IC-Card Lock
- if (pad_status.button & PAD_BUTTON_DOWN)
- player_data[1] |= 0x20;
- }
}
break;
// Controller configuration for Gekitou Pro Yakyuu
@@ -1615,11 +1151,6 @@ int CSIDevice_AMBaseboard::RunBuffer(u8* buffer, int request_length)
// Switch 2
if (pad_status.button & PAD_BUTTON_B)
player_data[0] |= 0x08;
- // Toggle inserted card
- if (pad_status.button & PAD_TRIGGER_L)
- {
- m_ic_card_status ^= ICCARDStatus::NoCard;
- }
}
break;
}
@@ -1810,70 +1341,45 @@ int CSIDevice_AMBaseboard::RunBuffer(u8* buffer, int request_length)
break;
const u32 bytes = *jvs_io++;
- if (bytes)
- {
- message.AddData(StatusOkay);
+ if (!validate_jvs_io(bytes, "GeneralDriverOutput"))
+ break;
- // The lamps are controlled via this
- if (AMMediaboard::GetGameType() == MarioKartGP)
- {
- if (!validate_jvs_io(1, "GeneralDriverOutput (MarioKartGP)"))
- break;
- const u32 status = *jvs_io++;
- if (status & 4)
- {
- DEBUG_LOG_FMT(SERIALINTERFACE_JVSIO, "JVS-IO: Command 32, Item Button ON");
- }
- else
- {
- DEBUG_LOG_FMT(SERIALINTERFACE_JVSIO, "JVS-IO: Command 32, Item Button OFF");
- }
- if (status & 8)
- {
- DEBUG_LOG_FMT(SERIALINTERFACE_JVSIO, "JVS-IO: Command 32, Cancel Button ON");
- }
- else
- {
- DEBUG_LOG_FMT(SERIALINTERFACE_JVSIO, "JVS-IO: Command 32, Cancel Button OFF");
- }
- break;
- }
+ message.AddData(StatusOkay);
- if (!validate_jvs_io(bytes, "GeneralDriverOutput"))
- break;
+ if (bytes)
+ {
+ m_io_ports.SetGenericOutputs({jvs_io, bytes});
- INFO_LOG_FMT(SERIALINTERFACE_JVSIO,
- "JVS-IO: Command 0x32, GPO: delay=0x{:02x}, rx_reply=0x{:02x},"
- " bytes={}, buffer:\n{}",
- m_delay, m_rx_reply, bytes, HexDump(jvs_io, bytes));
+ DEBUG_LOG_FMT(SERIALINTERFACE_JVSIO,
+ "JVS-IO: GPO: delay=0x{:02x}, rx_reply=0x{:02x},"
+ " bytes={}, buffer:\n{}",
+ m_delay, m_rx_reply, bytes, HexDump(jvs_io, bytes));
- if (bytes < 3)
+ if ((AMMediaboard::GetGameType() == FZeroAX) && bytes >= 3)
{
- jvs_io += bytes;
- break;
- }
+ // Handling of the motion seat used in F-Zero AXs DX version
+ const u16 seat_state = Common::swap16(jvs_io + 1) >> 2;
- // Handling of the motion seat used in F-Zero AXs DX version
- const u16 seat_state = Common::swap16(jvs_io + 1) >> 2;
- jvs_io += bytes;
-
- switch (seat_state)
- {
- case 0x70:
- m_delay++;
- if ((m_delay % 10) == 0)
+ switch (seat_state)
{
- m_rx_reply = 0xFB;
+ case 0x70:
+ m_delay++;
+ if ((m_delay % 10) == 0)
+ {
+ m_rx_reply = 0xFB;
+ }
+ break;
+ case 0xF0:
+ m_rx_reply = 0xF0;
+ break;
+ default:
+ case 0xA0:
+ case 0x60:
+ break;
}
- break;
- case 0xF0:
- m_rx_reply = 0xF0;
- break;
- default:
- case 0xA0:
- case 0x60:
- break;
}
+
+ jvs_io += bytes;
}
break;
}
@@ -1921,7 +1427,6 @@ int CSIDevice_AMBaseboard::RunBuffer(u8* buffer, int request_length)
NOTICE_LOG_FMT(SERIALINTERFACE_JVSIO, "JVS-IO: Command 0xF0, Reset");
m_delay = 0;
m_wheel_init = 0;
- m_ic_card_state = 0x20;
}
message.AddData(StatusOkay);
@@ -1988,30 +1493,38 @@ int CSIDevice_AMBaseboard::RunBuffer(u8* buffer, int request_length)
// Update attached serial devices and read data into our response buffer.
// This is done regardless of the game having just sent a SerialA/B write.
- if (m_serial_device_b != nullptr)
- {
- m_serial_device_b->Update();
+
+ const auto process_serial_device = [&](Triforce::SerialDevice* serial_device, GCAMCommand cmd) {
+ if (serial_device == nullptr)
+ return;
+
+ serial_device->Update();
const auto out_length =
- std::min(u32(m_serial_device_b->GetTxByteCount()), SERIAL_PORT_MAX_READ_SIZE);
+ std::min(u32(serial_device->GetTxByteCount()), SERIAL_PORT_MAX_READ_SIZE);
- if (out_length != 0)
- {
- // Also accounting for the 2-byte header.
- if (!validate_data_in_out(0, out_length + 2, "SerialB"))
- break;
+ if (out_length == 0)
+ return;
- // Write the 2-byte header.
- data_out[data_offset++] = GCAMCommand::SerialB;
- data_out[data_offset++] = u8(out_length);
+ // Also accounting for the 2-byte header.
+ if (!validate_data_in_out(0, out_length + 2, "SerialDevice"))
+ return;
- const auto out_span = std::span{data_out}.subspan(data_offset, out_length);
+ // Write the 2-byte header.
+ data_out[data_offset++] = cmd;
+ data_out[data_offset++] = u8(out_length);
- m_serial_device_b->TakeTxBytes(out_span);
+ const auto out_span = std::span{data_out}.subspan(data_offset, out_length);
- data_offset += out_length;
- }
- }
+ serial_device->TakeTxBytes(out_span);
+
+ data_offset += out_length;
+ };
+
+ m_io_ports.Update();
+
+ process_serial_device(m_serial_device_a.get(), GCAMCommand::SerialA);
+ process_serial_device(m_serial_device_b.get(), GCAMCommand::SerialB);
data_out[0] = 0x01; // Status code ?
data_out[1] = data_offset - response_header_size; // Length
@@ -2069,16 +1582,11 @@ void CSIDevice_AMBaseboard::DoState(PointerWrap& p)
p.Do(m_coin);
p.Do(m_coin_pressed);
- p.Do(m_ic_card_data);
-
- // Setup IC-card
- p.Do(m_ic_card_state);
- p.Do(m_ic_card_status);
- p.Do(m_ic_card_session);
+ m_io_ports.DoState(p);
- p.Do(m_ic_write_buffer);
- p.Do(m_ic_write_offset);
- p.Do(m_ic_write_size);
+ // Serial A
+ if (m_serial_device_a != nullptr)
+ m_serial_device_a->DoState(p);
// Serial B
if (m_serial_device_b != nullptr)
diff --git a/Source/Core/Core/HW/SI/SI_DeviceAMBaseboard.h b/Source/Core/Core/HW/SI/SI_DeviceAMBaseboard.h
index 85a321b424..4d1990297c 100644
--- a/Source/Core/Core/HW/SI/SI_DeviceAMBaseboard.h
+++ b/Source/Core/Core/HW/SI/SI_DeviceAMBaseboard.h
@@ -6,6 +6,7 @@
#include "Core/HW/MagCard/MagneticCardReader.h"
#include "Core/HW/SI/SI.h"
#include "Core/HW/SI/SI_Device.h"
+#include "Core/HW/Triforce/IOPorts.h"
namespace Triforce
{
@@ -118,66 +119,6 @@ private:
AcknowledgeOverflow = 4,
};
- enum ICCARDCommand
- {
- GetStatus = 0x10,
- SetBaudrate = 0x11,
- FieldOn = 0x14,
- FieldOff = 0x15,
- InsertCheck = 0x20,
- AntiCollision = 0x21,
- SelectCard = 0x22,
- ReadPage = 0x24,
- WritePage = 0x25,
- DecreaseUseCount = 0x26,
- ReadUseCount = 0x33,
- ReadPages = 0x34,
- WritePages = 0x35,
- };
-
- enum ICCARDStatus
- {
- Okay = 0,
- NoCard = 0x8000,
- Unknown = 0x800E,
- BadCard = 0xFFFF,
- };
-
- enum CDReaderCommand
- {
- ShutterAuto = 0x61,
- BootVersion = 0x62,
- SensLock = 0x63,
- SensCard = 0x65,
- FirmwareUpdate = 0x66,
- ShutterGet = 0x67,
- CameraCheck = 0x68,
- ShutterCard = 0x69,
- ProgramChecksum = 0x6B,
- BootChecksum = 0x6D,
- ShutterLoad = 0x6F,
- ReadCard = 0x72,
- ShutterSave = 0x73,
- SelfTest = 0x74,
- ProgramVersion = 0x76,
- };
-
- // NOTE: Used to be an union with `u8 data[81 + 4 + 4 + 4]`
- // TODO: Should the struct be packed?
- struct ICCommand
- {
- u32 pktcmd : 8;
- u32 pktlen : 8;
- u32 fixed : 8;
- u32 command : 8;
- u32 flag : 8;
- u32 length : 8;
- u32 status : 16;
-
- u8 extdata[81] = {};
- u32 extlen;
- };
-
static constexpr u32 RESPONSE_SIZE = SerialInterfaceManager::BUFFER_SIZE;
// This value prevents F-Zero AX mag card breakage.
@@ -189,23 +130,17 @@ private:
std::array<std::array<u8, RESPONSE_SIZE>, 2> m_response_buffers{};
u8 m_current_response_buffer_index = 0;
+ Triforce::IOPorts m_io_ports;
+
std::array<u16, 2> m_coin{};
std::array<u32, 2> m_coin_pressed{};
- u8 m_ic_card_data[2048] = {};
-
- // Setup IC-card
- u16 m_ic_card_state = 0x20;
- u16 m_ic_card_status = ICCARDStatus::Okay;
- u16 m_ic_card_session = 0x23;
-
- u8 m_ic_write_buffer[512] = {};
- u32 m_ic_write_offset = 0;
- u32 m_ic_write_size = 0;
-
// Magnetic Card Reader
MagCard::MagneticCardReader::Settings m_mag_card_settings;
+ // Serial A
+ std::unique_ptr<Triforce::SerialDevice> m_serial_device_a;
+
// Serial B
std::unique_ptr<Triforce::SerialDevice> m_serial_device_b;
@@ -232,8 +167,6 @@ private:
u32 m_dip_switch_0 = 0xFF;
int m_delay = 0;
-
- void ICCardSendReply(ICCommand* iccommand, u8* buffer, u32* length);
};
} // namespace SerialInterface
diff --git a/Source/Core/Core/HW/Triforce/DeckReader.cpp b/Source/Core/Core/HW/Triforce/DeckReader.cpp
new file mode 100644
index 0000000000..a475b63bea
--- /dev/null
+++ b/Source/Core/Core/HW/Triforce/DeckReader.cpp
@@ -0,0 +1,449 @@
+// Copyright 2026 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "Core/HW/Triforce/DeckReader.h"
+
+#include <string_view>
+
+#include <fmt/ranges.h>
+
+#include <picojson.h>
+
+#include "Common/BitUtils.h"
+#include "Common/ChunkFile.h"
+#include "Common/FileUtil.h"
+#include "Common/JsonUtil.h"
+#include "Common/Logging/Log.h"
+#include "Common/ScopeGuard.h"
+#include "Common/Swap.h"
+
+#include "Core/ConfigManager.h"
+
+namespace
+{
+
+// Note: Avalon literally skips `strlen("Version ")` bytes in the display.
+constexpr std::string_view CDR_PROGRAM_VERSION = "Version 1.22,2003/09/19,171-8213B";
+constexpr std::string_view CDR_BOOT_VERSION = "Version 1.04,2003/06/17,171-8213B";
+
+constexpr std::size_t EXPECTED_VERSION_STR_LENGTH = 48;
+
+// Avalon seems to not care about the actual value.
+constexpr u32 SHUTTER_TIME = 1234567890;
+
+constexpr u32 FIRMWARE_UPDATE_TIMEOUT = 240;
+
+auto GetFirmwareDumpFilename()
+{
+ return fmt::format("{}card_deck_reader_firmware.bin", File::GetUserPath(D_TRIUSER_IDX));
+}
+
+enum class CDReaderCommand : u8
+{
+ ShutterAuto = 0x61,
+ BootVersion = 0x62,
+ SensLock = 0x63,
+ SensCard = 0x65,
+ FirmwareUpdate = 0x66,
+ ShutterGet = 0x67,
+ CameraCheck = 0x68,
+ Shutter = 0x69,
+ ProgramChecksum = 0x6b,
+ ShutterAlt = 0x6c, // Not sure what the difference is. Avalon seems to only use 0x69.
+ BootChecksum = 0x6d,
+ ShutterLoad = 0x6f,
+ ReadCard = 0x72,
+ ShutterSave = 0x73,
+ SelfTest = 0x74,
+ ProgramVersion = 0x76,
+};
+
+#pragma pack(push, 1)
+struct CardIdentifier
+{
+ // When the 0x01 bit is set, Avalon indexes a separate smaller table.
+ // Avalon specifically requires 0x80, 0x40, and 0x20 bits are not set.
+ // We don't know the relevance of this second table.
+ // There are even some duplicates between the two tables.
+ u8 table_index;
+
+ Common::BigEndianValue<u16> card_index;
+};
+#pragma pack(pop)
+
+std::optional<std::vector<CardIdentifier>> LoadCardDeckFromFile()
+{
+ // Example json format:
+ // table defaults to 0. quantity defaults to 1.
+ //
+ // {
+ // "cards": [
+ // {
+ // "index": 12
+ // },
+ // {
+ // "table": 1,
+ // "index": 13
+ // },
+ // {
+ // "index": 18,
+ // "quantity": 3
+ // }
+ // ]
+ // }
+
+ const std::string filename =
+ fmt::format("{}tricard_{}_deck.json", File::GetUserPath(D_TRIUSER_IDX),
+ SConfig::GetInstance().GetGameID());
+
+ std::string file_contents;
+ File::ReadFileToString(filename, file_contents);
+
+ picojson::value json_root;
+ const auto err = picojson::parse(json_root, file_contents);
+
+ if (!err.empty())
+ {
+ ERROR_LOG_FMT(SERIALINTERFACE_CARD, "LoadCardDeckFromFile: {}", err);
+ return std::nullopt;
+ }
+
+ if (!json_root.is<picojson::object>())
+ {
+ ERROR_LOG_FMT(SERIALINTERFACE_CARD, "LoadCardDeckFromFile: Invalid JSON root object.");
+ return std::nullopt;
+ }
+
+ const auto cards_obj = json_root.get("cards");
+ if (!cards_obj.is<picojson::array>())
+ {
+ ERROR_LOG_FMT(SERIALINTERFACE_CARD, "LoadCardDeckFromFile: Invalid \"cards\" array.");
+ return std::nullopt;
+ }
+
+ std::optional<std::vector<CardIdentifier>> result;
+ result.emplace();
+
+ for (const auto& item : cards_obj.get<picojson::array>())
+ {
+ if (!item.is<picojson::object>())
+ continue;
+
+ const auto& item_obj = item.get<picojson::object>();
+
+ const auto card_index = ReadNumericFromJson<u16>(item_obj, "index");
+ if (!card_index)
+ {
+ ERROR_LOG_FMT(SERIALINTERFACE_CARD, "LoadCardDeckFromFile: Invalid \"index\" field.");
+ continue;
+ }
+
+ CardIdentifier card_id{};
+ card_id.card_index = *card_index;
+ card_id.table_index = ReadNumericFromJson<u8>(item_obj, "table").value_or(0);
+
+ const auto quantity = ReadNumericFromJson<u8>(item_obj, "quantity").value_or(1);
+ result->resize(result->size() + quantity, card_id);
+ }
+
+ return result;
+}
+
+} // namespace
+
+namespace Triforce
+{
+
+void DeckReader::Update()
+{
+ Common::ScopeGuard update_ic_card_reader{[this] {
+ // Update and take any response data from the IC card reader.
+ m_ic_card_reader.Update();
+ PassThroughTxBytes(m_ic_card_reader);
+ }};
+
+ // All Deck Reader commands are exactly one byte (except FirmwareUpdate).
+ // Responses must be the correct length, which varies by command.
+ // Only the ReadCard response actually includes some kind of length field.
+ //
+ // A leading 2 bytes and trailing 1 byte are expected for all responses.
+ // Avalon seems to largely not check the actual values,
+ // but a few handlers do verify them.
+
+ // FirmwareUpdate is the only known stateful command.
+ if (m_firmware_update_timeout != 0)
+ {
+ HandleFirmwareUpdate();
+ return;
+ }
+
+ const auto input_span = GetRxByteSpan();
+
+ if (input_span.empty())
+ return; // Wait for more data.
+
+ const u8 cd_reader_command = input_span.front();
+
+ if (cd_reader_command == 0x00)
+ {
+ // This is an IC Card Reader command.
+
+ // How does the actual hardware deal with this ?
+ // Does the Deck Reader really parse the IC Card Reader commands ?
+
+ if (input_span.size() < 4)
+ return; // Wait for more data.
+
+ const u16 input_payload_size = Common::swap16(input_span.data() + 2);
+ const u32 total_request_size = input_payload_size + 5u;
+
+ if (input_span.size() >= total_request_size)
+ {
+ // Forward the data to the attached IC Card Reader.
+ m_ic_card_reader.WriteRxBytes(input_span.first(total_request_size));
+ ConsumeRxBytes(total_request_size);
+ }
+
+ return;
+ }
+
+ // Write header.
+ WriteTxBytes(std::array<u8, 2>{0xaa, cd_reader_command});
+
+ switch (CDReaderCommand(cd_reader_command))
+ {
+ case CDReaderCommand::SelfTest:
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "SelfTest");
+
+ // Avalon appears to not inspect this value.
+ WriteTxByte(0x00);
+
+ break;
+ }
+ case CDReaderCommand::SensLock:
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "SensLock");
+
+ // Avalon will ask the user to close the shutter if open.
+ constexpr bool is_closed = true;
+
+ WriteTxByte(is_closed ? 0x01 : 0x00);
+
+ break;
+ }
+ case CDReaderCommand::ProgramVersion:
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "ProgramVersion");
+
+ WriteTxBytes(Common::AsU8Span((CDR_PROGRAM_VERSION)));
+
+ // Pad with zeros.
+ WriteTxBytes(std::array<u8, EXPECTED_VERSION_STR_LENGTH - CDR_PROGRAM_VERSION.size()>{});
+
+ break;
+ }
+ case CDReaderCommand::BootVersion:
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "BootVersion");
+
+ WriteTxBytes(Common::AsU8Span(CDR_BOOT_VERSION));
+
+ // Pad with zeros.
+ WriteTxBytes(std::array<u8, EXPECTED_VERSION_STR_LENGTH - CDR_BOOT_VERSION.size()>{});
+
+ break;
+ }
+ case CDReaderCommand::ProgramChecksum:
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "ProgramChecksum");
+
+ // Avalon seems to not care about the actual value.
+ Common::BigEndianValue<u32> fake_checksum{0x89abcdef};
+ WriteTxBytes(Common::AsU8Span(fake_checksum));
+
+ break;
+ }
+ case CDReaderCommand::BootChecksum:
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "BootChecksum");
+
+ // Avalon seems to not care about the actual value.
+ Common::BigEndianValue<u32> fake_checksum{0x01234567};
+ WriteTxBytes(Common::AsU8Span(fake_checksum));
+
+ break;
+ }
+ case CDReaderCommand::CameraCheck:
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "CameraCheck");
+
+ // FYI: If `header[1] != 0x68` the game inspects some bits and 9 bytes. Some error state ?
+
+ std::array<u8, 10> response{};
+ WriteTxBytes(response);
+
+ break;
+ }
+ case CDReaderCommand::ShutterGet:
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "ShutterGet");
+
+ Common::BigEndianValue<u32> shutter_time{SHUTTER_TIME};
+ WriteTxBytes(Common::AsU8Span(shutter_time));
+
+ break;
+ }
+ case CDReaderCommand::ShutterAuto:
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "ShutterAuto");
+
+ Common::BigEndianValue<u32> shutter_time{SHUTTER_TIME};
+ WriteTxBytes(Common::AsU8Span(shutter_time));
+
+ break;
+ }
+ case CDReaderCommand::Shutter:
+ case CDReaderCommand::ShutterAlt:
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "Shutter");
+ break;
+ }
+ case CDReaderCommand::ShutterLoad:
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "ShutterLoad");
+ break;
+ }
+ case CDReaderCommand::ShutterSave:
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "ShutterSave");
+ break;
+ }
+ case CDReaderCommand::SensCard:
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "SensCard");
+
+ // TODO: What is the meaning of this value ?
+ // Avalon tests for 0x01.
+ WriteTxByte(0x01);
+
+ break;
+ }
+ case CDReaderCommand::ReadCard:
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "ReadCard");
+
+ constexpr bool is_deck_empty = false;
+
+ if (is_deck_empty)
+ {
+ // I think this means that there are no cards or maybe "not ready".
+ WriteTxBytes(std::array<u8, 2>{0xaa, 0x52});
+ }
+ else
+ {
+ // We've had one, yes, but what about second header ?
+ WriteTxBytes(std::array<u8, 2>{0xaa, u8(CDReaderCommand::ReadCard)});
+
+ if (const auto deck = LoadCardDeckFromFile())
+ {
+ // What happens with more than 30 cards ?
+ WriteTxByte(u8(deck->size() * sizeof(CardIdentifier)));
+
+ WriteTxBytes(Common::AsU8Span(*deck));
+ }
+ else
+ {
+ constexpr u8 deck_size = 0;
+ WriteTxByte(deck_size);
+ }
+ }
+
+ break;
+ }
+ case CDReaderCommand::FirmwareUpdate:
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "FirmwareUpdate");
+
+ m_firmware_update_timeout = FIRMWARE_UPDATE_TIMEOUT;
+
+ // The game will now send the raw firmware data which we write to a file.
+ // FYI: It appears to be SuperH4:LE code.
+
+ const auto filename = GetFirmwareDumpFilename();
+ if (m_firmware_dump_file.Open(filename, File::AccessMode::Write))
+ {
+ NOTICE_LOG_FMT(SERIALINTERFACE_CARD, "Writing firmware to: {}", filename);
+ }
+ else
+ {
+ ERROR_LOG_FMT(SERIALINTERFACE_CARD, "Failed to open: {}", filename);
+ }
+
+ break;
+ }
+ default:
+ {
+ // TODO: I think maybe we're supposed to produce a header of {0xaa, 0x55} ?
+
+ // Responses seem to be variable in length and unspecified so we can't do much.
+ ERROR_LOG_FMT(SERIALINTERFACE_CARD, "Unknown command: {:02x}", cd_reader_command);
+ break;
+ }
+ }
+
+ // Write footer.
+ // TODO: Is this an error code maybe ?
+ WriteTxByte(0x00);
+
+ // Every known command (other than FirmwareUpdate) is just one byte.
+ ConsumeRxBytes(1);
+}
+
+void DeckReader::HandleFirmwareUpdate()
+{
+ const auto input_span = GetRxByteSpan();
+
+ // No idea how the real hardware works.
+ // There doesn't seem to be any sort of size field so.. here's a timeout.
+ if (input_span.empty())
+ {
+ if (--m_firmware_update_timeout == 0)
+ {
+ constexpr std::array<u8, 3> fw_update_done{0xaa, u8(CDReaderCommand::FirmwareUpdate), 0x00};
+ WriteTxBytes(fw_update_done);
+
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "Fake FirmwareUpdate done.");
+
+ m_firmware_dump_file.Close();
+ }
+ }
+ else
+ {
+ if (m_firmware_dump_file.IsOpen())
+ {
+ if (!m_firmware_dump_file.Write(input_span))
+ {
+ ERROR_LOG_FMT(SERIALINTERFACE_CARD, "Failed to write {} bytes to firmware.",
+ input_span.size());
+ }
+ }
+
+ ConsumeRxBytes(input_span.size());
+ m_firmware_update_timeout = FIRMWARE_UPDATE_TIMEOUT;
+ }
+}
+
+void DeckReader::DoState(PointerWrap& p)
+{
+ SerialDevice::DoState(p);
+
+ m_ic_card_reader.DoState(p);
+
+ p.Do(m_firmware_update_timeout);
+
+ if (p.IsReadMode())
+ m_firmware_dump_file.Close();
+}
+
+} // namespace Triforce
diff --git a/Source/Core/Core/HW/Triforce/DeckReader.h b/Source/Core/Core/HW/Triforce/DeckReader.h
new file mode 100644
index 0000000000..1e1fb23dad
--- /dev/null
+++ b/Source/Core/Core/HW/Triforce/DeckReader.h
@@ -0,0 +1,41 @@
+// Copyright 2026 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include "Common/DirectIOFile.h"
+
+#include "Core/HW/Triforce/ICCardReader.h"
+#include "Core/HW/Triforce/SerialDevice.h"
+
+class PointerWrap;
+
+namespace Triforce
+{
+
+// Serial deck reader used by The Key of Avalon games.
+class DeckReader final : public SerialDevice
+{
+public:
+ void Update() override;
+
+ void DoState(PointerWrap& p) override;
+
+ auto* GetICCardReader() { return &m_ic_card_reader; }
+
+private:
+ // It seems that the IC Card Reader must be connected through the Deck Reader.
+ // The Deck Reader forwards appropriate commands to the IC Card Reader.
+ // IC Card Reader responses are passed through back to the baseboard as-is.
+ // It can't be the other way around because FirmwareUpdate sends many raw bytes.
+ // Unless FirmwareUpdate temporarily cuts off the IC Card Reader ?
+ ICCardReader m_ic_card_reader{0};
+
+ void HandleFirmwareUpdate();
+
+ u8 m_firmware_update_timeout = 0;
+
+ File::DirectIOFile m_firmware_dump_file;
+};
+
+} // namespace Triforce
diff --git a/Source/Core/Core/HW/Triforce/ICCardReader.cpp b/Source/Core/Core/HW/Triforce/ICCardReader.cpp
new file mode 100644
index 0000000000..b438992fb4
--- /dev/null
+++ b/Source/Core/Core/HW/Triforce/ICCardReader.cpp
@@ -0,0 +1,774 @@
+// Copyright 2026 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "Core/HW/Triforce/ICCardReader.h"
+
+#include <numeric>
+
+#include <fmt/ranges.h>
+
+#include "Common/BitUtils.h"
+#include "Common/ChunkFile.h"
+#include "Common/DirectIOFile.h"
+#include "Common/FileUtil.h"
+#include "Common/Logging/Log.h"
+#include "Common/ScopeGuard.h"
+#include "Common/Swap.h"
+
+#include "Core/ConfigManager.h"
+#include "Core/HW/DVD/AMMediaboard.h"
+
+namespace
+{
+
+// We return a card session with our card index in the lower bits.
+constexpr u16 CARD_SESSION_BASE = 0x2300;
+
+// The serial number is located here. Games seem to expect it to be read-only.
+constexpr u32 READ_ONLY_PAGE_INDEX = 4;
+
+constexpr u8 CheckSumXOR(std::span<const u8> data)
+{
+ return std::accumulate(data.data(), data.data() + data.size(), u8{}, std::bit_xor{});
+}
+
+bool LoadCardData(const std::string& filename, std::span<u8> data)
+{
+ File::DirectIOFile file{filename, File::AccessMode::Read};
+
+ if (!File::Exists(filename))
+ return false;
+
+ const auto file_size = file.GetSize();
+ if (file_size > data.size())
+ {
+ WARN_LOG_FMT(SERIALINTERFACE_CARD, "Unexpected size of {} for file: {}", file_size, filename);
+ }
+
+ const auto read_size = std::min<u64>(file_size, data.size());
+
+ if (!file.Read(std::span{data}.first(read_size)))
+ {
+ ERROR_LOG_FMT(SERIALINTERFACE_CARD, "Failed to read from file: {}", filename);
+ return false;
+ }
+
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "Loaded {} bytes from file: {}", read_size, filename);
+ return true;
+}
+
+void SanitizeSerialNumber(std::span<u8> data)
+{
+ DEBUG_ASSERT(data.size() == 8);
+
+ // This seems to be treated as a 16-digit BCD value.
+
+ // Avalon: 9571 264_ XXXX XXXX
+ // Where _ is mod 10 of the sum of all the X digits.
+
+ // VirtuaStriker4: 9571 440_ XXXX XXXX
+ // I think this might be generated from the SN physically printed on the card.
+ // The game code that deals with this is very complicated.
+ // We're lucky that all zeros seems to work.
+
+ // Gekitou: 9571 765_ XXXX XXXX
+ // How does the checksum work ?
+
+ data[0] = 0x95;
+ data[1] = 0x71;
+
+ switch (AMMediaboard::GetGameType())
+ {
+ case AMMediaboard::KeyOfAvalon:
+ {
+ data[2] = 0x26;
+ data[3] = 0x40;
+
+ u32 checksum = 0;
+ for (auto bcd_pair : data.subspan(4))
+ checksum += (bcd_pair & 0x0f) + (bcd_pair >> 4);
+ data[3] |= (checksum % 10);
+
+ break;
+ }
+ case AMMediaboard::VirtuaStriker4:
+ case AMMediaboard::VirtuaStriker4_2006:
+ {
+ data[2] = 0x44;
+ data[3] = 0x00;
+ break;
+ }
+ case AMMediaboard::GekitouProYakyuu:
+ {
+ data[2] = 0x76;
+ data[3] = 0x50;
+ break;
+ }
+ default:
+ break;
+ }
+}
+
+void InitializeCardData(std::span<u8> data)
+{
+ SanitizeSerialNumber(data.subspan(READ_ONLY_PAGE_INDEX * 8, 8));
+
+ constexpr u32 use_count_offset = 0x28;
+
+ constexpr u16 use_count = 0;
+
+ // The use count seems to be a big endian count down from 0xffff.
+ Common::WriteSwap16(data.data() + use_count_offset, 0xffff - use_count);
+}
+
+// TODO: I'm not so sure that this is really a device status.
+// The values that Avalon seems to test for seem very command-specific.
+// It might be more like a command result code.
+enum ICCardStatus : u16
+{
+ Okay = 0x0000,
+ NoCard = 0x8000,
+ Unknown = 0x800e,
+ BadCard = 0xffff,
+ // TODO: Figure out what's actually returned in these situations.
+ HardwareUntestedErrorCode = 0x0080,
+};
+
+enum class ICCardCommand : u8
+{
+ GetStatus = 0x10,
+ SetBaudrate = 0x11,
+ FieldOn = 0x14,
+ FieldOff = 0x15,
+ EjectCard = 0x16,
+ InsertCheck = 0x20,
+ AntiCollision = 0x21,
+ SelectCard = 0x22,
+ ReadPage = 0x24,
+ WritePage = 0x25,
+ DecreaseUseCount = 0x26,
+ HaltCard = 0x27,
+ ReadUseCount = 0x33,
+ ReadPages = 0x34,
+ WritePages = 0x35,
+};
+
+} // namespace
+
+namespace Triforce
+{
+
+ICCardReader::ICCardReader(u8 slot_index) : m_slot_index{slot_index}
+{
+}
+
+void ICCardReader::CreateCards(u8 card_count)
+{
+ m_ic_cards.clear();
+
+ // Filled in with slot index and card number just for uniqueness.
+ ICCard::UID card_id = {0x00, 0x00, 0x54, 0x4D, 0x50, 0x00, m_slot_index, 0x00};
+
+ for (u8 i = 0; i != card_count; ++i)
+ {
+ std::string slot_name = fmt::format("slot{}", m_slot_index + 1);
+
+ // Files for additional tags get naming like "slot1b.bin"
+ if (i > 0)
+ slot_name += char('a' + i);
+
+ const auto filename = fmt::format("{}tricard_{}_{}.bin", File::GetUserPath(D_TRIUSER_IDX),
+ SConfig::GetInstance().GetGameID(), slot_name);
+
+ card_id.back() = i;
+
+ m_ic_cards.emplace_back(std::make_unique<ICCard>(filename, card_id));
+ }
+}
+
+void ICCardReader::Update()
+{
+ if (m_eject_timer != 0)
+ --m_eject_timer;
+
+ const auto rx_byte_span = GetRxByteSpan();
+ if (rx_byte_span.size() < 4)
+ return; // Wait for more data.
+
+ // For reference:
+ // struct RequestPacket
+ // {
+ // u8 fixed; // Seems to be always 0x00.
+ // u8 ic_card_command;
+ // u16 payload_size; // Big-endian.
+ // u8 payload[payload_size];
+ // u8 checksum; // XOR of all previous bytes.
+ // };
+
+ const u16 input_payload_size = Common::swap16(rx_byte_span.data() + 2);
+ // 4 header bytes + 1 checksum byte
+ const u32 entire_request_size = input_payload_size + 5u;
+
+ if (rx_byte_span.size() < entire_request_size)
+ return; // Wait for more data.
+
+ if (CheckSumXOR(rx_byte_span.first(entire_request_size)) != 0)
+ {
+ ERROR_LOG_FMT(SERIALINTERFACE_CARD, "Bad checksum!");
+ ConsumeRxBytes(1);
+ return;
+ }
+
+ Common::ScopeGuard consume_request{[&] { ConsumeRxBytes(entire_request_size); }};
+
+ const u8 card_command = rx_byte_span[1];
+ const auto input_payload = rx_byte_span.subspan(4, input_payload_size);
+
+ u16 status_code = 0x00;
+
+ const auto validate_input_payload_size = [&](u32 expected_size) {
+ if (input_payload_size < expected_size)
+ {
+ ERROR_LOG_FMT(SERIALINTERFACE_CARD, "Undersized payload size {} for command: {:02x}",
+ input_payload_size, card_command);
+
+ status_code = HardwareUntestedErrorCode;
+ return false;
+ }
+
+ if (input_payload_size > expected_size)
+ {
+ WARN_LOG_FMT(SERIALINTERFACE_CARD, "Oversized payload size {} for command: {:02x}",
+ input_payload_size, card_command);
+ }
+
+ return true;
+ };
+
+ const auto get_card_for_session = [&](u16 card_session) -> ICCard* {
+ if ((card_session & 0xff00) == CARD_SESSION_BASE)
+ {
+ // FYI: We don't verify that this session was actually created before using it.
+ // This is fine, but I'm sure it's not really hardware accurate.
+ const std::size_t index = card_session & 0xffu;
+ if (index < m_ic_cards.size())
+ return m_ic_cards[index].get();
+ }
+
+ ERROR_LOG_FMT(SERIALINTERFACE_CARD, "Unexpected card session: {:04x}", card_session);
+
+ status_code = HardwareUntestedErrorCode;
+ return nullptr;
+ };
+
+ // Note: Command and response payload sizes are multiples of 8-bytes.
+ std::array<u8, 8> small_reply_payload{};
+
+ // Will be later assigned to `small_reply_payload` or some region of the card data itself.
+ std::span<const u8> reply_payload_span;
+
+ // FYI: Many of the big-endian u16 parameters may just be u8 values.
+ // Avalon writes single bytes, but at odd addresses, so u16 seems like the intention.
+
+ // TODO: Many of these functions should probably fail when the field is off.
+ // It might make sense for that logic to be inside ICCard.
+
+ if (ICCardCommand(card_command) != ICCardCommand::InsertCheck)
+ {
+ m_card_present_insert_check_count = 0;
+ }
+
+ switch (ICCardCommand(card_command))
+ {
+ case ICCardCommand::GetStatus:
+ {
+ if (!validate_input_payload_size(0))
+ break;
+
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "GetStatus");
+
+ // Avalon's tests expect one of these specific values.
+ status_code = m_is_field_on ? 0x30 : 0x20;
+
+ break;
+ }
+ case ICCardCommand::SetBaudrate:
+ {
+ if (!validate_input_payload_size(8))
+ break;
+
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "SetBaudrate: {:02x}", fmt::join(input_payload, " "));
+ break;
+ }
+ case ICCardCommand::FieldOn:
+ {
+ if (!validate_input_payload_size(0))
+ break;
+
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "FieldOn");
+
+ m_is_field_on = true;
+
+ // FYI: Avalon's tests expect status code here to be 0x0000.
+
+ break;
+ }
+ case ICCardCommand::FieldOff:
+ {
+ if (!validate_input_payload_size(0))
+ break;
+
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "FieldOff");
+
+ m_is_field_on = false;
+
+ std::ranges::for_each(m_ic_cards, &ICCard::SetIdle);
+
+ break;
+ }
+ case ICCardCommand::EjectCard:
+ {
+ if (!validate_input_payload_size(0))
+ break;
+
+ EjectCard();
+ break;
+ }
+ case ICCardCommand::InsertCheck:
+ {
+ if (!validate_input_payload_size(8))
+ break;
+
+ // Avalon sends 0 or 1 here, not sure what the meaning is.
+ // This is maybe the "Time Slot Number" (TSN)? 0=1-slot, 1=2-slots, etc.
+ const u16 unknown_parameter = Common::swap16(input_payload.data() + 0);
+
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "InsertCheck: {}", unknown_parameter);
+
+ const bool is_card_present = std::ranges::any_of(m_ic_cards, std::not_fn(&ICCard::IsHalted));
+
+ if (is_card_present &&
+ m_card_present_insert_check_count <
+ std::numeric_limits<decltype(m_card_present_insert_check_count)>::max())
+ {
+ ++m_card_present_insert_check_count;
+ }
+
+ // Avalon seems to test specifically for zero.
+ status_code = is_card_present ? 0x00 : HardwareUntestedErrorCode;
+ break;
+ }
+ case ICCardCommand::AntiCollision:
+ {
+ if (!validate_input_payload_size(16))
+ break;
+
+ // Avalon's logic optionally sets param0=0x20 and 8 memcpy'd bytes but never seems to do it.
+ const u16 unknown_param0 = Common::swap16(input_payload.data() + 0);
+ const auto unknown_param1 = input_payload.subspan(2, 8);
+
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "AntiCollision: unk0:{:04x} unk1:{:02x}", unknown_param0,
+ fmt::join(unknown_param1, " "));
+
+ const auto first_non_halted_card = std::ranges::find_if_not(m_ic_cards, &ICCard::IsHalted);
+
+ if (first_non_halted_card != m_ic_cards.end())
+ {
+ reply_payload_span = (*first_non_halted_card)->GetUID();
+
+ const bool additional_cards =
+ std::ranges::count_if(m_ic_cards, std::not_fn(&ICCard::IsHalted)) > 1;
+
+ // Avalon seems to like a value of 0x00 or 0x01. 0x01 causes two cards to be processed.
+ status_code = additional_cards ? 0x01 : 0x00;
+ }
+ else
+ {
+ status_code = HardwareUntestedErrorCode;
+ }
+
+ break;
+ }
+ case ICCardCommand::SelectCard:
+ {
+ if (!validate_input_payload_size(8))
+ break;
+
+ // FYI: The request includes the UID that we produced in `AntiCollision`.
+ const auto card_uid = input_payload.first(8);
+
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "SelectCard: {:02x}", fmt::join(card_uid, " "));
+
+ const auto found_card = std::ranges::find_if(
+ m_ic_cards, std::bind_front(std::ranges::equal, card_uid), &ICCard::GetUID);
+
+ if (found_card != m_ic_cards.end())
+ {
+ const u16 card_session = CARD_SESSION_BASE | u16(found_card - m_ic_cards.begin());
+
+ Common::WriteSwap16(small_reply_payload.data(), card_session);
+ reply_payload_span = small_reply_payload;
+
+ // Avalon's tests specifically want 0x00.
+ status_code = 0x00;
+ }
+ else
+ {
+ WARN_LOG_FMT(SERIALINTERFACE_CARD, "SelectCard: Unexpected Card ID.");
+
+ status_code = HardwareUntestedErrorCode;
+ break;
+ }
+
+ break;
+ }
+ // FYI: These two seem to have the same parameters.
+ // Maybe ReadUseCount is meant to copy just 2 bytes? The response is still expected to be 8.
+ case ICCardCommand::ReadPage:
+ case ICCardCommand::ReadUseCount:
+ {
+ if (!validate_input_payload_size(8))
+ break;
+
+ const u16 card_session = Common::swap16(input_payload.data() + 0);
+ const u16 page = Common::swap16(input_payload.data() + 2) & PAGE_INDEX_MASK;
+
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "ReadPage: session:{:04x} page:{}", card_session, page);
+
+ auto* const ic_card = get_card_for_session(card_session);
+ if (!ic_card)
+ break;
+
+ const auto read_span = ic_card->ReadData(page, 1);
+ if (read_span.empty())
+ {
+ status_code = HardwareUntestedErrorCode;
+ }
+ else
+ {
+ reply_payload_span = read_span;
+ }
+
+ break;
+ }
+ case ICCardCommand::WritePage:
+ {
+ if (!validate_input_payload_size(16))
+ break;
+
+ const u16 card_session = Common::swap16(input_payload.data() + 0);
+ // Avalon specifically puts a zero here.
+ const u16 unknown = Common::swap16(input_payload.data() + 2);
+ const u16 page = Common::swap16(input_payload.data() + 4) & PAGE_INDEX_MASK;
+
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "WritePage: session:{:04x} unk:{} page:{}", card_session,
+ unknown, page);
+
+ auto* const ic_card = get_card_for_session(card_session);
+ if (!ic_card)
+ break;
+
+ if (!ic_card->WriteData(page, input_payload.subspan(6, PAGE_SIZE)))
+ {
+ status_code = HardwareUntestedErrorCode;
+ }
+
+ break;
+ }
+ case ICCardCommand::DecreaseUseCount:
+ {
+ if (!validate_input_payload_size(8))
+ break;
+
+ const u16 card_session = Common::swap16(input_payload.data() + 0);
+ // Avalon seems to always sends 5 and 1. Guessing on the meaning and behavior.
+ const u16 page = Common::swap16(input_payload.data() + 2) & PAGE_INDEX_MASK;
+ const u16 amount = Common::swap16(input_payload.data() + 4);
+
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "DecreaseUseCount: session:{:04x}, page:{} amount:{}",
+ card_session, page, amount);
+
+ auto* const ic_card = get_card_for_session(card_session);
+ if (!ic_card)
+ break;
+
+ const u16 new_count = ic_card->DecreaseUseCount(page, amount);
+
+ Common::WriteSwap16(small_reply_payload.data(), new_count);
+ reply_payload_span = small_reply_payload;
+
+ break;
+ }
+ case ICCardCommand::HaltCard:
+ {
+ if (!validate_input_payload_size(8))
+ break;
+
+ const u16 card_session = Common::swap16(input_payload.data() + 0);
+
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "HaltCard: session:{:04x}", card_session);
+
+ auto* const ic_card = get_card_for_session(card_session);
+ if (!ic_card)
+ break;
+
+ ic_card->SetHalted();
+
+ break;
+ }
+ case ICCardCommand::ReadPages:
+ {
+ if (!validate_input_payload_size(8))
+ break;
+
+ const u16 card_session = Common::swap16(input_payload.data() + 0);
+ const u16 page = Common::swap16(input_payload.data() + 2) & PAGE_INDEX_MASK;
+ const u16 page_count = Common::swap16(input_payload.data() + 4);
+
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "ReadPages: session:{:04x} page:{} page_count:{}",
+ card_session, page, page_count);
+
+ auto* const ic_card = get_card_for_session(card_session);
+ if (!ic_card)
+ break;
+
+ const auto read_span = ic_card->ReadData(page, page_count);
+ if (read_span.empty())
+ {
+ status_code = HardwareUntestedErrorCode;
+ }
+ else
+ {
+ reply_payload_span = read_span;
+ }
+
+ break;
+ }
+ case ICCardCommand::WritePages:
+ {
+ if (input_payload_size < 8)
+ {
+ ERROR_LOG_FMT(SERIALINTERFACE_CARD, "WritePages: Undersized payload size: {}",
+ input_payload_size);
+ break;
+ }
+
+ const u16 card_session = Common::swap16(input_payload.data() + 0);
+ const u32 page = Common::swap16(input_payload.data() + 2) & PAGE_INDEX_MASK;
+ const u32 page_count = Common::swap16(input_payload.data() + 4);
+
+ INFO_LOG_FMT(SERIALINTERFACE_CARD, "WritePages: session:{:04x} page:{} page_count:{}",
+ card_session, page, page_count);
+
+ auto* const ic_card = get_card_for_session(card_session);
+ if (!ic_card)
+ break;
+
+ const u32 byte_count = page_count * PAGE_SIZE;
+
+ if (!validate_input_payload_size(8 + byte_count))
+ break;
+
+ if (!ic_card->WriteData(page, input_payload.subspan(6, byte_count)))
+ {
+ status_code = HardwareUntestedErrorCode;
+ }
+
+ break;
+ }
+ default:
+ {
+ ERROR_LOG_FMT(SERIALINTERFACE_CARD, "Unknown command: {:02x}", card_command);
+ break;
+ }
+ }
+
+ SendReply(card_command, status_code, reply_payload_span);
+}
+
+void ICCardReader::SendReply(u8 command, u16 status_code, std::span<const u8> payload)
+{
+ struct ICCardReplyHeader
+ {
+ u8 fixed = 0x10; // Games seem to expect 0x10.
+ u8 command;
+ Common::BigEndianValue<u16> length; // Includes status and payload bytes.
+ Common::BigEndianValue<u16> status;
+ };
+
+ ICCardReplyHeader header{.command = command};
+ header.length = u16(sizeof(header.status) + payload.size());
+ header.status = status_code;
+
+ const auto header_span = Common::AsU8Span(header);
+
+ const u8 checksum = CheckSumXOR(header_span) ^ CheckSumXOR(payload);
+
+ WriteTxBytes(header_span);
+ WriteTxBytes(payload);
+ WriteTxByte(checksum);
+}
+
+std::span<const u8> ICCardReader::ICCard::ReadData(u16 page, u16 page_count)
+{
+ const u32 byte_offset = page * PAGE_SIZE;
+ const u32 byte_count = page_count * PAGE_SIZE;
+
+ if (byte_count + byte_offset > m_data.size())
+ {
+ WARN_LOG_FMT(SERIALINTERFACE_CARD, "ReadData: Attempt to read beyond end of card.");
+ return {};
+ }
+
+ const auto read_span = std::span{m_data}.subspan(byte_offset, byte_count);
+
+ DEBUG_LOG_FMT(SERIALINTERFACE_CARD, "\n{}", HexDump(read_span));
+
+ return read_span;
+}
+
+bool ICCardReader::ICCard::WriteData(u16 page, std::span<const u8> write_span)
+{
+ constexpr u32 read_only_area_begin = READ_ONLY_PAGE_INDEX * PAGE_SIZE;
+ constexpr u32 read_only_area_end = read_only_area_begin + PAGE_SIZE;
+
+ const u32 byte_offset = page * PAGE_SIZE;
+
+ if ((byte_offset < read_only_area_end) &&
+ (byte_offset + write_span.size() > read_only_area_begin))
+ {
+ WARN_LOG_FMT(SERIALINTERFACE_CARD, "WriteData: Read-only page.");
+ // Read-only page, must return error.
+ return false;
+ }
+
+ if (write_span.size() + byte_offset > m_data.size())
+ {
+ WARN_LOG_FMT(SERIALINTERFACE_CARD, "WriteData: Attempt to write beyond end of card.");
+ return false;
+ }
+
+ std::ranges::copy(write_span, m_data.data() + byte_offset);
+
+ DEBUG_LOG_FMT(SERIALINTERFACE_CARD, "\n{}", HexDump(write_span));
+
+ FlushData(byte_offset, u32(write_span.size()));
+
+ return true;
+}
+
+u16 ICCardReader::ICCard::DecreaseUseCount(u16 page, u16 amount)
+{
+ const u32 byte_offset = page * PAGE_SIZE;
+ auto* const addr = m_data.data() + byte_offset;
+
+ const u16 previous_use_count = Common::swap16(addr);
+
+ // We're not sure how the real hardware functions when decreasing below 0.
+ // We'll clamp it to 0 for now.
+ const u16 new_use_count = MathUtil::SaturatingCast<u16>(s32(previous_use_count) - amount);
+
+ NOTICE_LOG_FMT(SERIALINTERFACE_CARD, "DecreaseUseCount: {} -> {}", previous_use_count,
+ new_use_count);
+
+ Common::WriteSwap16(addr, new_use_count);
+
+ FlushData(byte_offset, sizeof(u16));
+
+ return new_use_count;
+}
+
+// TODO: Maybe in the future we should write to disk after a delay.
+// Games seem to write many chunks when saving.
+void ICCardReader::ICCard::FlushData(u32 byte_offset, u32 byte_count)
+{
+ File::DirectIOFile file{m_filename, File::AccessMode::Write, File::OpenMode::Always};
+ if (!file.OffsetWrite(byte_offset, std::span{m_data}.subspan(byte_offset, byte_count)))
+ {
+ ERROR_LOG_FMT(SERIALINTERFACE_CARD, "FlushData: Failed to write to: {}", m_filename);
+ }
+}
+
+void ICCardReader::DoState(PointerWrap& p)
+{
+ SerialDevice::DoState(p);
+
+ // TODO: Think about what to do with the card data on the filesystem.
+
+ auto card_count = u8(m_ic_cards.size());
+ p.Do(card_count);
+
+ if (card_count != m_ic_cards.size())
+ CreateCards(card_count);
+
+ for (auto& card : m_ic_cards)
+ card->DoState(p);
+
+ p.Do(m_is_field_on);
+
+ p.Do(m_eject_timer);
+ p.Do(m_card_present_insert_check_count);
+}
+
+void ICCardReader::ICCard::DoState(PointerWrap& p)
+{
+ p.Do(m_data);
+ p.Do(m_uid); // UID is deterministically generated, but we'll sync it in case that changes.
+ p.Do(m_current_state);
+}
+
+ICCardReader::ICCard::ICCard(std::string filename, const UID& uid)
+ : m_filename{std::move(filename)}, m_uid{uid}
+{
+}
+
+void ICCardReader::ICCard::Initialize()
+{
+ if (!LoadCardData(m_filename, m_data))
+ {
+ NOTICE_LOG_FMT(SERIALINTERFACE_CARD, "Creating new IC Card data.");
+ InitializeCardData(m_data);
+ FlushData(READ_ONLY_PAGE_INDEX * PAGE_SIZE, PAGE_SIZE * 2);
+ }
+}
+
+bool ICCardReader::IsCardPresent() const
+{
+ return !m_ic_cards.empty();
+}
+
+bool ICCardReader::IsEjecting() const
+{
+ return m_eject_timer > 60;
+}
+
+bool ICCardReader::IsReadyToInsertCard() const
+{
+ return !IsCardPresent() && m_eject_timer == 0;
+}
+
+void ICCardReader::InsertCard()
+{
+ if (IsCardPresent())
+ return;
+
+ NOTICE_LOG_FMT(SERIALINTERFACE_CARD, "InsertCard");
+
+ CreateCards(1);
+ std::ranges::for_each(m_ic_cards, &ICCard::Initialize);
+}
+
+void ICCardReader::EjectCard()
+{
+ if (!IsCardPresent())
+ return;
+
+ NOTICE_LOG_FMT(SERIALINTERFACE_CARD, "EjectCard");
+
+ m_ic_cards.clear();
+
+ m_eject_timer = 120;
+ m_card_present_insert_check_count = 0;
+}
+
+} // namespace Triforce
diff --git a/Source/Core/Core/HW/Triforce/ICCardReader.h b/Source/Core/Core/HW/Triforce/ICCardReader.h
new file mode 100644
index 0000000000..7f4911c85d
--- /dev/null
+++ b/Source/Core/Core/HW/Triforce/ICCardReader.h
@@ -0,0 +1,110 @@
+// Copyright 2026 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include <array>
+#include <memory>
+#include <span>
+#include <string>
+#include <vector>
+
+#include "Common/CommonTypes.h"
+
+#include "Core/HW/Triforce/SerialDevice.h"
+
+namespace Triforce
+{
+
+// IC card reader.
+// Used by: GekitouProYakyuu, KeyOfAvalon, VirtuaStriker4[_2006]
+class ICCardReader final : public SerialDevice
+{
+public:
+ explicit ICCardReader(u8 slot_index);
+
+ void Update() override;
+
+ bool IsCardPresent() const;
+ bool IsEjecting() const;
+ bool IsReadyToInsertCard() const;
+
+ void InsertCard();
+ void EjectCard();
+
+ u8 GetCardPresentInsertCheckCount() const { return m_card_present_insert_check_count; }
+
+ void DoState(PointerWrap& p) override;
+
+private:
+ void CreateCards(u8 card_count);
+
+ void SendReply(u8 command, u16 status_code, std::span<const u8> payload);
+
+ static constexpr std::size_t PAGE_SIZE = 8;
+ static constexpr u32 PAGE_COUNT = 256;
+
+ // TODO: Maybe we're supposed to error for out-of-bound pages instead?
+ static constexpr u32 PAGE_INDEX_MASK = 0xff;
+
+ class ICCard
+ {
+ public:
+ using UID = std::array<u8, PAGE_SIZE>;
+
+ ICCard(std::string filename, const UID& uid);
+
+ // Load from file if it exists, else create fresh card data.
+ void Initialize();
+
+ const UID& GetUID() const { return m_uid; }
+ bool IsHalted() const { return m_current_state == State::Halted; }
+
+ // Returns an empty span on error.
+ std::span<const u8> ReadData(u16 page, u16 page_count);
+
+ bool WriteData(u16 page, std::span<const u8> write_span);
+
+ // Returns the new value.
+ u16 DecreaseUseCount(u16 page, u16 amount);
+
+ void SetHalted() { m_current_state = State::Halted; }
+ void SetIdle() { m_current_state = State::Idle; }
+
+ void DoState(PointerWrap& p);
+
+ private:
+ void FlushData(u32 byte_offset, u32 byte_count);
+
+ std::array<u8, PAGE_SIZE * PAGE_COUNT> m_data{};
+
+ const std::string m_filename;
+
+ const UID m_uid;
+
+ enum class State : u8
+ {
+ Idle = 0,
+ Halted = 1,
+ };
+
+ // I believe "Halted" cards don't respond in "AntiCollision".
+ State m_current_state = State::Idle;
+ };
+
+ // FYI: These are more like multiple RFID tags on a single card.
+ // Avalon makes use of this for not yet fully understood reasons.
+ std::vector<std::unique_ptr<ICCard>> m_ic_cards;
+
+ bool m_is_field_on = false;
+
+ const u8 m_slot_index;
+
+ u8 m_eject_timer{};
+
+ // Avalon spams InsertCheck when it's waiting for a card to be removed.
+ // We listen for that to automatically remove the card.
+ u8 m_card_present_insert_check_count{};
+};
+
+} // namespace Triforce
diff --git a/Source/Core/Core/HW/Triforce/IOPorts.cpp b/Source/Core/Core/HW/Triforce/IOPorts.cpp
new file mode 100644
index 0000000000..2a600f94cd
--- /dev/null
+++ b/Source/Core/Core/HW/Triforce/IOPorts.cpp
@@ -0,0 +1,226 @@
+// Copyright 2026 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "Core/HW/Triforce/IOPorts.h"
+
+#include <functional>
+
+#include <fmt/ranges.h>
+
+#include "Common/BitUtils.h"
+#include "Common/ChunkFile.h"
+
+#include "Core/HW/Triforce/ICCardReader.h"
+
+namespace Triforce
+{
+
+void IOPorts::Update()
+{
+ std::ranges::for_each(m_io_adapters, &IOAdapter::Update);
+}
+
+void IOPorts::DoState(PointerWrap& p)
+{
+ p.Do(m_switch_input_data);
+ p.Do(m_generic_output_data);
+}
+
+void IOPorts::AddIOAdapter(std::unique_ptr<IOAdapter> adapter)
+{
+ adapter->SetIOPorts(this);
+ m_io_adapters.emplace_back(std::move(adapter));
+}
+
+IOAdapter::IOAdapter() = default;
+IOAdapter::~IOAdapter() = default;
+
+void IOPorts::SetGenericOutputs(std::span<const u8> bytes)
+{
+ const auto bytes_to_copy = std::min(bytes.size(), GENERIC_OUTPUT_BYTE_COUNT);
+
+ if (bytes.size() > m_generic_output_data.size())
+ {
+ WARN_LOG_FMT(SERIALINTERFACE_JVSIO, "JVS-IO: GenericOutputs: Unexpected byte count: {}",
+ bytes.size());
+ }
+
+ decltype(m_generic_output_data) bits_set{};
+ decltype(m_generic_output_data) bits_cleared{};
+
+ for (std::size_t i = 0; i != bytes_to_copy; ++i)
+ {
+ bits_set[i] = u8(~m_generic_output_data[i]) & bytes[i];
+ bits_cleared[i] = m_generic_output_data[i] & u8(~bytes[i]);
+
+ m_generic_output_data[i] = bytes[i];
+ }
+
+ bool bits_changed = false;
+
+ if (std::ranges::any_of(bits_set, std::bind_front(std::not_equal_to{}, 0x00)))
+ {
+ bits_changed = true;
+ INFO_LOG_FMT(SERIALINTERFACE_JVSIO, "JVS-IO: GenericOutputs: bits_set: {:02x}",
+ fmt::join(bits_set, " "));
+ }
+ if (std::ranges::any_of(bits_cleared, std::bind_front(std::not_equal_to{}, 0x00)))
+ {
+ bits_changed = true;
+ INFO_LOG_FMT(SERIALINTERFACE_JVSIO, "JVS-IO: GenericOutputs: bits_cleared: {:02x}",
+ fmt::join(bits_cleared, " "));
+ }
+
+ if (!bits_changed)
+ return;
+
+ for (auto& adapter : m_io_adapters)
+ {
+ adapter->HandleGenericOutputsChanged(bits_set, bits_cleared);
+ }
+}
+
+void IOAdapter::Update()
+{
+}
+
+void IOAdapter::HandleGenericOutputsChanged(std::span<const u8> bits_set,
+ std::span<const u8> bits_cleared)
+{
+}
+
+void MarioKartGPCommon_IOAdapter::HandleGenericOutputsChanged(std::span<const u8> bits_set,
+ std::span<const u8> bits_cleared)
+{
+ const u8 bits_changed_0 = bits_set[0] | bits_cleared[0];
+
+ if (bits_changed_0 & ITEM_LIGHT_BIT)
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_JVSIO, "JVS-IO: Item Button: {}",
+ (bits_set[0] & ITEM_LIGHT_BIT) ? "ON" : "OFF");
+ }
+
+ if (bits_changed_0 & CANCEL_LIGHT_BIT)
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_JVSIO, "JVS-IO: Cancel Button: {}",
+ (bits_set[0] & CANCEL_LIGHT_BIT) ? "ON" : "OFF");
+ }
+}
+
+void VirtuaStriker4Common_IOAdapter::Update()
+{
+ const auto generic_outputs = GetIOPorts()->GetGenericOutputs();
+
+ const bool is_slot_a_locked = generic_outputs[0] & SLOT_A_LOCK_BIT;
+ const bool is_slot_b_locked = generic_outputs[0] & SLOT_B_LOCK_BIT;
+
+ // FYI: VS4 and VS4_2006 won't accept cards when both are presented at the same time.
+ // It expects to load and lock one before the second is inserted.
+
+ if (is_slot_a_locked)
+ {
+ // If slot 1 is locked, insert slot 2.
+ if (m_card_reader_b->IsReadyToInsertCard())
+ m_card_reader_b->InsertCard();
+ }
+ else
+ {
+ // If slot 1 is unlocked, remove slot 2 if unlocked.
+ if (m_card_reader_b->IsCardPresent() && !is_slot_b_locked)
+ m_card_reader_b->EjectCard();
+ }
+
+ if (m_card_reader_a->IsReadyToInsertCard())
+ m_card_reader_a->InsertCard();
+
+ const auto p1_inputs = GetIOPorts()->GetSwitchInputs(0);
+ const auto p2_inputs = GetIOPorts()->GetSwitchInputs(1);
+
+ // Bit 0x10 of each player's 1st byte is a card presence switch.
+ Common::SetBit(p1_inputs[0], 4, m_card_reader_a->IsCardPresent());
+ Common::SetBit(p2_inputs[0], 4, m_card_reader_b->IsCardPresent());
+
+ // Bit 0x20 of each player's 2nd byte is some kind of "eject" sensor.
+ Common::SetBit(p1_inputs[1], 5, m_card_reader_a->IsEjecting());
+ Common::SetBit(p2_inputs[1], 5, m_card_reader_b->IsEjecting());
+}
+
+void VirtuaStriker4Common_IOAdapter::HandleGenericOutputsChanged(std::span<const u8> bits_set,
+ std::span<const u8> bits_cleared)
+{
+ const u8 bits_changed_0 = bits_set[0] | bits_cleared[0];
+
+ if (bits_changed_0 & SLOT_A_LOCK_BIT)
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_JVSIO, "JVS-IO: Slot 1: {}",
+ (bits_set[0] & SLOT_A_LOCK_BIT) ? "Locked" : "Unlocked");
+ }
+
+ if (bits_changed_0 & SLOT_B_LOCK_BIT)
+ {
+ INFO_LOG_FMT(SERIALINTERFACE_JVSIO, "JVS-IO: Slot 2: {}",
+ (bits_set[0] & SLOT_B_LOCK_BIT) ? "Locked" : "Unlocked");
+ }
+}
+
+void VirtuaStriker4_2006_IOAdapter::HandleGenericOutputsChanged(std::span<const u8> bits_set,
+ std::span<const u8> bits_cleared)
+{
+ (void)bits_cleared;
+
+ // Unlike VirtuaStriker4, which uses ICCardCommand::Eject,
+ // VirtuaStriker4_2006 triggers card ejection with JVS-IO.
+
+ if (bits_set[0] & SLOT_A_EJECT_BIT)
+ m_card_reader_a->EjectCard();
+
+ if (bits_set[0] & SLOT_B_EJECT_BIT)
+ m_card_reader_b->EjectCard();
+}
+
+void GekitouProYakyuu_IOAdapter::Update()
+{
+ // Gekitou isn't as picky as VS4. We can insert both cards simultaneously.
+ for (const auto& card_reader : {m_card_reader_a, m_card_reader_b})
+ {
+ if (card_reader->IsReadyToInsertCard())
+ card_reader->InsertCard();
+ }
+
+ const auto p1_inputs = GetIOPorts()->GetSwitchInputs(0);
+ const auto p2_inputs = GetIOPorts()->GetSwitchInputs(1);
+
+ // Bit 0x40 of each player's 2nd byte is a card presence switch.
+ Common::SetBit(p1_inputs[1], 6, m_card_reader_a->IsCardPresent());
+ Common::SetBit(p2_inputs[1], 6, m_card_reader_b->IsCardPresent());
+}
+
+void GekitouProYakyuu_IOAdapter::HandleGenericOutputsChanged(std::span<const u8> bits_set,
+ std::span<const u8> bits_cleared)
+{
+ (void)bits_set;
+
+ // When a card is inserted Gekitou sets the relevant "lock" bit.
+ // When done with the card it clears this bit, then expects the card presence to be cleared.
+ // I guess we need to treat this as an alternative to ICCardCommand::Eject.
+
+ if (bits_cleared[0] & SLOT_A_LOCK_BIT)
+ m_card_reader_a->EjectCard();
+
+ if (bits_cleared[0] & SLOT_B_LOCK_BIT)
+ m_card_reader_b->EjectCard();
+}
+
+void KeyOfAvalon_IOAdapter::Update()
+{
+ // Note that the game sometimes does a few InsertCheck when it still wants a card.
+ constexpr u8 insert_check_count_before_removing_card = 10;
+
+ if (m_card_reader->GetCardPresentInsertCheckCount() >= insert_check_count_before_removing_card)
+ m_card_reader->EjectCard();
+
+ if (m_card_reader->IsReadyToInsertCard())
+ m_card_reader->InsertCard();
+}
+
+} // namespace Triforce
diff --git a/Source/Core/Core/HW/Triforce/IOPorts.h b/Source/Core/Core/HW/Triforce/IOPorts.h
new file mode 100644
index 0000000000..a5379c3a50
--- /dev/null
+++ b/Source/Core/Core/HW/Triforce/IOPorts.h
@@ -0,0 +1,183 @@
+// Copyright 2026 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include <array>
+#include <memory>
+#include <span>
+#include <vector>
+
+#include "Common/Assert.h"
+#include "Common/CommonTypes.h"
+
+class PointerWrap;
+
+namespace Triforce
+{
+
+class ICCardReader;
+class IOAdapter;
+
+// Triforce GPIO peripherals connect to JVS-IO and eachother in game specific ways.
+// This class hopes to handle those customizable connections.
+// TODO: Use this for other JVS-IO (Analog Input, Coin, etc.).
+class IOPorts
+{
+public:
+ void Update();
+
+ std::span<u8> GetSwitchInputs(u32 player_index)
+ {
+ ASSERT(player_index < PLAYER_COUNT);
+
+ return std::span{m_switch_input_data}.subspan(SWITCH_INPUT_BYTES_PER_PLAYER * player_index,
+ SWITCH_INPUT_BYTES_PER_PLAYER);
+ }
+ std::span<const u8> GetSwitchInputs(u32 player_index) const
+ {
+ ASSERT(player_index < PLAYER_COUNT);
+
+ return std::span{m_switch_input_data}.subspan(SWITCH_INPUT_BYTES_PER_PLAYER * player_index,
+ SWITCH_INPUT_BYTES_PER_PLAYER);
+ }
+
+ std::span<u8> GetGenericOutputs() { return m_generic_output_data; }
+ std::span<const u8> GetGenericOutputs() const { return m_generic_output_data; }
+
+ void SetGenericOutputs(std::span<const u8> bytes);
+
+ void AddIOAdapter(std::unique_ptr<IOAdapter> adapter);
+
+ void DoState(PointerWrap& p);
+
+private:
+ std::vector<std::unique_ptr<IOAdapter>> m_io_adapters;
+
+ static constexpr std::size_t PLAYER_COUNT = 2;
+ static constexpr std::size_t SWITCH_INPUT_BYTES_PER_PLAYER = 2;
+
+ std::array<u8, PLAYER_COUNT * SWITCH_INPUT_BYTES_PER_PLAYER> m_switch_input_data{};
+
+ static constexpr std::size_t GENERIC_OUTPUT_BYTE_COUNT = 4;
+
+ std::array<u8, GENERIC_OUTPUT_BYTE_COUNT> m_generic_output_data{};
+};
+
+class IOAdapter
+{
+ friend IOPorts;
+
+public:
+ IOAdapter();
+ virtual ~IOAdapter();
+
+ IOAdapter(const IOAdapter&) = delete;
+ IOAdapter& operator=(const IOAdapter&) = delete;
+ IOAdapter(IOAdapter&&) = delete;
+ IOAdapter& operator=(IOAdapter&&) = delete;
+
+ virtual void Update();
+
+protected:
+ IOPorts* GetIOPorts() { return m_io_ports; }
+
+ // Invoked with newly set and cleared bits.
+ virtual void HandleGenericOutputsChanged(std::span<const u8> bits_set,
+ std::span<const u8> bits_cleared);
+
+private:
+ void SetIOPorts(IOPorts* io_ports) { m_io_ports = io_ports; }
+
+ IOPorts* m_io_ports{};
+};
+
+// Used for both MarioKartGP and MarioKartGP2.
+class MarioKartGPCommon_IOAdapter final : public IOAdapter
+{
+protected:
+ void HandleGenericOutputsChanged(std::span<const u8> bits_set,
+ std::span<const u8> bits_cleared) override;
+
+private:
+ static constexpr u8 ITEM_LIGHT_BIT = 0x04;
+ static constexpr u8 CANCEL_LIGHT_BIT = 0x08;
+};
+
+// Common functionality for both VirtuaStriker4 and VirtuaStriker4_2006.
+class VirtuaStriker4Common_IOAdapter final : public IOAdapter
+{
+public:
+ VirtuaStriker4Common_IOAdapter(ICCardReader* card_reader_a, ICCardReader* card_reader_b)
+ : m_card_reader_a{card_reader_a}, m_card_reader_b{card_reader_b}
+ {
+ }
+
+ void Update() override;
+
+protected:
+ static constexpr u8 SLOT_A_LOCK_BIT = 0x40;
+ static constexpr u8 SLOT_B_LOCK_BIT = 0x10;
+
+ void HandleGenericOutputsChanged(std::span<const u8> bits_set,
+ std::span<const u8> bits_cleared) override;
+
+private:
+ ICCardReader* const m_card_reader_a;
+ ICCardReader* const m_card_reader_b;
+};
+
+class VirtuaStriker4_2006_IOAdapter final : public IOAdapter
+{
+public:
+ VirtuaStriker4_2006_IOAdapter(ICCardReader* card_reader_a, ICCardReader* card_reader_b)
+ : m_card_reader_a{card_reader_a}, m_card_reader_b{card_reader_b}
+ {
+ }
+
+protected:
+ void HandleGenericOutputsChanged(std::span<const u8> bits_set,
+ std::span<const u8> bits_cleared) override;
+
+private:
+ static constexpr u8 SLOT_A_EJECT_BIT = 0x80;
+ static constexpr u8 SLOT_B_EJECT_BIT = 0x20;
+
+ ICCardReader* const m_card_reader_a;
+ ICCardReader* const m_card_reader_b;
+};
+
+class GekitouProYakyuu_IOAdapter final : public IOAdapter
+{
+public:
+ GekitouProYakyuu_IOAdapter(ICCardReader* card_reader_a, ICCardReader* card_reader_b)
+ : m_card_reader_a{card_reader_a}, m_card_reader_b{card_reader_b}
+ {
+ }
+
+ void Update() override;
+
+protected:
+ void HandleGenericOutputsChanged(std::span<const u8> bits_set,
+ std::span<const u8> bits_cleared) override;
+
+private:
+ static constexpr u8 SLOT_A_LOCK_BIT = 0x40;
+ static constexpr u8 SLOT_B_LOCK_BIT = 0x10;
+
+ ICCardReader* const m_card_reader_a;
+ ICCardReader* const m_card_reader_b;
+};
+
+class KeyOfAvalon_IOAdapter final : public IOAdapter
+{
+public:
+ explicit KeyOfAvalon_IOAdapter(ICCardReader* card_reader) : m_card_reader{card_reader} {}
+
+ void Update() override;
+
+private:
+ ICCardReader* const m_card_reader;
+};
+
+} // namespace Triforce
diff --git a/Source/Core/Core/HW/Triforce/SerialDevice.cpp b/Source/Core/Core/HW/Triforce/SerialDevice.cpp
index 33e06e695d..cf9d2ca706 100644
--- a/Source/Core/Core/HW/Triforce/SerialDevice.cpp
+++ b/Source/Core/Core/HW/Triforce/SerialDevice.cpp
@@ -16,9 +16,7 @@ void SerialDevice::WriteRxBytes(std::span<const u8> bytes)
#if defined(__cpp_lib_containers_ranges)
m_rx_buffer.append_range(bytes);
#else
- const auto prev_size = m_rx_buffer.size();
- m_rx_buffer.resize(prev_size + bytes.size());
- std::ranges::copy(bytes, m_rx_buffer.begin() + prev_size);
+ m_rx_buffer.insert(m_rx_buffer.end(), bytes.begin(), bytes.end());
#endif
}
@@ -42,12 +40,34 @@ void SerialDevice::WriteTxBytes(std::span<const u8> bytes)
#if defined(__cpp_lib_containers_ranges)
m_tx_buffer.append_range(bytes);
#else
- const auto prev_size = m_tx_buffer.size();
- m_tx_buffer.resize(prev_size + bytes.size());
- std::ranges::copy(bytes, m_tx_buffer.begin() + prev_size);
+ m_tx_buffer.insert(m_tx_buffer.end(), bytes.begin(), bytes.end());
#endif
}
+void SerialDevice::PassThroughTxBytes(SerialDevice& other)
+{
+ // The destination buffer will often be empty or near-empty.
+ if (m_tx_buffer.size() < other.m_tx_buffer.size()) [[likely]]
+ {
+#if defined(__cpp_lib_containers_ranges)
+ other.m_tx_buffer.prepend_range(m_tx_buffer);
+#else
+ other.m_tx_buffer.insert(other.m_tx_buffer.begin(), m_tx_buffer.begin(), m_tx_buffer.end());
+#endif
+ m_tx_buffer.swap(other.m_tx_buffer);
+ }
+ else
+ {
+#if defined(__cpp_lib_containers_ranges)
+ m_tx_buffer.append_range(other.m_tx_buffer);
+#else
+ m_tx_buffer.insert(m_tx_buffer.end(), other.m_tx_buffer.begin(), other.m_tx_buffer.end());
+#endif
+ }
+
+ other.m_tx_buffer.clear();
+}
+
void SerialDevice::DoState(PointerWrap& p)
{
p.Do(m_rx_buffer);
diff --git a/Source/Core/Core/HW/Triforce/SerialDevice.h b/Source/Core/Core/HW/Triforce/SerialDevice.h
index e3aba79635..fa3af0b954 100644
--- a/Source/Core/Core/HW/Triforce/SerialDevice.h
+++ b/Source/Core/Core/HW/Triforce/SerialDevice.h
@@ -45,6 +45,9 @@ protected:
void WriteTxByte(u8 byte) { m_tx_buffer.emplace_back(byte); }
void WriteTxBytes(std::span<const u8> bytes);
+ // Transfer the entirety of other's tx buffer to this tx buffer.
+ void PassThroughTxBytes(SerialDevice& other);
+
private:
// The stream of bytes from the baseboard to the device.
// FYI: Current device implementations tend to empty the entire buffer in one go,
diff --git a/Source/Core/Core/State.cpp b/Source/Core/Core/State.cpp
index 29aa1f3aeb..fa6544d61b 100644
--- a/Source/Core/Core/State.cpp
+++ b/Source/Core/Core/State.cpp
@@ -95,7 +95,7 @@ struct CompressAndDumpStateArgs
static Common::WorkQueueThreadSP<CompressAndDumpStateArgs> s_compress_and_dump_thread;
// Don't forget to increase this after doing changes on the savestate system
-constexpr u32 STATE_VERSION = 181; // Last changed in PR 14400
+constexpr u32 STATE_VERSION = 182; // Last changed in PR 14482
// Increase this if the StateExtendedHeader definition changes
constexpr u32 EXTENDED_HEADER_VERSION = 1; // Last changed in PR 12217
diff --git a/Source/Core/DolphinLib.props b/Source/Core/DolphinLib.props
index 7d63c5a4b9..1713c0f88e 100644
--- a/Source/Core/DolphinLib.props
+++ b/Source/Core/DolphinLib.props
@@ -341,6 +341,9 @@
<ClInclude Include="Core\HW\Sram.h" />
<ClInclude Include="Core\HW\StreamADPCM.h" />
<ClInclude Include="Core\HW\SystemTimers.h" />
+ <ClInclude Include="Core\HW\Triforce\DeckReader.h" />
+ <ClInclude Include="Core\HW\Triforce\ICCardReader.h" />
+ <ClInclude Include="Core\HW\Triforce\IOPorts.h" />
<ClInclude Include="Core\HW\Triforce\SerialDevice.h" />
<ClInclude Include="Core\HW\Triforce\Touchscreen.h" />
<ClInclude Include="Core\HW\VideoInterface.h" />
@@ -1043,6 +1046,9 @@
<ClCompile Include="Core\HW\Sram.cpp" />
<ClCompile Include="Core\HW\StreamADPCM.cpp" />
<ClCompile Include="Core\HW\SystemTimers.cpp" />
+ <ClCompile Include="Core\HW\Triforce\DeckReader.cpp" />
+ <ClCompile Include="Core\HW\Triforce\ICCardReader.cpp" />
+ <ClCompile Include="Core\HW\Triforce\IOPorts.cpp" />
<ClCompile Include="Core\HW\Triforce\SerialDevice.cpp" />
<ClCompile Include="Core\HW\Triforce\Touchscreen.cpp" />
<ClCompile Include="Core\HW\VideoInterface.cpp" />