From 5c912e881ee11422e9cd063ce4c4b8aa2c952f33 Mon Sep 17 00:00:00 2001 From: Jordan Woyak Date: Mon, 23 Mar 2026 00:51:13 -0500 Subject: [PATCH] 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. --- Source/Core/Core/HW/Triforce/FZeroAX.cpp | 275 ++++++++++++++++ Source/Core/Core/HW/Triforce/FZeroAX.h | 70 ++++ Source/Core/Core/HW/Triforce/IOPorts.cpp | 316 +++++++++++++++---- Source/Core/Core/HW/Triforce/IOPorts.h | 92 ++++-- Source/Core/Core/HW/Triforce/MarioKartGP.cpp | 138 ++++++++ Source/Core/Core/HW/Triforce/MarioKartGP.h | 47 +++ 6 files changed, 833 insertions(+), 105 deletions(-) create mode 100644 Source/Core/Core/HW/Triforce/FZeroAX.cpp create mode 100644 Source/Core/Core/HW/Triforce/FZeroAX.h create mode 100644 Source/Core/Core/HW/Triforce/MarioKartGP.cpp create mode 100644 Source/Core/Core/HW/Triforce/MarioKartGP.h diff --git a/Source/Core/Core/HW/Triforce/FZeroAX.cpp b/Source/Core/Core/HW/Triforce/FZeroAX.cpp new file mode 100644 index 0000000000..dde106882c --- /dev/null +++ b/Source/Core/Core/HW/Triforce/FZeroAX.cpp @@ -0,0 +1,275 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "Core/HW/Triforce/FZeroAX.h" + +#include + +#include + +#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(pad_status.stickX, 8); + } + + analog_inputs[1] = Common::ExpandValue(pad_status.stickY, 8); + + // Gas + analog_inputs[4] = Common::ExpandValue(pad_status.triggerRight, 8); + // Brake + analog_inputs[5] = Common::ExpandValue(pad_status.triggerLeft, 8); + // Seat Motion + analog_inputs[6] = IOPorts::NEUTRAL_ANALOG_VALUE; +} + +void FZeroAXCommon_IOAdapter::HandleGenericOutputsChanged(std::span bits_set, + std::span bits_cleared) +{ + const u8 bits_changed_0 = bits_set[0] | bits_cleared[0]; + + constexpr auto LED_NAMES = std::to_array>({ + {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(); + + // 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 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{}); +} + +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 diff --git a/Source/Core/Core/HW/Triforce/FZeroAX.h b/Source/Core/Core/HW/Triforce/FZeroAX.h new file mode 100644 index 0000000000..18688336e2 --- /dev/null +++ b/Source/Core/Core/HW/Triforce/FZeroAX.h @@ -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); + + 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 bits_set, + std::span 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 diff --git a/Source/Core/Core/HW/Triforce/IOPorts.cpp b/Source/Core/Core/HW/Triforce/IOPorts.cpp index 2a600f94cd..a393987d0f 100644 --- a/Source/Core/Core/HW/Triforce/IOPorts.cpp +++ b/Source/Core/Core/HW/Triforce/IOPorts.cpp @@ -3,27 +3,63 @@ #include "Core/HW/Triforce/IOPorts.h" -#include +#include #include +#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 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 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 adapter) @@ -37,42 +73,33 @@ IOAdapter::~IOAdapter() = default; void IOPorts::SetGenericOutputs(std::span 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 bytes) } } +void IOPorts::ResetGenericOutputs() +{ + SetGenericOutputs(std::array{}); +} + void IOAdapter::Update() { } +void IOAdapter::DoState(PointerWrap& p) +{ +} + void IOAdapter::HandleGenericOutputsChanged(std::span bits_set, std::span bits_cleared) { } -void MarioKartGPCommon_IOAdapter::HandleGenericOutputsChanged(std::span bits_set, - std::span 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(0xff - pad_status.stickY, 8); + analog_inputs[(2 * i) + 1] = Common::ExpandValue(pad_status.stickX, 8); + } } void VirtuaStriker4Common_IOAdapter::HandleGenericOutputsChanged(std::span bits_set, @@ -172,27 +307,55 @@ void VirtuaStriker4_2006_IOAdapter::HandleGenericOutputsChanged(std::spanEjectCard(); + 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 bits_set, @@ -205,10 +368,10 @@ void GekitouProYakyuu_IOAdapter::HandleGenericOutputsChanged(std::span // 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 diff --git a/Source/Core/Core/HW/Triforce/IOPorts.h b/Source/Core/Core/HW/Triforce/IOPorts.h index a5379c3a50..7fa3f7ae8c 100644 --- a/Source/Core/Core/HW/Triforce/IOPorts.h +++ b/Source/Core/Core/HW/Triforce/IOPorts.h @@ -8,11 +8,15 @@ #include #include -#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 GetSwitchInputs(u32 player_index) - { - ASSERT(player_index < PLAYER_COUNT); + std::span GetStatusSwitches() { return m_status_switches; } + std::span 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 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 GetSwitchInputs(u32 player_index); + std::span GetSwitchInputs(u32 player_index) const; - std::span GetGenericOutputs() { return m_generic_output_data; } - std::span GetGenericOutputs() const { return m_generic_output_data; } + std::span GetAnalogInputs() { return m_analog_inputs; } + std::span GetAnalogInputs() const { return m_analog_inputs; } + + std::span GetGenericOutputs() const { return m_generic_outputs; } void SetGenericOutputs(std::span bytes); + void ResetGenericOutputs(); + + static constexpr std::size_t PLAYER_COUNT = 2; + static constexpr std::size_t COIN_SLOT_COUNT = 2; + + std::span GetCoinInputs() { return m_coin_inputs; } + std::span GetCoinInputs() const { return m_coin_inputs; } void AddIOAdapter(std::unique_ptr adapter); void DoState(PointerWrap& p); + static constexpr u16 NEUTRAL_ANALOG_VALUE = 0x8000; + private: std::vector> m_io_adapters; - static constexpr std::size_t PLAYER_COUNT = 2; + std::array 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 m_switch_input_data{}; + std::array m_switch_inputs{}; + + static constexpr std::size_t ANALOG_INPUT_COUNT = 8; + + std::array m_analog_inputs{}; static constexpr std::size_t GENERIC_OUTPUT_BYTE_COUNT = 4; - std::array m_generic_output_data{}; + std::array m_generic_outputs{}; + + std::array 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 bits_set, - std::span 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 bits_cleared) override; private: - ICCardReader* const m_card_reader_a; - ICCardReader* const m_card_reader_b; + std::array 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 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 const m_card_readers; }; class KeyOfAvalon_IOAdapter final : public IOAdapter diff --git a/Source/Core/Core/HW/Triforce/MarioKartGP.cpp b/Source/Core/Core/HW/Triforce/MarioKartGP.cpp new file mode 100644 index 0000000000..63203b4403 --- /dev/null +++ b/Source/Core/Core/HW/Triforce/MarioKartGP.cpp @@ -0,0 +1,138 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "Core/HW/Triforce/MarioKartGP.h" + +#include + +#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(pad_status.stickX, 8); + // Gas + analog_inputs[1] = Common::ExpandValue(pad_status.triggerRight, 8); + // Brake + analog_inputs[2] = Common::ExpandValue(pad_status.triggerLeft, 8); +} + +void MarioKartGPCommon_IOAdapter::HandleGenericOutputsChanged(std::span bits_set, + std::span 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>({ + {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()); + rx_position += REQUEST_SIZE; + } + + ConsumeRxBytes(rx_position); +} + +void MarioKartGPSteeringWheel::ProcessRequest(std::span 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{'E', '0', '0'}); // Error + ++m_init_state; + break; + case 1: + WriteTxBytes(std::array{'C', '0', '6'}); // Power Off + ++m_init_state; + break; + default: + WriteTxBytes(std::array{'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 diff --git a/Source/Core/Core/HW/Triforce/MarioKartGP.h b/Source/Core/Core/HW/Triforce/MarioKartGP.h new file mode 100644 index 0000000000..163bfbb61b --- /dev/null +++ b/Source/Core/Core/HW/Triforce/MarioKartGP.h @@ -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); + + 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 bits_set, + std::span bits_cleared) override; + +private: + MarioKartGPSteeringWheel* const m_steering_wheel; +}; + +} // namespace Triforce