IOPorts: Add additional functionality to handle analog input, coin input, et al.

Created IOAdapter classes for FZeroAX games.
Created SerialDevice classes for MarioKartGP and FZeroAX FFB steering wheels.
Added game-specific input handling to the various IOAdapter classes.
This commit is contained in:
Jordan Woyak 2026-03-23 00:51:13 -05:00
parent 49518164bb
commit 5c912e881e
6 changed files with 833 additions and 105 deletions

View File

@ -0,0 +1,275 @@
// Copyright 2026 Dolphin Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include "Core/HW/Triforce/FZeroAX.h"
#include <numeric>
#include <fmt/ranges.h>
#include "Common/BitUtils.h"
#include "Common/ChunkFile.h"
#include "Common/Logging/Log.h"
#include "Common/Swap.h"
#include "Core/HW/GCPad.h"
#include "InputCommon/GCPadStatus.h"
namespace Triforce
{
void FZeroAXCommon_IOAdapter::Update()
{
auto* const io_ports = GetIOPorts();
// Horizontal Scanning Frequency switch.
// Required for booting via Sega Boot.
io_ports->GetStatusSwitches()[0] &= ~0x20u;
const GCPadStatus pad_status = Pad::GetStatus(0);
const auto switch_inputs_0 = io_ports->GetSwitchInputs(0);
// Start
if (pad_status.button & PAD_BUTTON_START)
switch_inputs_0[0] |= 0x80;
// View Change 1
if (pad_status.button & PAD_BUTTON_RIGHT)
switch_inputs_0[0] |= 0x20;
// View Change 2
if (pad_status.button & PAD_BUTTON_LEFT)
switch_inputs_0[0] |= 0x10;
// View Change 3
if (pad_status.button & PAD_BUTTON_UP)
switch_inputs_0[0] |= 0x08;
// View Change 4
if (pad_status.button & PAD_BUTTON_DOWN)
switch_inputs_0[0] |= 0x04;
// Boost
if (pad_status.button & PAD_BUTTON_A)
switch_inputs_0[0] |= 0x02;
const auto switch_inputs_1 = io_ports->GetSwitchInputs(1);
// Paddle left
if (pad_status.button & PAD_BUTTON_X)
switch_inputs_1[0] |= 0x20;
// Paddle right
if (pad_status.button & PAD_BUTTON_Y)
switch_inputs_1[0] |= 0x10;
const auto analog_inputs = io_ports->GetAnalogInputs();
// Steering
if (m_steering_wheel->IsInitializing())
{
// Override X position during initialization to make the calibration happy.
analog_inputs[0] =
(m_steering_wheel->GetServoPosition() * (1 << 9)) + IOPorts::NEUTRAL_ANALOG_VALUE;
}
else
{
analog_inputs[0] = Common::ExpandValue<u16>(pad_status.stickX, 8);
}
analog_inputs[1] = Common::ExpandValue<u16>(pad_status.stickY, 8);
// Gas
analog_inputs[4] = Common::ExpandValue<u16>(pad_status.triggerRight, 8);
// Brake
analog_inputs[5] = Common::ExpandValue<u16>(pad_status.triggerLeft, 8);
// Seat Motion
analog_inputs[6] = IOPorts::NEUTRAL_ANALOG_VALUE;
}
void FZeroAXCommon_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];
constexpr auto LED_NAMES = std::to_array<std::pair<u8, const char*>>({
{0x80, "START BUTTON"},
{0x20, "VIEW CHANGE 1"},
{0x10, "VIEW CHANGE 2"},
{0x08, "VIEW CHANGE 3"},
{0x04, "VIEW CHANGE 4"},
{0x40, "BOOST"},
});
for (const auto& [led_value, led_name] : LED_NAMES)
{
if (bits_changed_0 & led_value)
{
INFO_LOG_FMT(SERIALINTERFACE_JVSIO, "JVS-IO: {}: {}", led_name,
(bits_set[0] & led_value) ? "ON" : "OFF");
}
}
}
void FZeroAXDeluxe_IOAdapter::Update()
{
auto* const io_ports = GetIOPorts();
const auto generic_outputs = io_ports->GetGenericOutputs();
// Not fully understood trickery to satisfy the game's initialization sequence.
const u16 seat_state = Common::swap16(generic_outputs.data() + 1) >> 2;
switch (seat_state)
{
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;
}
constexpr bool seatbelt = true;
constexpr bool motion_stop = false;
constexpr bool sensor_left = false;
constexpr bool sensor_right = false;
const auto switch_inputs_p0 = io_ports->GetSwitchInputs(0);
if (seatbelt)
switch_inputs_p0[0] |= 0x01;
switch_inputs_p0[1] = m_rx_reply & 0xF0;
const auto switch_inputs_p1 = io_ports->GetSwitchInputs(1);
if (sensor_left)
switch_inputs_p1[0] |= 0x08;
if (sensor_right)
switch_inputs_p1[0] |= 0x04;
if (motion_stop)
switch_inputs_p1[0] |= 0x02;
switch_inputs_p1[1] = m_rx_reply << 4;
}
void FZeroAXMonster_IOAdapter::Update()
{
auto* const io_ports = GetIOPorts();
constexpr bool sensor = false;
constexpr bool emergency = false;
constexpr bool service = false;
constexpr bool seatbelt = true;
const auto switch_inputs_p0 = io_ports->GetSwitchInputs(0);
if (sensor)
switch_inputs_p0[0] |= 0x01;
const auto switch_inputs_p1 = io_ports->GetSwitchInputs(1);
if (emergency)
switch_inputs_p1[0] |= 0x08;
if (service)
switch_inputs_p1[0] |= 0x04;
if (seatbelt)
switch_inputs_p1[0] |= 0x02;
}
void FZeroAXDeluxe_IOAdapter::DoState(PointerWrap& p)
{
p.Do(m_delay);
p.Do(m_rx_reply);
}
void FZeroAXSteeringWheel::Update()
{
constexpr std::size_t REQUEST_SIZE = 4;
std::size_t rx_position = 0;
while (true)
{
const auto rx_span = GetRxByteSpan().subspan(rx_position);
if (rx_span.size() < REQUEST_SIZE)
break; // Wait for more data.
const auto request = rx_span.first<REQUEST_SIZE>();
// The first byte is XOR'd with 0x80.
// The last byte is an XOR of the previous bytes.
if (std::accumulate(request.begin(), request.end(), u8{0x80}, std::bit_xor{}) != 0)
{
WARN_LOG_FMT(SERIALINTERFACE_AMBB, "SteeringWheel: Bad checksum!");
++rx_position;
continue;
}
ProcessRequest(request);
rx_position += REQUEST_SIZE;
}
ConsumeRxBytes(rx_position);
}
void FZeroAXSteeringWheel::ProcessRequest(std::span<const u8> request)
{
DEBUG_LOG_FMT(SERIALINTERFACE_AMBB, "SteeringWheel: Request: {:02x}", fmt::join(request, " "));
// The first byte is XOR'd with 0x80.
const u8 cmd = request[0] ^ 0x80u;
switch (cmd)
{
case 0: // Power on/off ?
// Sent before force commands: 00 01
// Sent after force commands: 00 00
case 1: // Set Maximum?
case 2:
break;
case 4: // Move Steering Wheel
{
// This seems to be a u8 value but the MSb is the LSb of the previous byte.
// e.g. 01 7f -> ff
// This produces a value in the range around [-56, +56].
m_servo_position = s8(0x80 - (u8(request[1] << 7u) | request[2]));
DEBUG_LOG_FMT(SERIALINTERFACE_AMBB, "SteeringWheel: servo_position: {}", m_servo_position);
break;
}
case 6: // nice
case 9:
default:
break;
// Switch back to normal controls
case 7:
m_init_state = 2;
break;
// Reset
case 0x7F:
m_init_state = 1;
break;
}
// Simple 4 byte response.
WriteTxBytes(std::array<u8, 4>{});
}
bool FZeroAXSteeringWheel::IsInitializing() const
{
return m_init_state == 1;
}
void FZeroAXSteeringWheel::DoState(PointerWrap& p)
{
p.Do(m_init_state);
p.Do(m_servo_position);
}
} // namespace Triforce

View File

@ -0,0 +1,70 @@
// Copyright 2026 Dolphin Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include "Core/HW/Triforce/IOPorts.h"
#include "Core/HW/Triforce/SerialDevice.h"
namespace Triforce
{
// FFB wheel used by FZeroAX and FZeroAXMonster.
class FZeroAXSteeringWheel final : public SerialDevice
{
public:
void Update() override;
bool IsInitializing() const;
s8 GetServoPosition() const { return m_servo_position; }
void DoState(PointerWrap&) override;
private:
void ProcessRequest(std::span<const u8>);
u8 m_init_state = 0;
s8 m_servo_position = 0;
};
// Used for both FZeroAX and FZeroAXMonster.
class FZeroAXCommon_IOAdapter final : public IOAdapter
{
public:
explicit FZeroAXCommon_IOAdapter(FZeroAXSteeringWheel* steering_wheel)
: m_steering_wheel{steering_wheel}
{
}
void Update() override;
protected:
void HandleGenericOutputsChanged(std::span<const u8> bits_set,
std::span<const u8> bits_cleared) override;
private:
FZeroAXSteeringWheel* const m_steering_wheel;
};
// Includes seat motion handling.
class FZeroAXDeluxe_IOAdapter final : public IOAdapter
{
public:
void Update() override;
void DoState(PointerWrap&) override;
private:
u32 m_delay = 0;
u8 m_rx_reply = 0xF0;
};
class FZeroAXMonster_IOAdapter final : public IOAdapter
{
public:
void Update() override;
};
} // namespace Triforce

View File

@ -3,27 +3,63 @@
#include "Core/HW/Triforce/IOPorts.h"
#include <functional>
#include <algorithm>
#include <fmt/ranges.h>
#include "Common/Assert.h"
#include "Common/BitUtils.h"
#include "Common/ChunkFile.h"
#include "Core/HW/DVD/AMMediaboard.h"
#include "Core/HW/GCPad.h"
#include "Core/HW/SI/SI.h"
#include "Core/HW/SI/SI_Device.h"
#include "Core/HW/Triforce/ICCardReader.h"
#include "Core/System.h"
#include "InputCommon/GCPadStatus.h"
#include "VideoCommon/OnScreenDisplay.h"
namespace Triforce
{
void IOPorts::Update()
{
m_system_inputs = 0x00;
m_switch_inputs.fill(0x00);
m_analog_inputs.fill(NEUTRAL_ANALOG_VALUE);
m_coin_inputs.fill(false);
std::ranges::for_each(m_io_adapters, &IOAdapter::Update);
}
std::span<u8> IOPorts::GetSwitchInputs(u32 player_index)
{
ASSERT(player_index < PLAYER_COUNT);
return std::span{m_switch_inputs}.subspan(SWITCH_INPUT_BYTES_PER_PLAYER * player_index,
SWITCH_INPUT_BYTES_PER_PLAYER);
}
std::span<const u8> IOPorts::GetSwitchInputs(u32 player_index) const
{
ASSERT(player_index < PLAYER_COUNT);
return std::span{m_switch_inputs}.subspan(SWITCH_INPUT_BYTES_PER_PLAYER * player_index,
SWITCH_INPUT_BYTES_PER_PLAYER);
}
void IOPorts::DoState(PointerWrap& p)
{
p.Do(m_switch_input_data);
p.Do(m_generic_output_data);
p.Do(m_status_switches);
// Input states need not be state saved. They are updated before use.
p.Do(m_generic_outputs);
for (auto& io_adapter : m_io_adapters)
io_adapter->DoState(p);
}
void IOPorts::AddIOAdapter(std::unique_ptr<IOAdapter> adapter)
@ -37,42 +73,33 @@ 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())
if (bytes.size() > m_generic_outputs.size())
{
WARN_LOG_FMT(SERIALINTERFACE_JVSIO, "JVS-IO: GenericOutputs: Unexpected byte count: {}",
WARN_LOG_FMT(SERIALINTERFACE_JVSIO, "SetGenericOutputs: Unexpected byte count: {}",
bytes.size());
}
decltype(m_generic_output_data) bits_set{};
decltype(m_generic_output_data) bits_cleared{};
const auto bytes_to_copy = std::min(bytes.size(), GENERIC_OUTPUT_BYTE_COUNT);
const bool no_change = std::ranges::equal(bytes.first(bytes_to_copy),
std::span{m_generic_outputs}.first(bytes_to_copy));
if (no_change)
return;
decltype(m_generic_outputs) bits_set{};
decltype(m_generic_outputs) 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]);
bits_set[i] = u8(~m_generic_outputs[i]) & bytes[i];
bits_cleared[i] = m_generic_outputs[i] & u8(~bytes[i]);
m_generic_output_data[i] = bytes[i];
m_generic_outputs[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;
DEBUG_LOG_FMT(SERIALINTERFACE_JVSIO, "SetGenericOutputs: set:{:02x} clr:{:02x} ({:02x})",
fmt::join(bits_set, " "), fmt::join(bits_cleared, " "),
fmt::join(m_generic_outputs, " "));
for (auto& adapter : m_io_adapters)
{
@ -80,36 +107,109 @@ void IOPorts::SetGenericOutputs(std::span<const u8> bytes)
}
}
void IOPorts::ResetGenericOutputs()
{
SetGenericOutputs(std::array<u8, GENERIC_OUTPUT_BYTE_COUNT>{});
}
void IOAdapter::Update()
{
}
void IOAdapter::DoState(PointerWrap& p)
{
}
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)
void Common_IOAdapter::Update()
{
const u8 bits_changed_0 = bits_set[0] | bits_cleared[0];
auto* const io_ports = GetIOPorts();
const auto coin_inputs = io_ports->GetCoinInputs();
if (bits_changed_0 & ITEM_LIGHT_BIT)
// Test/Service input is also possible this way, but we handle them via JVS IO instead.
// const auto status_switches = io_ports->GetStatusSwitches();
// status_switches[0] &= ~0x80; // Test
// status_switches[0] &= ~0x40; // Service
for (int i = 0; i != IOPorts::PLAYER_COUNT; ++i)
{
INFO_LOG_FMT(SERIALINTERFACE_JVSIO, "JVS-IO: Item Button: {}",
(bits_set[0] & ITEM_LIGHT_BIT) ? "ON" : "OFF");
if (m_system.GetSerialInterface().GetDeviceType(i) != SerialInterface::SIDEVICE_AM_BASEBOARD)
continue;
const GCPadStatus pad_status = Pad::GetStatus(i);
// Test button
if (pad_status.switches & SWITCH_TEST)
{
if (AMMediaboard::GetTestMenu())
{
*io_ports->GetSystemInputs() |= 0x80u;
}
else
{
// Trying to access the test menu without SegaBoot present will cause a crash.
OSD::AddMessage("Test menu is disabled due to missing SegaBoot.", OSD::Duration::NORMAL,
OSD::Color::RED);
}
}
const auto switch_inputs = io_ports->GetSwitchInputs(i);
// Service button
if (pad_status.switches & SWITCH_SERVICE)
switch_inputs[0] |= 0x40;
// Coin button
if (pad_status.switches & SWITCH_COIN)
coin_inputs[i] = true;
}
}
if (bits_changed_0 & CANCEL_LIGHT_BIT)
void VirtuaStriker3_IOAdapter::Update()
{
auto* const io_ports = GetIOPorts();
for (int i = 0; i != IOPorts::PLAYER_COUNT; ++i)
{
INFO_LOG_FMT(SERIALINTERFACE_JVSIO, "JVS-IO: Cancel Button: {}",
(bits_set[0] & CANCEL_LIGHT_BIT) ? "ON" : "OFF");
const auto switch_inputs = io_ports->GetSwitchInputs(i);
const GCPadStatus pad_status = Pad::GetStatus(i);
// Start
if (pad_status.button & PAD_BUTTON_START)
switch_inputs[0] |= 0x80;
// Up
if (pad_status.button & PAD_BUTTON_UP)
switch_inputs[0] |= 0x20;
// Down
if (pad_status.button & PAD_BUTTON_DOWN)
switch_inputs[0] |= 0x10;
// Left
if (pad_status.button & PAD_BUTTON_LEFT)
switch_inputs[0] |= 0x08;
// Right
if (pad_status.button & PAD_BUTTON_RIGHT)
switch_inputs[0] |= 0x04;
// Long Pass
if (pad_status.button & PAD_BUTTON_X)
switch_inputs[0] |= 0x02;
// Shoot
if (pad_status.button & PAD_BUTTON_B)
switch_inputs[0] |= 0x01;
// Short Pass
if (pad_status.button & PAD_BUTTON_A)
switch_inputs[1] |= 0x80;
}
}
void VirtuaStriker4Common_IOAdapter::Update()
{
const auto generic_outputs = GetIOPorts()->GetGenericOutputs();
auto* const io_ports = GetIOPorts();
const auto generic_outputs = io_ports->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;
@ -119,30 +219,65 @@ void VirtuaStriker4Common_IOAdapter::Update()
if (is_slot_a_locked)
{
// If slot 1 is locked, insert slot 2.
if (m_card_reader_b->IsReadyToInsertCard())
m_card_reader_b->InsertCard();
// If slot A is locked, insert slot B.
if (m_card_readers[1]->IsReadyToInsertCard())
m_card_readers[1]->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 slot A is unlocked, remove slot B if unlocked.
if (m_card_readers[1]->IsCardPresent() && !is_slot_b_locked)
m_card_readers[1]->EjectCard();
}
if (m_card_reader_a->IsReadyToInsertCard())
m_card_reader_a->InsertCard();
// Insert slot A.
if (m_card_readers[0]->IsReadyToInsertCard())
m_card_readers[0]->InsertCard();
const auto p1_inputs = GetIOPorts()->GetSwitchInputs(0);
const auto p2_inputs = GetIOPorts()->GetSwitchInputs(1);
const auto analog_inputs = io_ports->GetAnalogInputs();
// 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());
for (int i = 0; i != IOPorts::PLAYER_COUNT; ++i)
{
const auto switch_inputs = io_ports->GetSwitchInputs(i);
// 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());
// Bit 0x10 of each player's 1st byte is a card presence switch.
Common::SetBit(switch_inputs[0], 4, m_card_readers[i]->IsCardPresent());
// Bit 0x20 of each player's 2nd byte is some kind of "eject" sensor.
Common::SetBit(switch_inputs[1], 5, m_card_readers[i]->IsEjecting());
const GCPadStatus pad_status = Pad::GetStatus(i);
// Start
if (pad_status.button & PAD_BUTTON_START)
switch_inputs[0] |= 0x80;
// Tactics (U)
if (pad_status.button & PAD_BUTTON_LEFT)
switch_inputs[0] |= 0x20;
// Tactics (M)
if (pad_status.button & PAD_BUTTON_UP)
switch_inputs[0] |= 0x08;
// Tactics (D)
if (pad_status.button & PAD_BUTTON_RIGHT)
switch_inputs[0] |= 0x04;
// Short Pass
if (pad_status.button & PAD_BUTTON_A)
switch_inputs[0] |= 0x02;
// Long Pass
if (pad_status.button & PAD_BUTTON_X)
switch_inputs[0] |= 0x01;
// Shoot
if (pad_status.button & PAD_BUTTON_B)
switch_inputs[1] |= 0x80;
// Dash
if (pad_status.button & PAD_BUTTON_Y)
switch_inputs[1] |= 0x40;
// Movement
analog_inputs[(2 * i) + 0] = Common::ExpandValue<u16>(0xff - pad_status.stickY, 8);
analog_inputs[(2 * i) + 1] = Common::ExpandValue<u16>(pad_status.stickX, 8);
}
}
void VirtuaStriker4Common_IOAdapter::HandleGenericOutputsChanged(std::span<const u8> bits_set,
@ -172,27 +307,55 @@ void VirtuaStriker4_2006_IOAdapter::HandleGenericOutputsChanged(std::span<const
// VirtuaStriker4_2006 triggers card ejection with JVS-IO.
if (bits_set[0] & SLOT_A_EJECT_BIT)
m_card_reader_a->EjectCard();
m_card_readers[0]->EjectCard();
if (bits_set[0] & SLOT_B_EJECT_BIT)
m_card_reader_b->EjectCard();
m_card_readers[1]->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})
auto* const io_ports = GetIOPorts();
for (int i = 0; i != IOPorts::PLAYER_COUNT; ++i)
{
if (card_reader->IsReadyToInsertCard())
card_reader->InsertCard();
// Gekitou isn't as picky as VS4. We can insert cards whenever.
if (m_card_readers[i]->IsReadyToInsertCard())
m_card_readers[i]->InsertCard();
const auto switch_inputs = io_ports->GetSwitchInputs(i);
// Bit 0x40 of each player's 2nd byte is a card presence switch.
Common::SetBit(switch_inputs[1], 6, m_card_readers[i]->IsCardPresent());
const GCPadStatus pad_status = Pad::GetStatus(i);
// Start
if (pad_status.button & PAD_BUTTON_START)
switch_inputs[0] |= 0x80;
// Up
if (pad_status.button & PAD_BUTTON_UP)
switch_inputs[0] |= 0x20;
// Down
if (pad_status.button & PAD_BUTTON_DOWN)
switch_inputs[0] |= 0x10;
// Left
if (pad_status.button & PAD_BUTTON_LEFT)
switch_inputs[0] |= 0x08;
// Right
if (pad_status.button & PAD_BUTTON_RIGHT)
switch_inputs[0] |= 0x04;
// B
if (pad_status.button & PAD_BUTTON_A)
switch_inputs[0] |= 0x02;
// A
if (pad_status.button & PAD_BUTTON_B)
switch_inputs[0] |= 0x01;
// Gekitou
if (pad_status.button & PAD_TRIGGER_L)
switch_inputs[1] |= 0x80;
}
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,
@ -205,10 +368,10 @@ void GekitouProYakyuu_IOAdapter::HandleGenericOutputsChanged(std::span<const u8>
// 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();
m_card_readers[0]->EjectCard();
if (bits_cleared[0] & SLOT_B_LOCK_BIT)
m_card_reader_b->EjectCard();
m_card_readers[1]->EjectCard();
}
void KeyOfAvalon_IOAdapter::Update()
@ -221,6 +384,19 @@ void KeyOfAvalon_IOAdapter::Update()
if (m_card_reader->IsReadyToInsertCard())
m_card_reader->InsertCard();
const auto switch_inputs = GetIOPorts()->GetSwitchInputs(0);
const GCPadStatus pad_status = Pad::GetStatus(0);
// Debug On
if (pad_status.button & PAD_BUTTON_START)
switch_inputs[0] |= 0x80;
// Switch 2
if (pad_status.button & PAD_BUTTON_B)
switch_inputs[0] |= 0x08;
// Switch 1
if (pad_status.button & PAD_BUTTON_A)
switch_inputs[0] |= 0x04;
}
} // namespace Triforce

View File

@ -8,11 +8,15 @@
#include <span>
#include <vector>
#include "Common/Assert.h"
#include "Common/CommonTypes.h"
class PointerWrap;
namespace Core
{
class System;
}
namespace Triforce
{
@ -21,47 +25,61 @@ 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);
std::span<u8> GetStatusSwitches() { return m_status_switches; }
std::span<const u8> GetStatusSwitches() const { return m_status_switches; }
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);
u8* GetSystemInputs() { return &m_system_inputs; }
const u8* GetSystemInputs() const { return &m_system_inputs; }
return std::span{m_switch_input_data}.subspan(SWITCH_INPUT_BYTES_PER_PLAYER * player_index,
SWITCH_INPUT_BYTES_PER_PLAYER);
}
std::span<u8> GetSwitchInputs(u32 player_index);
std::span<const u8> GetSwitchInputs(u32 player_index) const;
std::span<u8> GetGenericOutputs() { return m_generic_output_data; }
std::span<const u8> GetGenericOutputs() const { return m_generic_output_data; }
std::span<u16> GetAnalogInputs() { return m_analog_inputs; }
std::span<const u16> GetAnalogInputs() const { return m_analog_inputs; }
std::span<const u8> GetGenericOutputs() const { return m_generic_outputs; }
void SetGenericOutputs(std::span<const u8> bytes);
void ResetGenericOutputs();
static constexpr std::size_t PLAYER_COUNT = 2;
static constexpr std::size_t COIN_SLOT_COUNT = 2;
std::span<bool, COIN_SLOT_COUNT> GetCoinInputs() { return m_coin_inputs; }
std::span<const bool, COIN_SLOT_COUNT> GetCoinInputs() const { return m_coin_inputs; }
void AddIOAdapter(std::unique_ptr<IOAdapter> adapter);
void DoState(PointerWrap& p);
static constexpr u16 NEUTRAL_ANALOG_VALUE = 0x8000;
private:
std::vector<std::unique_ptr<IOAdapter>> m_io_adapters;
static constexpr std::size_t PLAYER_COUNT = 2;
std::array<u8, 2> m_status_switches{0xff, 0xff};
// The first byte in JVSIO switch input data.
u8 m_system_inputs = 0x00;
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{};
std::array<u8, PLAYER_COUNT * SWITCH_INPUT_BYTES_PER_PLAYER> m_switch_inputs{};
static constexpr std::size_t ANALOG_INPUT_COUNT = 8;
std::array<u16, ANALOG_INPUT_COUNT> m_analog_inputs{};
static constexpr std::size_t GENERIC_OUTPUT_BYTE_COUNT = 4;
std::array<u8, GENERIC_OUTPUT_BYTE_COUNT> m_generic_output_data{};
std::array<u8, GENERIC_OUTPUT_BYTE_COUNT> m_generic_outputs{};
std::array<bool, COIN_SLOT_COUNT> m_coin_inputs{};
};
class IOAdapter
@ -79,6 +97,8 @@ public:
virtual void Update();
virtual void DoState(PointerWrap& p);
protected:
IOPorts* GetIOPorts() { return m_io_ports; }
@ -92,16 +112,21 @@ private:
IOPorts* m_io_ports{};
};
// Used for both MarioKartGP and MarioKartGP2.
class MarioKartGPCommon_IOAdapter final : public IOAdapter
class Common_IOAdapter final : public IOAdapter
{
protected:
void HandleGenericOutputsChanged(std::span<const u8> bits_set,
std::span<const u8> bits_cleared) override;
public:
explicit Common_IOAdapter(Core::System& system) : m_system{system} {}
void Update() override;
private:
static constexpr u8 ITEM_LIGHT_BIT = 0x04;
static constexpr u8 CANCEL_LIGHT_BIT = 0x08;
Core::System& m_system;
};
class VirtuaStriker3_IOAdapter final : public IOAdapter
{
public:
void Update() override;
};
// Common functionality for both VirtuaStriker4 and VirtuaStriker4_2006.
@ -109,7 +134,7 @@ 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}
: m_card_readers{card_reader_a, card_reader_b}
{
}
@ -123,15 +148,14 @@ protected:
std::span<const u8> bits_cleared) override;
private:
ICCardReader* const m_card_reader_a;
ICCardReader* const m_card_reader_b;
std::array<ICCardReader*, IOPorts::PLAYER_COUNT> const m_card_readers;
};
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}
: m_card_readers{card_reader_a, card_reader_b}
{
}
@ -143,15 +167,14 @@ 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;
std::array<ICCardReader*, IOPorts::PLAYER_COUNT> const m_card_readers;
};
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}
: m_card_readers{card_reader_a, card_reader_b}
{
}
@ -165,8 +188,7 @@ 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;
std::array<ICCardReader*, IOPorts::PLAYER_COUNT> const m_card_readers;
};
class KeyOfAvalon_IOAdapter final : public IOAdapter

View File

@ -0,0 +1,138 @@
// Copyright 2026 Dolphin Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include "Core/HW/Triforce/MarioKartGP.h"
#include <fmt/ranges.h>
#include "Common/BitUtils.h"
#include "Common/ChunkFile.h"
#include "Common/Logging/Log.h"
#include "Common/Swap.h"
#include "Core/HW/GCPad.h"
#include "InputCommon/GCPadStatus.h"
namespace Triforce
{
void MarioKartGPCommon_IOAdapter::Update()
{
auto* const io_ports = GetIOPorts();
// TODO: Devices (e.g. Camera, Networking) can be disabled but it's not yet fully figured out.
constexpr u8 DISABLE_DEVICES = 0xff;
io_ports->GetStatusSwitches()[0] &= DISABLE_DEVICES;
const GCPadStatus pad_status = Pad::GetStatus(0);
const auto switch_inputs = io_ports->GetSwitchInputs(0);
// Start
if (pad_status.button & PAD_BUTTON_START)
switch_inputs[0] |= 0x80;
// Item button
if (pad_status.button & PAD_BUTTON_A)
switch_inputs[1] |= 0x20;
// VS-Cancel button
if (pad_status.button & PAD_BUTTON_B)
switch_inputs[1] |= 0x02;
const auto analog_inputs = io_ports->GetAnalogInputs();
// Steering
analog_inputs[0] = Common::ExpandValue<u16>(pad_status.stickX, 8);
// Gas
analog_inputs[1] = Common::ExpandValue<u16>(pad_status.triggerRight, 8);
// Brake
analog_inputs[2] = Common::ExpandValue<u16>(pad_status.triggerLeft, 8);
}
void MarioKartGPCommon_IOAdapter::HandleGenericOutputsChanged(std::span<const u8> bits_set,
std::span<const u8> bits_cleared)
{
if (bits_set[0] & 0x80u)
m_steering_wheel->Reset();
const u8 bits_changed_0 = bits_set[0] | bits_cleared[0];
constexpr auto LED_NAMES = std::to_array<std::pair<u8, const char*>>({
{0x04, "ITEM BUTTON"},
{0x08, "CANCEL BUTTON"},
});
for (const auto& [led_value, led_name] : LED_NAMES)
{
if (bits_changed_0 & led_value)
{
INFO_LOG_FMT(SERIALINTERFACE_JVSIO, "JVS-IO: {}: {}", led_name,
(bits_set[0] & led_value) ? "ON" : "OFF");
}
}
}
void MarioKartGPSteeringWheel::Update()
{
constexpr std::size_t REQUEST_SIZE = 10;
std::size_t rx_position = 0;
while (true)
{
const auto rx_span = GetRxByteSpan().subspan(rx_position);
if (rx_span.size() < REQUEST_SIZE)
break; // Wait for more data.
ProcessRequest(rx_span.first<REQUEST_SIZE>());
rx_position += REQUEST_SIZE;
}
ConsumeRxBytes(rx_position);
}
void MarioKartGPSteeringWheel::ProcessRequest(std::span<const u8> request)
{
DEBUG_LOG_FMT(SERIALINTERFACE_AMBB, "SteeringWheel: Request: {:02x}", fmt::join(request, " "));
const u8 cmd = request[3];
if (cmd != 0x01)
{
WARN_LOG_FMT(SERIALINTERFACE_AMBB, "SteeringWheel: Unknown command: {:02x}", cmd);
return;
}
const u16 centering_force = Common::swap16(request.data() + 4);
const u16 friction_force = Common::swap16(request.data() + 6);
const u16 roll = Common::swap16(request.data() + 8);
DEBUG_LOG_FMT(SERIALINTERFACE_AMBB, "SteeringWheel: FFB: {:04x} {:04x} {:04x}", centering_force,
friction_force, roll);
switch (m_init_state)
{
case 0:
WriteTxBytes(std::array<u8, 3>{'E', '0', '0'}); // Error
++m_init_state;
break;
case 1:
WriteTxBytes(std::array<u8, 3>{'C', '0', '6'}); // Power Off
++m_init_state;
break;
default:
WriteTxBytes(std::array<u8, 3>{'C', '0', '1'}); // Power On
break;
}
}
void MarioKartGPSteeringWheel::Reset()
{
INFO_LOG_FMT(SERIALINTERFACE_AMBB, "SteeringWheel: Reset");
m_init_state = 0;
}
void MarioKartGPSteeringWheel::DoState(PointerWrap& p)
{
p.Do(m_init_state);
}
} // namespace Triforce

View File

@ -0,0 +1,47 @@
// Copyright 2026 Dolphin Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include "Core/HW/Triforce/IOPorts.h"
#include "Core/HW/Triforce/SerialDevice.h"
namespace Triforce
{
// FFB wheel used by MarioKartGP and MarioKartGP2.
class MarioKartGPSteeringWheel final : public SerialDevice
{
public:
void Update() override;
void Reset();
void DoState(PointerWrap&) override;
private:
void ProcessRequest(std::span<const u8>);
u8 m_init_state = 0;
};
// Used for both MarioKartGP and MarioKartGP2.
class MarioKartGPCommon_IOAdapter final : public IOAdapter
{
public:
explicit MarioKartGPCommon_IOAdapter(MarioKartGPSteeringWheel* steering_wheel)
: m_steering_wheel{steering_wheel}
{
}
void Update() override;
protected:
void HandleGenericOutputsChanged(std::span<const u8> bits_set,
std::span<const u8> bits_cleared) override;
private:
MarioKartGPSteeringWheel* const m_steering_wheel;
};
} // namespace Triforce