diff --git a/rpcs3/Emu/CMakeLists.txt b/rpcs3/Emu/CMakeLists.txt index 080789e07b..7e728bc6bc 100644 --- a/rpcs3/Emu/CMakeLists.txt +++ b/rpcs3/Emu/CMakeLists.txt @@ -414,6 +414,8 @@ target_sources(rpcs3_emu PRIVATE Io/midi_config_types.cpp Io/RB3MidiKeyboard.cpp Io/RB3MidiGuitar.cpp + Io/RB3MidiDrums.cpp + Io/rb3drums_config.cpp Io/Buzz.cpp Io/Turntable.cpp Io/usio.cpp diff --git a/rpcs3/Emu/Cell/lv2/sys_usbd.cpp b/rpcs3/Emu/Cell/lv2/sys_usbd.cpp index ce33ac77da..05ed617900 100644 --- a/rpcs3/Emu/Cell/lv2/sys_usbd.cpp +++ b/rpcs3/Emu/Cell/lv2/sys_usbd.cpp @@ -25,6 +25,8 @@ #include "Emu/Io/turntable_config.h" #include "Emu/Io/RB3MidiKeyboard.h" #include "Emu/Io/RB3MidiGuitar.h" +#include "Emu/Io/RB3MidiDrums.h" +#include "Emu/Io/rb3drums_config.h" #include "Emu/Io/usio.h" #include "Emu/Io/usio_config.h" #include "Emu/Io/midi_config_types.h" @@ -236,6 +238,7 @@ usb_handler_thread::usb_handler_thread() bool found_skylander = false; bool found_infinity = false; bool found_usj = false; + bool found_rb3drums = false; for (ssize_t index = 0; index < ndev; index++) { @@ -403,6 +406,18 @@ usb_handler_thread::usb_handler_thread() case midi_device_type::keyboard: usb_devices.push_back(std::make_shared(get_new_location(), device.name)); break; + case midi_device_type::drums: + found_rb3drums = true; + usb_devices.push_back(std::make_shared(get_new_location(), device.name)); + break; + } + } + + if (found_rb3drums) + { + if (!g_cfg_rb3drums.load()) + { + sys_usbd.notice("Could not load rb3drums config. Using defaults."); } } diff --git a/rpcs3/Emu/Io/RB3MidiDrums.cpp b/rpcs3/Emu/Io/RB3MidiDrums.cpp new file mode 100644 index 0000000000..f4409f4ef0 --- /dev/null +++ b/rpcs3/Emu/Io/RB3MidiDrums.cpp @@ -0,0 +1,892 @@ +// Rock Band 3 MIDI Pro Adapter Emulator (drums Mode) + +#include "stdafx.h" +#include "RB3MidiDrums.h" +#include "Emu/Cell/lv2/sys_usbd.h" +#include "Emu/Io/rb3drums_config.h" + +using namespace std::chrono_literals; + +LOG_CHANNEL(rb3_midi_drums_log); + +namespace +{ + +namespace controller +{ + +// Bit flags by byte index. + +constexpr usz FLAG = 0; +constexpr usz INDEX = 1; + +using FlagByIndex = std::array; + +constexpr FlagByIndex BUTTON_1 = {0x01, 0}; +constexpr FlagByIndex BUTTON_2 = {0x02, 0}; +constexpr FlagByIndex BUTTON_3 = {0x04, 0}; +constexpr FlagByIndex BUTTON_4 = {0x08, 0}; +constexpr FlagByIndex BUTTON_5 = {0x10, 0}; +constexpr FlagByIndex BUTTON_6 = {0x20, 0}; +constexpr FlagByIndex BUTTON_7 = {0x40, 0}; +constexpr FlagByIndex BUTTON_8 = {0x80, 0}; + +constexpr FlagByIndex BUTTON_9 = {0x01, 1}; +constexpr FlagByIndex BUTTON_10 = {0x02, 1}; +constexpr FlagByIndex BUTTON_11 = {0x04, 1}; +constexpr FlagByIndex BUTTON_12 = {0x08, 1}; +constexpr FlagByIndex BUTTON_13 = {0x10, 1}; + +constexpr usz DPAD_INDEX = 2; +enum class DPad : u8 +{ + Up = 0x00, + Right = 0x02, + Down = 0x04, + Left = 0x06, + Center = 0x08, +}; + +constexpr u8 AXIS_CENTER = 0x7F; + +constexpr std::array default_state = { + 0x00, // buttons 1 to 8 + 0x00, // buttons 9 to 13 + static_cast(controller::DPad::Center), + controller::AXIS_CENTER, // x axis + controller::AXIS_CENTER, // y axis + controller::AXIS_CENTER, // z axis + controller::AXIS_CENTER, // w axis + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, // yellow drum/cymbal velocity + 0x00, // red drum/cymbal velocity + 0x00, // green drum/cymbal velocity + 0x00, // blue drum/cymbal velocity + 0x00, + 0x00, + 0x00, + 0x00, + 0x02, + 0x00, + 0x02, + 0x00, + 0x02, + 0x00, + 0x02, + 0x00 +}; + +} // namespace controller + +namespace drum +{ + +// Hold each hit for a period of time. Rock band doesn't pick up a single tick. +std::chrono::milliseconds hit_duration() +{ + return std::chrono::milliseconds(g_cfg_rb3drums.pulse_ms); +} + +// Scale velocity from midi to what rock band expects. +u8 scale_velocity(u8 value) +{ + return (0xFF - (2 * value)); +} + +constexpr usz FLAG = controller::FLAG; +constexpr usz INDEX = controller::INDEX; + +using FlagByIndex = controller::FlagByIndex; + +constexpr FlagByIndex GREEN = controller::BUTTON_2; +constexpr FlagByIndex RED = controller::BUTTON_3; +constexpr FlagByIndex YELLOW = controller::BUTTON_4; +constexpr FlagByIndex BLUE = controller::BUTTON_1; +constexpr FlagByIndex KICK_PEDAL = controller::BUTTON_5; +constexpr FlagByIndex HIHAT_PEDAL = controller::BUTTON_6; + +constexpr FlagByIndex IS_DRUM = controller::BUTTON_11; +constexpr FlagByIndex IS_CYMBAL = controller::BUTTON_12; + +constexpr FlagByIndex BACK_BUTTON = controller::BUTTON_3; +constexpr FlagByIndex START_BUTTON = controller::BUTTON_10; +constexpr FlagByIndex SYSTEM_BUTTON = controller::BUTTON_13; +constexpr FlagByIndex SELECT_BUTTON = controller::BUTTON_9; + +rb3drums::KitState start_state() +{ + rb3drums::KitState s{}; + s.expiry = std::chrono::steady_clock::now() + drum::hit_duration(); + s.start = true; + return s; +} + +rb3drums::KitState select_state() +{ + rb3drums::KitState s{}; + s.expiry = std::chrono::steady_clock::now() + drum::hit_duration(); + s.select = true; + return s; +} + +rb3drums::KitState toggle_hold_kick_state() +{ + rb3drums::KitState s{}; + s.expiry = std::chrono::steady_clock::now() + drum::hit_duration(); + s.toggle_hold_kick = true; + return s; +} + +rb3drums::KitState kick_state() +{ + rb3drums::KitState s{}; + s.expiry = std::chrono::steady_clock::now() + drum::hit_duration(); + s.kick_pedal = 127; + return s; +} + +} // namespace drum + +namespace midi +{ + +u8 min_velocity() +{ + return g_cfg_rb3drums.minimum_velocity; +} + +enum class Id : u8 +{ + // Each 'Note' can be triggered by multiple different numbers. + // Keeping them flattened in an enum for simplicity / switch statement usage. + + // These follow the rockband 3 midi pro adapter support. + Snare0 = 38, + Snare1 = 31, + Snare2 = 34, + Snare3 = 37, + Snare4 = 39, + HiTom0 = 48, + HiTom1 = 50, + LowTom0 = 45, + LowTom1 = 47, + FloorTom0 = 41, + FloorTom1 = 43, + Hihat0 = 22, + Hihat1 = 26, + Hihat2 = 42, + Hihat3 = 54, + Ride0 = 51, + Ride1 = 53, + Ride2 = 56, + Ride3 = 59, + Crash0 = 49, + Crash1 = 52, + Crash2 = 55, + Crash3 = 57, + Kick0 = 33, + Kick1 = 35, + Kick2 = 36, + HihatPedal = 44, + + // These are from alesis nitro mesh max. ymmv. + SnareRim = 40, // midi pro adapter counts this as snare. + HihatWithPedalUp = 46, // The midi pro adapter considers this a normal hihat hit. + HihatPedalPartial = 23, // If pedal is not 100% down, this will be sent instead of a normal hihat hit. + + // Internal value used for converting midi CC. + // Values past 127 are not used in midi notes. + MidiCC = 255, +}; + +// Intermediate mapping regardless of which midi ids triggered it. +enum class Note : u8 +{ + Invalid, + Kick, + HihatPedal, + Snare, + SnareRim, + HiTom, + LowTom, + FloorTom, + HihatWithPedalUp, + Hihat, + Ride, + Crash, +}; + +Note str_to_note(const std::string_view name) +{ + static const std::unordered_map mapping{ + {"Invalid", Note::Invalid}, + {"Kick", Note::Kick}, + {"HihatPedal", Note::HihatPedal}, + {"Snare", Note::Snare}, + {"SnareRim", Note::SnareRim}, + {"HiTom", Note::HiTom}, + {"LowTom", Note::LowTom}, + {"FloorTom", Note::FloorTom}, + {"HihatWithPedalUp", Note::HihatWithPedalUp}, + {"Hihat", Note::Hihat}, + {"Ride", Note::Ride}, + {"Crash", Note::Crash}, + }; + auto it = mapping.find(name); + return it != std::end(mapping) ? it->second : Note::Invalid; +} + +std::optional> parse_midi_override(const std::string_view config) +{ + auto split = fmt::split(config, {"="}); + if (split.size() != 2) + { + return {}; + } + uint64_t id_int = 0; + if (!try_to_uint64(&id_int, split[0], 0, 255)) + { + rb3_midi_drums_log.warning("midi override: %s is not a valid midi id", split[0]); + return {}; + } + auto id = static_cast(id_int); + auto note = str_to_note(split[1]); + if (note == Note::Invalid) + { + rb3_midi_drums_log.warning("midi override: %s is not a valid note", split[1]); + return {}; + } + rb3_midi_drums_log.success("found valid midi override: %s", config); + return {{id, note}}; +} + +std::unordered_map create_id_to_note_mapping() +{ + std::unordered_map mapping{ + {Id::MidiCC, Note::Kick}, + {Id::Kick0, Note::Kick}, + {Id::Kick1, Note::Kick}, + {Id::Kick2, Note::Kick}, + {Id::HihatPedal, Note::HihatPedal}, + {Id::HihatPedalPartial, Note::HihatPedal}, + {Id::Snare0, Note::Snare}, + {Id::Snare1, Note::Snare}, + {Id::Snare2, Note::Snare}, + {Id::Snare3, Note::Snare}, + {Id::Snare4, Note::Snare}, + {Id::SnareRim, Note::SnareRim}, + {Id::HiTom0, Note::HiTom}, + {Id::HiTom1, Note::HiTom}, + {Id::LowTom0, Note::LowTom}, + {Id::LowTom1, Note::LowTom}, + {Id::FloorTom0, Note::FloorTom}, + {Id::FloorTom1, Note::FloorTom}, + {Id::Hihat0, Note::Hihat}, + {Id::Hihat1, Note::Hihat}, + {Id::Hihat2, Note::Hihat}, + {Id::Hihat3, Note::Hihat}, + {Id::HihatWithPedalUp, Note::Hihat}, + {Id::Ride0, Note::Ride}, + {Id::Ride1, Note::Ride}, + {Id::Ride2, Note::Ride}, + {Id::Ride3, Note::Ride}, + {Id::Crash0, Note::Crash}, + {Id::Crash1, Note::Crash}, + {Id::Crash2, Note::Crash}, + {Id::Crash3, Note::Crash}, + }; + // Apply configured overrides. + auto split = fmt::split(g_cfg_rb3drums.midi_overrides.to_string(), {","}); + for (const auto& segment : split) + { + if (auto midi_override = parse_midi_override(segment)) + { + auto id = midi_override->first; + auto note = midi_override->second; + mapping[id] = note; + } + } + return mapping; +} + +Note id_to_note(Id id) +{ + static auto mapping = create_id_to_note_mapping(); + auto it = mapping.find(id); + return it != std::end(mapping) ? it->second : Note::Invalid; +} + +namespace combo +{ + +std::vector parse_combo(const std::string_view name, const std::string_view csv) +{ + if (csv.empty()) + { + return {}; + } + std::vector notes; + const auto& note_names = fmt::split(csv, {","}); + for (const auto& note_name : note_names) + { + const auto note = str_to_note(note_name); + if (note != midi::Note::Invalid) + { + notes.push_back(static_cast(note)); + } + else + { + rb3_midi_drums_log.warning("invalid note '%s' in configured combo '%s'", note_name, name); + } + } + return notes; +} + +struct Definition +{ + std::string name; + std::vector notes; + std::function create_state; + + Definition(std::string name, const std::string_view csv, const std::function create_state) + : name{std::move(name)} + , notes{parse_combo(this->name, csv)} + , create_state{create_state} + {} +}; + +std::chrono::milliseconds window() +{ + return std::chrono::milliseconds{g_cfg_rb3drums.combo_window_ms}; +} + +const std::vector& definitions() +{ + // Only parse once and cache. + static const std::vector defs{ + {"start", g_cfg_rb3drums.combo_start.to_string(), []{ return drum::start_state(); }}, + {"select", g_cfg_rb3drums.combo_select.to_string(), []{ return drum::select_state(); }}, + {"hold kick", g_cfg_rb3drums.combo_toggle_hold_kick.to_string(), []{ return drum::toggle_hold_kick_state(); }} + }; + return defs; +} + +} + +} // namespace midi + +void set_flag(u8* buf, [[maybe_unused]] std::string_view name, const controller::FlagByIndex& fbi) +{ + auto i = fbi[drum::INDEX]; + auto flag = fbi[drum::FLAG]; + buf[i] |= flag; + // rb3_midi_drums_log.success("wrote flag %x at index %d", flag, i); +} + +void set_flag_if_any(u8* buf, std::string_view name, const controller::FlagByIndex& fbi, const std::vector velocities) +{ + if (std::none_of(velocities.begin(), velocities.end(), [](u8 velocity){ return velocity >= midi::min_velocity(); })) + { + return; + } + set_flag(buf, name, fbi); +} + +} + +usb_device_rb3_midi_drums::usb_device_rb3_midi_drums(const std::array& location, const std::string& device_name) + : usb_device_emulated(location) +{ + UsbDeviceDescriptor descriptor{}; + descriptor.bcdDevice = 0x0200; + descriptor.bDeviceClass = 0x00; + descriptor.bDeviceSubClass = 0x00; + descriptor.bDeviceProtocol = 0x00; + descriptor.bMaxPacketSize0 = 64; + descriptor.idVendor = 0x12BA; // Harmonix + descriptor.idProduct = 0x0210; // Drums + descriptor.bcdDevice = 0x01; + descriptor.iManufacturer = 0x01; + descriptor.iProduct = 0x02; + descriptor.iSerialNumber = 0x00; + descriptor.bNumConfigurations = 0x01; + device = UsbDescriptorNode(USB_DESCRIPTOR_DEVICE, descriptor); + + auto& config0 = device.add_node(UsbDescriptorNode(USB_DESCRIPTOR_CONFIG, UsbDeviceConfiguration{41, 1, 1, 0, 0x80, 32})); + config0.add_node(UsbDescriptorNode(USB_DESCRIPTOR_INTERFACE, UsbDeviceInterface{0, 0, 2, 3, 0, 0, 0})); + config0.add_node(UsbDescriptorNode(USB_DESCRIPTOR_HID, UsbDeviceHID{0x0111, 0x00, 0x01, 0x22, 137})); + config0.add_node(UsbDescriptorNode(USB_DESCRIPTOR_ENDPOINT, UsbDeviceEndpoint{0x81, 0x03, 0x0040, 10})); + config0.add_node(UsbDescriptorNode(USB_DESCRIPTOR_ENDPOINT, UsbDeviceEndpoint{0x02, 0x03, 0x0040, 10})); + + usb_device_emulated::add_string("Licensed by Sony Computer Entertainment America"); + usb_device_emulated::add_string("Harmonix RB3 MIDI Drums Interface for PlayStation®3"); + + // connect to midi device + midi_in = rtmidi_in_create_default(); + ensure(midi_in); + + if (!midi_in->ok) + { + rb3_midi_drums_log.error("Could not get MIDI in ptr: %s", midi_in->msg); + return; + } + + const RtMidiApi api = rtmidi_in_get_current_api(midi_in); + + if (!midi_in->ok) + { + rb3_midi_drums_log.error("Could not get MIDI api: %s", midi_in->msg); + return; + } + + if (const char* api_name = rtmidi_api_name(api)) + { + rb3_midi_drums_log.notice("Using %s api", api_name); + } + else + { + rb3_midi_drums_log.warning("Could not get MIDI api name"); + } + + rtmidi_in_ignore_types(midi_in, false, true, true); + + const u32 port_count = rtmidi_get_port_count(midi_in); + + if (!midi_in->ok || port_count == umax) + { + rb3_midi_drums_log.error("Could not get MIDI port count: %s", midi_in->msg); + return; + } + + for (u32 port_number = 0; port_number < port_count; port_number++) + { + char buf[128]{}; + s32 size = sizeof(buf); + if (rtmidi_get_port_name(midi_in, port_number, buf, &size) == -1 || !midi_in->ok) + { + rb3_midi_drums_log.error("Error getting port name for port %d: %s", port_number, midi_in->msg); + return; + } + + rb3_midi_drums_log.notice("Found device with name: %s", buf); + + if (device_name == buf) + { + rtmidi_open_port(midi_in, port_number, "RPCS3 MIDI Drums Input"); + + if (!midi_in->ok) + { + rb3_midi_drums_log.error("Could not open port %d for device '%s': %s", port_number, device_name, midi_in->msg); + return; + } + + rb3_midi_drums_log.success("Connected to device: %s", device_name); + return; + } + } + + rb3_midi_drums_log.error("Could not find device with name: %s", device_name); +} + +usb_device_rb3_midi_drums::~usb_device_rb3_midi_drums() +{ + rtmidi_in_free(midi_in); +} + +static const std::array disabled_response = { + 0xe9, 0x00, 0x00, 0x00, 0x00, 0x02, 0x0f, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x82, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x21, 0x26, 0x02, 0x06, 0x00, 0x00, 0x00, 0x00}; + +static const std::array enabled_response = { + 0xe9, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x8a, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x21, 0x26, 0x02, 0x06, 0x00, 0x00, 0x00, 0x00}; + +void usb_device_rb3_midi_drums::control_transfer(u8 bmRequestType, u8 bRequest, u16 wValue, u16 wIndex, u16 wLength, u32 buf_size, u8* buf, UsbTransfer* transfer) +{ + transfer->fake = true; + + // configuration packets sent by rock band 3 + // we only really need to check 1 byte here to figure out if the game + // wants to enable midi data or disable it + if (bmRequestType == 0x21 && bRequest == 0x9 && wLength == 40) + { + if (buf_size < 3) + { + rb3_midi_drums_log.warning("buffer size < 3, bailing out early (buf_size=0x%x)", buf_size); + return; + } + + switch (buf[2]) + { + case 0x89: + rb3_midi_drums_log.notice("MIDI data enabled."); + buttons_enabled = true; + response_pos = 0; + break; + case 0x81: + rb3_midi_drums_log.notice("MIDI data disabled."); + buttons_enabled = false; + response_pos = 0; + break; + default: + rb3_midi_drums_log.warning("Unhandled SET_REPORT request: 0x%02X"); + break; + } + } + + // the game expects some sort of response to the configuration packet + else if (bmRequestType == 0xa1 && bRequest == 0x1) + { + //rb3_midi_drums_log.success("[control_transfer] config 0xa1 0x1 length %d", wLength); + + transfer->expected_count = buf_size; + if (buttons_enabled) + { + const usz remaining_bytes = enabled_response.size() - response_pos; + const usz copied_bytes = std::min(remaining_bytes, buf_size); + memcpy(buf, &enabled_response[response_pos], copied_bytes); + response_pos += copied_bytes; + } + else + { + const usz remaining_bytes = disabled_response.size() - response_pos; + const usz copied_bytes = std::min(remaining_bytes, buf_size); + memcpy(buf, &disabled_response[response_pos], copied_bytes); + response_pos += copied_bytes; + } + } + //else if (bmRequestType == 0x00 && bRequest == 0x9) + //{ + // // idk what this is but if I handle it we don't get input. + // rb3_midi_drums_log.success("handled -- request %x, type %x, length %x", bmRequestType, bRequest, wLength); + //} + //else if (bmRequestType == 0x80 && bRequest == 0x6) + //{ + // // idk what this is but if I handle it we don't get input. + // rb3_midi_drums_log.success("handled -- request %x, type %x, length %x", bmRequestType, bRequest, wLength); + //} + else if (bmRequestType == 0x21 && bRequest == 0x9 && wLength == 8) + { + // the game uses this request to do things like set the LEDs + // we don't have any LEDs, so do nothing + } + else + { + rb3_midi_drums_log.error("unhandled control_transfer: request %x, type %x, length %x", bmRequestType, bRequest, wLength); + usb_device_emulated::control_transfer(bmRequestType, bRequest, wValue, wIndex, wLength, buf_size, buf, transfer); + } +} + +void usb_device_rb3_midi_drums::interrupt_transfer(u32 buf_size, u8* buf, u32 /*endpoint*/, UsbTransfer* transfer) +{ + transfer->fake = true; + transfer->expected_count = buf_size; + transfer->expected_result = HC_CC_NOERR; + // the real device takes 8ms to send a response, but there is + // no reason we can't make it faster + transfer->expected_time = get_timestamp() + 1'000; + + const auto& bytes = controller::default_state; + if (buf_size < bytes.size()) + { + rb3_midi_drums_log.warning("buffer size < %x, bailing out early (buf_size=0x%x)", bytes.size(), buf_size); + return; + } + memcpy(buf, bytes.data(), bytes.size()); + + while (true) + { + u8 midi_msg[32]; + usz size = sizeof(midi_msg); + + // This returns a double as some sort of delta time, with -1.0 + // being used to signal an error. + if (rtmidi_in_get_message(midi_in, midi_msg, &size) == -1.0) + { + rb3_midi_drums_log.error("Error getting MIDI message: %s", midi_in->msg); + return; + } + + if (size == 0) + { + break; + } + + auto kit_state = parse_midi_message(midi_msg, size); + if (auto combo_state = combo.take_state()) + { + if (combo_state->toggle_hold_kick) + { + hold_kick = !hold_kick; + } + else + { + kit_states.push_back(std::move(combo_state.value())); + } + } + else + { + bool is_cancel = kit_state.snare >= midi::min_velocity(); + bool is_accept = kit_state.floor_tom >= midi::min_velocity(); + if (hold_kick && (is_cancel || is_accept)) + { + // Hold kick brings up the song category selector menu, which can be dismissed using accept/cancel buttons. + hold_kick = false; + } + else + { + kit_states.push_back(std::move(kit_state)); + } + } + } + + // Clean expired states. + auto now = std::chrono::steady_clock::now(); + kit_states.erase(std::remove_if(std::begin(kit_states), std::end(kit_states), [&now](const rb3drums::KitState& kit_state) { + return now >= kit_state.expiry; + }), std::end(kit_states)); + + bool cymbal_hit = false; + usz i = 0; + for (; i < kit_states.size(); ++i) + { + const auto& kit_state = kit_states[i]; + + // Rockband sometimes has trouble registering both hits when two cymbals are hit at once. + // To solve for this, we stagger cymbal hits so that they occur one after another instead of at the same time. + // Note that this is staggering by pulse_ms (30ms default) so a human is unlikely to notice it in practice. + if (g_cfg_rb3drums.stagger_cymbals && cymbal_hit && kit_state.is_cymbal()) + { + // Already have a cymbal applied, buffer other inputs. + break; + } + cymbal_hit = kit_state.is_cymbal(); + write_state(buf, kit_state); + } + + if (hold_kick) + { + write_state(buf, drum::kick_state()); + } + + // Extend expiry on buffered states since they are not active. + for (; i < kit_states.size(); ++i) + { + kit_states[i].expiry = now + drum::hit_duration(); + } +} + +rb3drums::KitState usb_device_rb3_midi_drums::parse_midi_message(u8* msg, usz size) +{ + if (size < 3) + { + rb3_midi_drums_log.warning("parse_midi_message: encountered message with size less than 3 bytes"); + return rb3drums::KitState{}; + } + + auto status = msg[0]; + auto id = msg[1]; + auto value = msg[2]; + + if (status == 0x99) + { + return parse_midi_note(id, value); + } + if (status == g_cfg_rb3drums.midi_cc_status) + { + if (is_midi_cc(id, value)) + { + return parse_midi_note(static_cast(midi::Id::MidiCC), 127); + } + } + // Ignore non-"note on" midi status messages. + return rb3drums::KitState{}; +} + +rb3drums::KitState usb_device_rb3_midi_drums::parse_midi_note(const u8 id, const u8 velocity) +{ + if (velocity < midi::min_velocity()) + { + // Must check here so we don't overwrite good values when applying states. + return rb3drums::KitState{}; + } + + rb3drums::KitState kit_state{}; + kit_state.expiry = std::chrono::steady_clock::now() + drum::hit_duration(); + auto note = midi::id_to_note(static_cast(id)); + switch (note) + { + case midi::Note::Kick: kit_state.kick_pedal = velocity; break; + case midi::Note::HihatPedal: kit_state.hihat_pedal = velocity; break; + case midi::Note::Snare: kit_state.snare = velocity; break; + case midi::Note::SnareRim: kit_state.snare_rim = velocity; break; + case midi::Note::HiTom: kit_state.hi_tom = velocity; break; + case midi::Note::LowTom: kit_state.low_tom = velocity; break; + case midi::Note::FloorTom: kit_state.floor_tom = velocity; break; + case midi::Note::Hihat: kit_state.hihat = velocity; break; + case midi::Note::Ride: kit_state.ride = velocity; break; + case midi::Note::Crash: kit_state.crash = velocity; break; + default: + // Ignored note. + rb3_midi_drums_log.error("IGNORED NOTE: id = %x or %d", id, id); + return rb3drums::KitState{}; + } + + combo.add(static_cast(note)); + return kit_state; +} + +bool usb_device_rb3_midi_drums::is_midi_cc(const u8 id, const u8 value) +{ + if (id != g_cfg_rb3drums.midi_cc_number) + { + return false; + } + auto is_past_threshold = [](u8 value) + { + const u8 threshold = g_cfg_rb3drums.midi_cc_threshold; + return g_cfg_rb3drums.midi_cc_invert_threshold + ? value < threshold + : value > threshold; + }; + + if (midi_cc_triggered) + { + if (!is_past_threshold(value)) + { + // Reset triggered state when we fall back past threshold. + midi_cc_triggered = false; + } + } + else + { + if (is_past_threshold(value)) + { + midi_cc_triggered = true; + return true; + } + } + return false; +} + +void usb_device_rb3_midi_drums::write_state(u8* buf, const rb3drums::KitState& kit_state) +{ + // See: https://github.com/TheNathannator/PlasticBand/blob/main/Docs/Instruments/4-Lane%20Drums/PS3%20and%20Wii.md#input-info + + // Interestingly, because cymbals use the same visual track as drums, a hit on that color can only be a drum OR a cymbal. + // rockband handles this by taking a flag to indicate if the hit is a drum vs cymbal. + set_flag_if_any(buf, "red", drum::RED, {kit_state.snare}); + set_flag_if_any(buf, "yellow", drum::YELLOW, {kit_state.hi_tom, kit_state.hihat}); + set_flag_if_any(buf, "blue", drum::BLUE, {kit_state.low_tom, kit_state.ride}); // Rock band charts blue cymbal for both hihat open and ride sometimes. + set_flag_if_any(buf, "green", drum::GREEN, {kit_state.floor_tom, kit_state.crash}); + + // Additionally, Yellow (hihat) and Blue (ride) cymbals add dpad up or down, respectively. This allows rockband to disambiguate between tom+cymbals hit at the same time. + if (kit_state.hihat >= midi::min_velocity()) + { + buf[controller::DPAD_INDEX] = static_cast(controller::DPad::Up); + } + if (kit_state.ride >= midi::min_velocity()) + { + buf[controller::DPAD_INDEX] = static_cast(controller::DPad::Down); + } + + set_flag_if_any(buf, "is_drum", drum::IS_DRUM, {kit_state.snare, kit_state.hi_tom, kit_state.low_tom, kit_state.floor_tom}); + set_flag_if_any(buf, "is_cymbal", drum::IS_CYMBAL, {kit_state.hihat, kit_state.ride, kit_state.crash}); + + set_flag_if_any(buf, "kick_pedal", drum::KICK_PEDAL, {kit_state.kick_pedal}); + set_flag_if_any(buf, "hihat_pedal", drum::HIHAT_PEDAL, {kit_state.hihat_pedal}); + + buf[11] = drum::scale_velocity(std::max(kit_state.hi_tom, kit_state.hihat)); + buf[12] = drum::scale_velocity(kit_state.snare); + buf[13] = drum::scale_velocity(std::max(kit_state.floor_tom, kit_state.crash)); + buf[14] = drum::scale_velocity(std::max({kit_state.low_tom, kit_state.ride})); + + if (kit_state.start) + { + set_flag(buf, "start", drum::START_BUTTON); + } + if (kit_state.select) + { + set_flag(buf, "select", drum::SELECT_BUTTON); + } + + // Unbound cause idk what to bind them to, but you don't really need them anyway. + // set_flag_if_any(buf, drum::SYSTEM_BUTTON, ); + // set_flag_if_any(buf, drum::BACK_BUTTON, ); +} + +bool rb3drums::KitState::is_cymbal() const +{ + return std::max({hihat, ride, crash}) >= midi::min_velocity(); +} + +bool rb3drums::KitState::is_drum() const +{ + return std::max({snare, hi_tom, low_tom, floor_tom}) >= midi::min_velocity(); +} + +void usb_device_rb3_midi_drums::ComboTracker::add(u8 note) +{ + if (!midi_notes.empty() && std::chrono::steady_clock::now() >= expiry) + { + // Combo expired. + reset(); + } + + const usz i = midi_notes.size(); + const auto& defs = midi::combo::definitions(); + bool is_in_combo = false; + for (const auto& def : defs) + { + if (i < def.notes.size() && note == def.notes[i]) + { + // Track notes as long as we match any combo. + midi_notes.push_back(note); + is_in_combo = true; + break; + } + } + + if (!is_in_combo) + { + reset(); + } + + if (midi_notes.size() == 1) + { + // New combo. + expiry = std::chrono::steady_clock::now() + midi::combo::window(); + } +} + +void usb_device_rb3_midi_drums::ComboTracker::reset() +{ + midi_notes.clear(); +} + +std::optional usb_device_rb3_midi_drums::ComboTracker::take_state() +{ + if (midi_notes.empty()) + { + return {}; + } + for (const auto& combo : midi::combo::definitions()) + { + if (midi_notes == combo.notes) + { + rb3_midi_drums_log.success("hit combo: %s", combo.name); + reset(); + return combo.create_state(); + } + } + return {}; +} diff --git a/rpcs3/Emu/Io/RB3MidiDrums.h b/rpcs3/Emu/Io/RB3MidiDrums.h new file mode 100644 index 0000000000..8c0c0446dd --- /dev/null +++ b/rpcs3/Emu/Io/RB3MidiDrums.h @@ -0,0 +1,76 @@ +#pragma once + +#include "Emu/Io/usb_device.h" + +#include +#include +#include + +namespace rb3drums +{ +struct KitState +{ + std::chrono::steady_clock::time_point expiry; + + u8 kick_pedal{}; + u8 hihat_pedal{}; + + u8 snare{}; + u8 snare_rim{}; + u8 hi_tom{}; + u8 low_tom{}; + u8 floor_tom{}; + + u8 hihat{}; + u8 ride{}; + u8 crash{}; + + // Buttons triggered by combos. + bool start{}; + bool select{}; + + // Special flag that keeps kick pedal held until toggled off. + // This is used in rb3 to access the category select dropdown in the song list. + bool toggle_hold_kick{}; + + bool is_cymbal() const; + bool is_drum() const; +}; + +}; // namespace rb3drums + +class usb_device_rb3_midi_drums : public usb_device_emulated +{ +private: + usz response_pos{}; + bool buttons_enabled{}; + RtMidiInPtr midi_in{}; + std::vector kit_states; + bool hold_kick{}; + bool midi_cc_triggered{}; + + class ComboTracker + { + public: + void add(u8 note); + void reset(); + std::optional take_state(); + + private: + std::chrono::steady_clock::time_point expiry; + std::vector midi_notes; + }; + ComboTracker combo; + + rb3drums::KitState parse_midi_message(u8* msg, usz size); + rb3drums::KitState parse_midi_note(u8 id, u8 velocity); + bool is_midi_cc(u8 id, u8 value); + void write_state(u8* buf, const rb3drums::KitState&); + +public: + usb_device_rb3_midi_drums(const std::array& location, const std::string& device_name); + ~usb_device_rb3_midi_drums(); + + void control_transfer(u8 bmRequestType, u8 bRequest, u16 wValue, u16 wIndex, u16 wLength, u32 buf_size, u8* buf, UsbTransfer* transfer) override; + void interrupt_transfer(u32 buf_size, u8* buf, u32 endpoint, UsbTransfer* transfer) override; +}; diff --git a/rpcs3/Emu/Io/midi_config_types.cpp b/rpcs3/Emu/Io/midi_config_types.cpp index ab11c87cf0..abe976ee93 100644 --- a/rpcs3/Emu/Io/midi_config_types.cpp +++ b/rpcs3/Emu/Io/midi_config_types.cpp @@ -13,6 +13,7 @@ void fmt_class_string::format(std::string& out, u64 arg) case midi_device_type::guitar: return "Guitar (17 frets)"; case midi_device_type::guitar_22fret: return "Guitar (22 frets)"; case midi_device_type::keyboard: return "Keyboard"; + case midi_device_type::drums: return "Drums"; } return unknown; diff --git a/rpcs3/Emu/Io/midi_config_types.h b/rpcs3/Emu/Io/midi_config_types.h index 49b67a93f5..9d2f40adf1 100644 --- a/rpcs3/Emu/Io/midi_config_types.h +++ b/rpcs3/Emu/Io/midi_config_types.h @@ -9,6 +9,7 @@ enum class midi_device_type keyboard, guitar, guitar_22fret, + drums, }; struct midi_device diff --git a/rpcs3/Emu/Io/rb3drums_config.cpp b/rpcs3/Emu/Io/rb3drums_config.cpp new file mode 100644 index 0000000000..2d40bfb431 --- /dev/null +++ b/rpcs3/Emu/Io/rb3drums_config.cpp @@ -0,0 +1,44 @@ +#include "stdafx.h" +#include "rb3drums_config.h" +#include + +LOG_CHANNEL(cfg_log, "CFG"); + +cfg_rb3drums g_cfg_rb3drums; + +cfg_rb3drums::cfg_rb3drums() + : cfg::node() +#ifdef _WIN32 + , + path(fs::get_config_dir() + "config/rb3drums.yml") +#else + , + path(fs::get_config_dir() + "rb3drums.yml") +#endif +{ +} + +bool cfg_rb3drums::load() +{ + cfg_log.notice("Loading rb3drums config from '%s'", path); + + if (fs::file cfg_file{path, fs::read}) + { + return from_string(cfg_file.to_string()); + } + + cfg_log.notice("No rb3drums config found. Using default settings. Path: %s", path); + from_default(); + save(); + return false; +} + +void cfg_rb3drums::save() const +{ + cfg_log.notice("Saving rb3drums config to '%s'", path); + + if (!cfg::node::save(path)) + { + cfg_log.error("Failed to save rb3drums config to '%s' (error=%s)", path, fs::g_tls_error); + } +} diff --git a/rpcs3/Emu/Io/rb3drums_config.h b/rpcs3/Emu/Io/rb3drums_config.h new file mode 100644 index 0000000000..cd638e0270 --- /dev/null +++ b/rpcs3/Emu/Io/rb3drums_config.h @@ -0,0 +1,27 @@ +#pragma once + +#include "Utilities/Config.h" + +struct cfg_rb3drums final : cfg::node +{ + cfg_rb3drums(); + bool load(); + void save() const; + + cfg::uint<1, 100> pulse_ms{this, "Pulse width ms", 30, true}; + cfg::uint<1, 127> minimum_velocity{this, "Minimum velocity", 10, true}; + cfg::uint<1, 5000> combo_window_ms{this, "Combo window in milliseconds", 2000, true}; + cfg::_bool stagger_cymbals{this, "Stagger cymbal hits", true, true}; + cfg::string midi_overrides{this, "Midi id to note override", ""}; + cfg::string combo_start{this, "Combo Start", "HihatPedal,HihatPedal,HihatPedal,Snare"}; + cfg::string combo_select{this, "Combo Select", "HihatPedal,HihatPedal,HihatPedal,SnareRim"}; + cfg::string combo_toggle_hold_kick{this, "Combo Toggle Hold Kick", "HihatPedal,HihatPedal,HihatPedal,Kick"}; + cfg::uint<0, 255> midi_cc_status{this, "Midi CC status", 0xB0, true}; + cfg::uint<0, 127> midi_cc_number{this, "Midi CC control number", 4, true}; + cfg::uint<0, 127> midi_cc_threshold{this, "Midi CC threshold", 64, true}; + cfg::_bool midi_cc_invert_threshold{this, "Midi CC invert threshold", false, true}; + + const std::string path; +}; + +extern cfg_rb3drums g_cfg_rb3drums; diff --git a/rpcs3/Emu/system_config.h b/rpcs3/Emu/system_config.h index d03b776ced..7941938d65 100644 --- a/rpcs3/Emu/system_config.h +++ b/rpcs3/Emu/system_config.h @@ -279,8 +279,9 @@ struct cfg_root : cfg::node cfg::_bool background_input_enabled{this, "Background input enabled", true, true}; cfg::_bool show_move_cursor{this, "Show move cursor", false, true}; cfg::_bool lock_overlay_input_to_player_one{this, "Lock overlay input to player one", false, true}; - cfg::string midi_devices{ this, "Emulated Midi devices", "ßßß@@@ßßß@@@ßßß@@@" }; + cfg::string midi_devices{this, "Emulated Midi devices", "ßßß@@@ßßß@@@ßßß@@@"}; cfg::_bool load_sdl_mappings{ this, "Load SDL GameController Mappings", true }; + } io{ this }; struct node_sys : cfg::node diff --git a/rpcs3/emucore.vcxproj b/rpcs3/emucore.vcxproj index 77390d7c33..49d0628e35 100644 --- a/rpcs3/emucore.vcxproj +++ b/rpcs3/emucore.vcxproj @@ -1,4 +1,4 @@ - + @@ -72,6 +72,8 @@ + + @@ -528,6 +530,8 @@ + + diff --git a/rpcs3/emucore.vcxproj.filters b/rpcs3/emucore.vcxproj.filters index 88805a21fe..4246aa1c15 100644 --- a/rpcs3/emucore.vcxproj.filters +++ b/rpcs3/emucore.vcxproj.filters @@ -1195,6 +1195,12 @@ Emu\GPU\RSX\Overlays\Network + + Emu\Io + + + Emu\Io + @@ -2422,6 +2428,12 @@ Emu\GPU\RSX\Overlays\Network + + Emu\Io + + + Emu\Io + diff --git a/rpcs3/rpcs3qt/emu_settings.cpp b/rpcs3/rpcs3qt/emu_settings.cpp index 1f4cad8a3a..054fd0778b 100644 --- a/rpcs3/rpcs3qt/emu_settings.cpp +++ b/rpcs3/rpcs3qt/emu_settings.cpp @@ -1303,6 +1303,7 @@ QString emu_settings::GetLocalizedSetting(const QString& original, emu_settings_ case midi_device_type::guitar: return tr("Guitar (17 frets)", "Midi Device Type"); case midi_device_type::guitar_22fret: return tr("Guitar (22 frets)", "Midi Device Type"); case midi_device_type::keyboard: return tr("Keyboard", "Midi Device Type"); + case midi_device_type::drums: return tr("Drums", "Midi Device Type"); } break; case emu_settings_type::XFloatAccuracy: