mirror of
https://github.com/yuzu-mirror/yuzu
synced 2024-11-28 10:10:23 +00:00
UI: Relocate tas menu and add brief description
This commit is contained in:
parent
5401cf6eb5
commit
75d8ec1e9f
10 changed files with 150 additions and 70 deletions
|
@ -117,7 +117,7 @@ struct InputSubsystem::Impl {
|
||||||
Common::ParamPackage{{"display", "Keyboard/Mouse"}, {"class", "keyboard"}},
|
Common::ParamPackage{{"display", "Keyboard/Mouse"}, {"class", "keyboard"}},
|
||||||
};
|
};
|
||||||
if (Settings::values.tas_enable) {
|
if (Settings::values.tas_enable) {
|
||||||
devices.push_back(
|
devices.emplace_back(
|
||||||
Common::ParamPackage{{"display", "TAS Controller"}, {"class", "tas"}});
|
Common::ParamPackage{{"display", "TAS Controller"}, {"class", "tas"}});
|
||||||
}
|
}
|
||||||
#ifdef HAVE_SDL2
|
#ifdef HAVE_SDL2
|
||||||
|
|
|
@ -40,12 +40,15 @@ constexpr std::array<std::pair<std::string_view, TasButton>, 20> text_to_tas_but
|
||||||
|
|
||||||
Tas::Tas() {
|
Tas::Tas() {
|
||||||
if (!Settings::values.tas_enable) {
|
if (!Settings::values.tas_enable) {
|
||||||
|
needs_reset = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
LoadTasFiles();
|
LoadTasFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
Tas::~Tas() = default;
|
Tas::~Tas() {
|
||||||
|
Stop();
|
||||||
|
};
|
||||||
|
|
||||||
void Tas::LoadTasFiles() {
|
void Tas::LoadTasFiles() {
|
||||||
script_length = 0;
|
script_length = 0;
|
||||||
|
@ -184,6 +187,9 @@ std::string Tas::ButtonsToString(u32 button) const {
|
||||||
|
|
||||||
void Tas::UpdateThread() {
|
void Tas::UpdateThread() {
|
||||||
if (!Settings::values.tas_enable) {
|
if (!Settings::values.tas_enable) {
|
||||||
|
if (is_running) {
|
||||||
|
Stop();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -196,34 +202,35 @@ void Tas::UpdateThread() {
|
||||||
LoadTasFiles();
|
LoadTasFiles();
|
||||||
LOG_DEBUG(Input, "tas_reset done");
|
LOG_DEBUG(Input, "tas_reset done");
|
||||||
}
|
}
|
||||||
if (is_running) {
|
|
||||||
if (current_command < script_length) {
|
if (!is_running) {
|
||||||
LOG_DEBUG(Input, "Playing TAS {}/{}", current_command, script_length);
|
tas_data.fill({});
|
||||||
size_t frame = current_command++;
|
return;
|
||||||
for (size_t i = 0; i < commands.size(); i++) {
|
}
|
||||||
if (frame < commands[i].size()) {
|
if (current_command < script_length) {
|
||||||
TASCommand command = commands[i][frame];
|
LOG_DEBUG(Input, "Playing TAS {}/{}", current_command, script_length);
|
||||||
tas_data[i].buttons = command.buttons;
|
size_t frame = current_command++;
|
||||||
auto [l_axis_x, l_axis_y] = command.l_axis;
|
for (size_t i = 0; i < commands.size(); i++) {
|
||||||
tas_data[i].axis[0] = l_axis_x;
|
if (frame < commands[i].size()) {
|
||||||
tas_data[i].axis[1] = l_axis_y;
|
TASCommand command = commands[i][frame];
|
||||||
auto [r_axis_x, r_axis_y] = command.r_axis;
|
tas_data[i].buttons = command.buttons;
|
||||||
tas_data[i].axis[2] = r_axis_x;
|
auto [l_axis_x, l_axis_y] = command.l_axis;
|
||||||
tas_data[i].axis[3] = r_axis_y;
|
tas_data[i].axis[0] = l_axis_x;
|
||||||
} else {
|
tas_data[i].axis[1] = l_axis_y;
|
||||||
tas_data[i] = {};
|
auto [r_axis_x, r_axis_y] = command.r_axis;
|
||||||
}
|
tas_data[i].axis[2] = r_axis_x;
|
||||||
}
|
tas_data[i].axis[3] = r_axis_y;
|
||||||
} else {
|
} else {
|
||||||
is_running = Settings::values.tas_loop.GetValue();
|
tas_data[i] = {};
|
||||||
current_command = 0;
|
|
||||||
tas_data.fill({});
|
|
||||||
if (!is_running) {
|
|
||||||
SwapToStoredController();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
is_running = Settings::values.tas_loop.GetValue();
|
||||||
|
current_command = 0;
|
||||||
tas_data.fill({});
|
tas_data.fill({});
|
||||||
|
if (!is_running) {
|
||||||
|
SwapToStoredController();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
LOG_DEBUG(Input, "TAS inputs: {}", DebugInputs(tas_data));
|
LOG_DEBUG(Input, "TAS inputs: {}", DebugInputs(tas_data));
|
||||||
}
|
}
|
||||||
|
@ -237,8 +244,8 @@ TasAnalog Tas::ReadCommandAxis(const std::string& line) const {
|
||||||
seglist.push_back(segment);
|
seglist.push_back(segment);
|
||||||
}
|
}
|
||||||
|
|
||||||
const float x = std::stof(seglist.at(0)) / 32767.f;
|
const float x = std::stof(seglist.at(0)) / 32767.0f;
|
||||||
const float y = std::stof(seglist.at(1)) / 32767.f;
|
const float y = std::stof(seglist.at(1)) / 32767.0f;
|
||||||
|
|
||||||
return {x, y};
|
return {x, y};
|
||||||
}
|
}
|
||||||
|
@ -293,12 +300,20 @@ std::string Tas::WriteCommandButtons(u32 data) const {
|
||||||
}
|
}
|
||||||
|
|
||||||
void Tas::StartStop() {
|
void Tas::StartStop() {
|
||||||
is_running = !is_running;
|
if (!Settings::values.tas_enable) {
|
||||||
if (is_running) {
|
return;
|
||||||
SwapToTasController();
|
|
||||||
} else {
|
|
||||||
SwapToStoredController();
|
|
||||||
}
|
}
|
||||||
|
if (is_running) {
|
||||||
|
Stop();
|
||||||
|
} else {
|
||||||
|
is_running = true;
|
||||||
|
SwapToTasController();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Tas::Stop() {
|
||||||
|
is_running = false;
|
||||||
|
SwapToStoredController();
|
||||||
}
|
}
|
||||||
|
|
||||||
void Tas::SwapToTasController() {
|
void Tas::SwapToTasController() {
|
||||||
|
@ -315,7 +330,8 @@ void Tas::SwapToTasController() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto tas_param = Common::ParamPackage{{"pad", static_cast<u8>(index)}};
|
Common::ParamPackage tas_param;
|
||||||
|
tas_param.Set("pad", static_cast<u8>(index));
|
||||||
auto button_mapping = GetButtonMappingForDevice(tas_param);
|
auto button_mapping = GetButtonMappingForDevice(tas_param);
|
||||||
auto analog_mapping = GetAnalogMappingForDevice(tas_param);
|
auto analog_mapping = GetAnalogMappingForDevice(tas_param);
|
||||||
auto& buttons = player.buttons;
|
auto& buttons = player.buttons;
|
||||||
|
@ -328,25 +344,33 @@ void Tas::SwapToTasController() {
|
||||||
analogs[i] = analog_mapping[static_cast<Settings::NativeAnalog::Values>(i)].Serialize();
|
analogs[i] = analog_mapping[static_cast<Settings::NativeAnalog::Values>(i)].Serialize();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is_old_input_saved = true;
|
||||||
Settings::values.is_device_reload_pending.store(true);
|
Settings::values.is_device_reload_pending.store(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Tas::SwapToStoredController() {
|
void Tas::SwapToStoredController() {
|
||||||
if (!Settings::values.tas_swap_controllers) {
|
if (!is_old_input_saved) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
auto& players = Settings::values.players.GetValue();
|
auto& players = Settings::values.players.GetValue();
|
||||||
for (std::size_t index = 0; index < players.size(); index++) {
|
for (std::size_t index = 0; index < players.size(); index++) {
|
||||||
players[index] = player_mappings[index];
|
players[index] = player_mappings[index];
|
||||||
}
|
}
|
||||||
|
is_old_input_saved = false;
|
||||||
Settings::values.is_device_reload_pending.store(true);
|
Settings::values.is_device_reload_pending.store(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Tas::Reset() {
|
void Tas::Reset() {
|
||||||
|
if (!Settings::values.tas_enable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
needs_reset = true;
|
needs_reset = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Tas::Record() {
|
bool Tas::Record() {
|
||||||
|
if (!Settings::values.tas_enable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
is_recording = !is_recording;
|
is_recording = !is_recording;
|
||||||
return is_recording;
|
return is_recording;
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,8 +13,8 @@
|
||||||
|
|
||||||
/*
|
/*
|
||||||
To play back TAS scripts on Yuzu, select the folder with scripts in the configuration menu below
|
To play back TAS scripts on Yuzu, select the folder with scripts in the configuration menu below
|
||||||
Emulation -> Configure TAS. The file itself has normal text format and has to be called
|
Tools -> Configure TAS. The file itself has normal text format and has to be called script0-1.txt
|
||||||
script0-1.txt for controller 1, script0-2.txt for controller 2 and so forth (with max. 8 players).
|
for controller 1, script0-2.txt for controller 2 and so forth (with max. 8 players).
|
||||||
|
|
||||||
A script file has the same format as TAS-nx uses, so final files will look like this:
|
A script file has the same format as TAS-nx uses, so final files will look like this:
|
||||||
|
|
||||||
|
@ -56,26 +56,26 @@ enum class TasState {
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class TasButton : u32 {
|
enum class TasButton : u32 {
|
||||||
BUTTON_A = 0x000001,
|
BUTTON_A = 1U << 0,
|
||||||
BUTTON_B = 0x000002,
|
BUTTON_B = 1U << 1,
|
||||||
BUTTON_X = 0x000004,
|
BUTTON_X = 1U << 2,
|
||||||
BUTTON_Y = 0x000008,
|
BUTTON_Y = 1U << 3,
|
||||||
STICK_L = 0x000010,
|
STICK_L = 1U << 4,
|
||||||
STICK_R = 0x000020,
|
STICK_R = 1U << 5,
|
||||||
TRIGGER_L = 0x000040,
|
TRIGGER_L = 1U << 6,
|
||||||
TRIGGER_R = 0x000080,
|
TRIGGER_R = 1U << 7,
|
||||||
TRIGGER_ZL = 0x000100,
|
TRIGGER_ZL = 1U << 8,
|
||||||
TRIGGER_ZR = 0x000200,
|
TRIGGER_ZR = 1U << 9,
|
||||||
BUTTON_PLUS = 0x000400,
|
BUTTON_PLUS = 1U << 10,
|
||||||
BUTTON_MINUS = 0x000800,
|
BUTTON_MINUS = 1U << 11,
|
||||||
BUTTON_LEFT = 0x001000,
|
BUTTON_LEFT = 1U << 12,
|
||||||
BUTTON_UP = 0x002000,
|
BUTTON_UP = 1U << 13,
|
||||||
BUTTON_RIGHT = 0x004000,
|
BUTTON_RIGHT = 1U << 14,
|
||||||
BUTTON_DOWN = 0x008000,
|
BUTTON_DOWN = 1U << 15,
|
||||||
BUTTON_SL = 0x010000,
|
BUTTON_SL = 1U << 16,
|
||||||
BUTTON_SR = 0x020000,
|
BUTTON_SR = 1U << 17,
|
||||||
BUTTON_HOME = 0x040000,
|
BUTTON_HOME = 1U << 18,
|
||||||
BUTTON_CAPTURE = 0x080000,
|
BUTTON_CAPTURE = 1U << 19,
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class TasAxes : u8 {
|
enum class TasAxes : u8 {
|
||||||
|
@ -105,6 +105,9 @@ public:
|
||||||
// Sets the flag to start or stop the TAS command excecution and swaps controllers profiles
|
// Sets the flag to start or stop the TAS command excecution and swaps controllers profiles
|
||||||
void StartStop();
|
void StartStop();
|
||||||
|
|
||||||
|
// Stop the TAS and reverts any controller profile
|
||||||
|
void Stop();
|
||||||
|
|
||||||
// Sets the flag to reload the file and start from the begining in the next update
|
// Sets the flag to reload the file and start from the begining in the next update
|
||||||
void Reset();
|
void Reset();
|
||||||
|
|
||||||
|
@ -219,6 +222,7 @@ private:
|
||||||
|
|
||||||
size_t script_length{0};
|
size_t script_length{0};
|
||||||
std::array<TasData, PLAYER_NUMBER> tas_data;
|
std::array<TasData, PLAYER_NUMBER> tas_data;
|
||||||
|
bool is_old_input_saved{false};
|
||||||
bool is_recording{false};
|
bool is_recording{false};
|
||||||
bool is_running{false};
|
bool is_running{false};
|
||||||
bool needs_reset{false};
|
bool needs_reset{false};
|
||||||
|
|
|
@ -55,7 +55,7 @@ void ConfigureTasDialog::SetDirectory(DirectoryTarget target, QLineEdit* edit) {
|
||||||
|
|
||||||
QString str = QFileDialog::getExistingDirectory(this, caption, edit->text());
|
QString str = QFileDialog::getExistingDirectory(this, caption, edit->text());
|
||||||
|
|
||||||
if (str.isNull() || str.isEmpty()) {
|
if (str.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,50 @@
|
||||||
<item>
|
<item>
|
||||||
<widget class="QGroupBox" name="groupBox">
|
<widget class="QGroupBox" name="groupBox">
|
||||||
<property name="title">
|
<property name="title">
|
||||||
<string>TAS Settings</string>
|
<string>TAS</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QGridLayout" name="gridLayout">
|
||||||
|
<item row="0" column="0" colspan="1">
|
||||||
|
<widget class="QLabel" name="label_1">
|
||||||
|
<property name="text">
|
||||||
|
<string>Reads controller input from scripts in the same format as TAS-nx scripts. For a more detailed explanation please consult the FAQ on the yuzu website.</string>
|
||||||
|
</property>
|
||||||
|
<property name="wordWrap">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="0" colspan="1">
|
||||||
|
<widget class="QLabel" name="label_2">
|
||||||
|
<property name="text">
|
||||||
|
<string>To check which hotkeys control the playback/recording, please refer to the Hotkey settings (General -> Hotkeys).</string>
|
||||||
|
</property>
|
||||||
|
<property name="wordWrap">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="2" column="0" colspan="1">
|
||||||
|
<widget class="QLabel" name="label_2">
|
||||||
|
<property name="text">
|
||||||
|
<string>WARNING: This is an experimental feature. It will not play back scripts frame perfectly with the current, imperfect syncing method.</string>
|
||||||
|
</property>
|
||||||
|
<property name="wordWrap">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QGroupBox" name="groupBox">
|
||||||
|
<property name="title">
|
||||||
|
<string>Settings</string>
|
||||||
</property>
|
</property>
|
||||||
<layout class="QGridLayout" name="gridLayout">
|
<layout class="QGridLayout" name="gridLayout">
|
||||||
<item row="0" column="0" colspan="4">
|
<item row="0" column="0" colspan="4">
|
||||||
|
@ -63,7 +106,7 @@
|
||||||
<item>
|
<item>
|
||||||
<widget class="QGroupBox" name="groupBox">
|
<widget class="QGroupBox" name="groupBox">
|
||||||
<property name="title">
|
<property name="title">
|
||||||
<string>TAS Directories</string>
|
<string>Script Directory</string>
|
||||||
</property>
|
</property>
|
||||||
<layout class="QGridLayout" name="gridLayout">
|
<layout class="QGridLayout" name="gridLayout">
|
||||||
<item row="0" column="0">
|
<item row="0" column="0">
|
||||||
|
|
|
@ -99,7 +99,7 @@ void ConfigureVibration::SetVibrationDevices(std::size_t player_index) {
|
||||||
const auto guid = param.Get("guid", "");
|
const auto guid = param.Get("guid", "");
|
||||||
const auto port = param.Get("port", "");
|
const auto port = param.Get("port", "");
|
||||||
|
|
||||||
if (engine.empty() || engine == "keyboard" || engine == "mouse") {
|
if (engine.empty() || engine == "keyboard" || engine == "mouse" || engine == "tas") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -78,7 +78,7 @@ void ControllerDialog::InputController(ControllerInput input) {
|
||||||
u32 buttons = 0;
|
u32 buttons = 0;
|
||||||
int index = 0;
|
int index = 0;
|
||||||
for (bool btn : input.button_values) {
|
for (bool btn : input.button_values) {
|
||||||
buttons += (btn ? 1 : 0) << index;
|
buttons |= (btn ? 1U : 0U) << index;
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
input_subsystem->GetTas()->RecordInput(buttons, input.axis_values);
|
input_subsystem->GetTas()->RecordInput(buttons, input.axis_values);
|
||||||
|
|
|
@ -1022,18 +1022,25 @@ void GMainWindow::InitializeHotkeys() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
connect(hotkey_registry.GetHotkey(main_window, QStringLiteral("TAS Start/Stop"), this),
|
connect(hotkey_registry.GetHotkey(main_window, QStringLiteral("TAS Start/Stop"), this),
|
||||||
&QShortcut::activated, this, [&] { input_subsystem->GetTas()->StartStop(); });
|
&QShortcut::activated, this, [&] {
|
||||||
|
if (!emulation_running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input_subsystem->GetTas()->StartStop();
|
||||||
|
});
|
||||||
connect(hotkey_registry.GetHotkey(main_window, QStringLiteral("TAS Reset"), this),
|
connect(hotkey_registry.GetHotkey(main_window, QStringLiteral("TAS Reset"), this),
|
||||||
&QShortcut::activated, this, [&] { input_subsystem->GetTas()->Reset(); });
|
&QShortcut::activated, this, [&] { input_subsystem->GetTas()->Reset(); });
|
||||||
connect(hotkey_registry.GetHotkey(main_window, QStringLiteral("TAS Record"), this),
|
connect(hotkey_registry.GetHotkey(main_window, QStringLiteral("TAS Record"), this),
|
||||||
&QShortcut::activated, this, [&] {
|
&QShortcut::activated, this, [&] {
|
||||||
|
if (!emulation_running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
bool is_recording = input_subsystem->GetTas()->Record();
|
bool is_recording = input_subsystem->GetTas()->Record();
|
||||||
if (!is_recording) {
|
if (!is_recording) {
|
||||||
QMessageBox::StandardButton reply;
|
const auto res = QMessageBox::question(this, tr("TAS Recording"),
|
||||||
reply = QMessageBox::question(this, tr("TAS Recording"),
|
tr("Overwrite file of player 1?"),
|
||||||
tr("Overwrite file of player 1?"),
|
QMessageBox::Yes | QMessageBox::No);
|
||||||
QMessageBox::Yes | QMessageBox::No);
|
input_subsystem->GetTas()->SaveRecording(res == QMessageBox::Yes);
|
||||||
input_subsystem->GetTas()->SaveRecording(reply == QMessageBox::Yes);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1487,6 +1494,8 @@ void GMainWindow::ShutdownGame() {
|
||||||
game_list->show();
|
game_list->show();
|
||||||
}
|
}
|
||||||
game_list->SetFilterFocus();
|
game_list->SetFilterFocus();
|
||||||
|
tas_label->clear();
|
||||||
|
input_subsystem->GetTas()->Stop();
|
||||||
|
|
||||||
render_window->removeEventFilter(render_window);
|
render_window->removeEventFilter(render_window);
|
||||||
render_window->setAttribute(Qt::WA_Hover, false);
|
render_window->setAttribute(Qt::WA_Hover, false);
|
||||||
|
|
|
@ -320,7 +320,7 @@ private:
|
||||||
QLabel* emu_speed_label = nullptr;
|
QLabel* emu_speed_label = nullptr;
|
||||||
QLabel* game_fps_label = nullptr;
|
QLabel* game_fps_label = nullptr;
|
||||||
QLabel* emu_frametime_label = nullptr;
|
QLabel* emu_frametime_label = nullptr;
|
||||||
QLabel* TASlabel;
|
QLabel* tas_label = nullptr;
|
||||||
QPushButton* gpu_accuracy_button = nullptr;
|
QPushButton* gpu_accuracy_button = nullptr;
|
||||||
QPushButton* renderer_status_button = nullptr;
|
QPushButton* renderer_status_button = nullptr;
|
||||||
QPushButton* dock_status_button = nullptr;
|
QPushButton* dock_status_button = nullptr;
|
||||||
|
|
|
@ -72,7 +72,6 @@
|
||||||
<addaction name="action_Restart"/>
|
<addaction name="action_Restart"/>
|
||||||
<addaction name="separator"/>
|
<addaction name="separator"/>
|
||||||
<addaction name="action_Configure"/>
|
<addaction name="action_Configure"/>
|
||||||
<addaction name="action_Configure_Tas"/>
|
|
||||||
<addaction name="action_Configure_Current_Game"/>
|
<addaction name="action_Configure_Current_Game"/>
|
||||||
</widget>
|
</widget>
|
||||||
<widget class="QMenu" name="menu_View">
|
<widget class="QMenu" name="menu_View">
|
||||||
|
@ -101,6 +100,7 @@
|
||||||
<addaction name="action_Rederive"/>
|
<addaction name="action_Rederive"/>
|
||||||
<addaction name="separator"/>
|
<addaction name="separator"/>
|
||||||
<addaction name="action_Capture_Screenshot"/>
|
<addaction name="action_Capture_Screenshot"/>
|
||||||
|
<addaction name="action_Configure_Tas"/>
|
||||||
</widget>
|
</widget>
|
||||||
<widget class="QMenu" name="menu_Help">
|
<widget class="QMenu" name="menu_Help">
|
||||||
<property name="title">
|
<property name="title">
|
||||||
|
|
Loading…
Reference in a new issue