mirror of
https://github.com/SourMesen/Mesen2.git
synced 2025-04-02 10:21:44 -04:00
NES: Implement auto-configure inputs option
This commit is contained in:
parent
77692f323d
commit
a15c9421a8
19 changed files with 201 additions and 24 deletions
|
@ -1,5 +1,4 @@
|
|||
#include "stdafx.h"
|
||||
#include "Shared/NotificationManager.h"
|
||||
#include "NES/MapperFactory.h"
|
||||
#include "NES/NesConsole.h"
|
||||
#include "NES/Loaders/RomLoader.h"
|
||||
|
@ -671,16 +670,6 @@ unique_ptr<BaseMapper> MapperFactory::InitializeFromFile(NesConsole* console, Vi
|
|||
romData = {};
|
||||
bool databaseEnabled = !console->GetNesConfig().DisableGameDatabase;
|
||||
if(RomLoader::LoadFile(romFile, romData, databaseEnabled)) {
|
||||
if((romData.Info.IsInDatabase || romData.Info.IsNes20Header) && romData.Info.InputType != GameInputType::Unspecified) {
|
||||
//If in DB or a NES 2.0 file, auto-configure the inputs
|
||||
//TODO NES
|
||||
/*
|
||||
if(console->GetNesConfig()->CheckFlag(EmulationFlags::AutoConfigureInput)) {
|
||||
console->GetSettings()->InitializeInputDevices(romData.Info.InputType, romData.Info.System, false);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
unique_ptr<BaseMapper> mapper(GetMapperFromID(romData));
|
||||
if(mapper) {
|
||||
result = LoadRomResult::Success;
|
||||
|
|
|
@ -23,11 +23,12 @@
|
|||
#include "NES/Mappers/FDS/Fds.h"
|
||||
#include "Shared/Emulator.h"
|
||||
#include "Shared/CheatManager.h"
|
||||
#include "Netplay/GameClient.h"
|
||||
#include "Shared/Movies/MovieManager.h"
|
||||
#include "Shared/BaseControlManager.h"
|
||||
#include "Shared/Interfaces/IBattery.h"
|
||||
#include "Shared/EmuSettings.h"
|
||||
#include "Shared/NotificationManager.h"
|
||||
#include "Netplay/GameClient.h"
|
||||
#include "Debugger/DebugTypes.h"
|
||||
#include "Utilities/Serializer.h"
|
||||
|
||||
|
@ -133,6 +134,11 @@ LoadRomResult NesConsole::LoadRom(VirtualFile& romFile)
|
|||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
//If in DB or a NES 2.0 file, auto-configure the inputs (if option is enabled)
|
||||
if(GetNesConfig().AutoConfigureInput && (romData.Info.IsInDatabase || romData.Info.IsNes20Header) && romData.Info.InputType != GameInputType::Unspecified) {
|
||||
InitializeInputDevices(romData.Info.InputType, romData.Info.System);
|
||||
}
|
||||
|
||||
_mapper.swap(mapper);
|
||||
_mixer.reset(new NesSoundMixer(this));
|
||||
|
@ -465,6 +471,123 @@ void NesConsole::DebugWriteVram(uint16_t addr, uint8_t value)
|
|||
}
|
||||
}
|
||||
|
||||
void NesConsole::InitializeInputDevices(GameInputType inputType, GameSystem system)
|
||||
{
|
||||
ControllerType port1 = ControllerType::NesController;
|
||||
ControllerType port2 = ControllerType::NesController;
|
||||
ControllerType expDevice = ControllerType::None;
|
||||
|
||||
auto log = [](string text) {
|
||||
MessageManager::Log(text);
|
||||
};
|
||||
|
||||
bool isFamicom = (system == GameSystem::Famicom || system == GameSystem::FDS || system == GameSystem::Dendy);
|
||||
|
||||
if(inputType == GameInputType::VsZapper) {
|
||||
//VS Duck Hunt, etc. need the zapper in the first port
|
||||
log("[Input] VS Zapper connected");
|
||||
port1 = ControllerType::NesZapper;
|
||||
} else if(inputType == GameInputType::Zapper) {
|
||||
log("[Input] Zapper connected");
|
||||
if(isFamicom) {
|
||||
expDevice = ControllerType::FamicomZapper;
|
||||
} else {
|
||||
port2 = ControllerType::NesZapper;
|
||||
}
|
||||
} else if(inputType == GameInputType::FourScore) {
|
||||
log("[Input] Four score connected");
|
||||
port1 = ControllerType::FourScore;
|
||||
port2 = ControllerType::FourScore;
|
||||
} else if(inputType == GameInputType::FourPlayerAdapter) {
|
||||
log("[Input] Four player adapter connected");
|
||||
expDevice = ControllerType::TwoPlayerAdapter;
|
||||
} else if(inputType == GameInputType::ArkanoidControllerFamicom) {
|
||||
log("[Input] Arkanoid controller (Famicom) connected");
|
||||
expDevice = ControllerType::FamicomArkanoidController;
|
||||
} else if(inputType == GameInputType::ArkanoidControllerNes) {
|
||||
log("[Input] Arkanoid controller (NES) connected");
|
||||
port2 = ControllerType::NesArkanoidController;
|
||||
} else if(inputType == GameInputType::DoubleArkanoidController) {
|
||||
log("[Input] 2x arkanoid controllers (NES) connected");
|
||||
port1 = ControllerType::NesArkanoidController;
|
||||
port2 = ControllerType::NesArkanoidController;
|
||||
} else if(inputType == GameInputType::OekaKidsTablet) {
|
||||
log("[Input] Oeka Kids Tablet connected");
|
||||
expDevice = ControllerType::OekaKidsTablet;
|
||||
} else if(inputType == GameInputType::KonamiHyperShot) {
|
||||
log("[Input] Konami Hyper Shot connected");
|
||||
expDevice = ControllerType::KonamiHyperShot;
|
||||
} else if(inputType == GameInputType::FamilyBasicKeyboard) {
|
||||
log("[Input] Family Basic Keyboard connected");
|
||||
expDevice = ControllerType::FamilyBasicKeyboard;
|
||||
} else if(inputType == GameInputType::PartyTap) {
|
||||
log("[Input] Party Tap connected");
|
||||
expDevice = ControllerType::PartyTap;
|
||||
} else if(inputType == GameInputType::PachinkoController) {
|
||||
log("[Input] Pachinko controller connected");
|
||||
expDevice = ControllerType::Pachinko;
|
||||
} else if(inputType == GameInputType::ExcitingBoxing) {
|
||||
log("[Input] Exciting Boxing controller connected");
|
||||
expDevice = ControllerType::ExcitingBoxing;
|
||||
} else if(inputType == GameInputType::SuborKeyboardMouse1) {
|
||||
log("[Input] Subor mouse connected");
|
||||
log("[Input] Subor keyboard connected");
|
||||
expDevice = ControllerType::SuborKeyboard;
|
||||
port2 = ControllerType::SuborMouse;
|
||||
} else if(inputType == GameInputType::JissenMahjong) {
|
||||
log("[Input] Jissen Mahjong controller connected");
|
||||
expDevice = ControllerType::JissenMahjong;
|
||||
} else if(inputType == GameInputType::BarcodeBattler) {
|
||||
log("[Input] Barcode Battler barcode reader connected");
|
||||
expDevice = ControllerType::BarcodeBattler;
|
||||
} else if(inputType == GameInputType::BandaiHypershot) {
|
||||
log("[Input] Bandai Hyper Shot gun connected");
|
||||
expDevice = ControllerType::BandaiHyperShot;
|
||||
} else if(inputType == GameInputType::BattleBox) {
|
||||
log("[Input] Battle Box connected");
|
||||
expDevice = ControllerType::BattleBox;
|
||||
} else if(inputType == GameInputType::TurboFile) {
|
||||
log("[Input] Ascii Turbo File connected");
|
||||
expDevice = ControllerType::AsciiTurboFile;
|
||||
} else if(inputType == GameInputType::FamilyTrainerSideA) {
|
||||
log("[Input] Family Trainer mat connected (Side A)");
|
||||
expDevice = ControllerType::FamilyTrainerMatSideA;
|
||||
} else if(inputType == GameInputType::FamilyTrainerSideB) {
|
||||
log("[Input] Family Trainer mat connected (Side B)");
|
||||
expDevice = ControllerType::FamilyTrainerMatSideB;
|
||||
} else if(inputType == GameInputType::PowerPadSideA) {
|
||||
log("[Input] Power Pad connected (Side A)");
|
||||
port2 = ControllerType::PowerPadSideA;
|
||||
} else if(inputType == GameInputType::PowerPadSideB) {
|
||||
log("[Input] Power Pad connected (Side B)");
|
||||
port2 = ControllerType::PowerPadSideB;
|
||||
} else if(inputType == GameInputType::SnesControllers) {
|
||||
log("[Input] 2 SNES controllers connected");
|
||||
port1 = ControllerType::SnesController;
|
||||
port2 = ControllerType::SnesController;
|
||||
} else {
|
||||
log("[Input] 2 standard controllers connected");
|
||||
}
|
||||
|
||||
isFamicom = (system == GameSystem::Famicom || system == GameSystem::FDS || system == GameSystem::Dendy);
|
||||
|
||||
NesConfig& cfg = GetNesConfig();
|
||||
cfg.Port1.Type = port1;
|
||||
cfg.Port2.Type = port2;
|
||||
cfg.ExpPort.Type = expDevice;
|
||||
|
||||
if(port1 == ControllerType::FourScore) {
|
||||
cfg.Port1SubPorts[0].Type = ControllerType::NesController;
|
||||
cfg.Port1SubPorts[1].Type = ControllerType::NesController;
|
||||
cfg.Port1SubPorts[2].Type = ControllerType::NesController;
|
||||
cfg.Port1SubPorts[3].Type = ControllerType::NesController;
|
||||
} else if(expDevice == ControllerType::TwoPlayerAdapter) {
|
||||
cfg.ExpPortSubPorts[0].Type = ControllerType::NesController;
|
||||
cfg.ExpPortSubPorts[1].Type = ControllerType::NesController;
|
||||
}
|
||||
_emu->GetNotificationManager()->SendNotification(ConsoleNotificationType::RequestConfigChange);
|
||||
}
|
||||
|
||||
void NesConsole::ProcessCheatCode(InternalCheatCode& code, uint32_t addr, uint8_t& value)
|
||||
{
|
||||
if(code.Type == CheatType::NesGameGenie && addr >= 0xC020) {
|
||||
|
|
|
@ -22,6 +22,8 @@ struct HdPackData;
|
|||
enum class DebugEventType;
|
||||
enum class EventType;
|
||||
enum class ConsoleRegion;
|
||||
enum class GameInputType;
|
||||
enum class GameSystem;
|
||||
|
||||
class NesConsole final : public IConsole
|
||||
{
|
||||
|
@ -48,6 +50,8 @@ private:
|
|||
|
||||
void UpdateRegion(bool forceUpdate = false);
|
||||
void LoadHdPack(VirtualFile& romFile);
|
||||
|
||||
void InitializeInputDevices(GameInputType inputType, GameSystem system);
|
||||
|
||||
public:
|
||||
NesConsole(Emulator* emulator);
|
||||
|
|
|
@ -14,7 +14,6 @@ class IInputProvider;
|
|||
class Emulator;
|
||||
class NesConsole;
|
||||
enum class ControllerType;
|
||||
enum class ExpansionPortDevice;
|
||||
|
||||
class NesControlManager : public ISerializable, public INesMemoryHandler, public BaseControlManager
|
||||
{
|
||||
|
|
|
@ -584,14 +584,12 @@ void BaseCartridge::ApplyConfigOverrides()
|
|||
string name = GetCartName();
|
||||
if(name == "POWERDRIVE" || name == "DEATH BRADE" || name == "RPG SAILORMOON") {
|
||||
//These games work better when ram is initialized to $FF
|
||||
SnesConfig cfg = _emu->GetSettings()->GetSnesConfig();
|
||||
SnesConfig& cfg = _emu->GetSettings()->GetSnesConfig();
|
||||
cfg.RamPowerOnState = RamState::AllOnes;
|
||||
_emu->GetSettings()->SetSnesConfig(cfg);
|
||||
} else if(name == "SUPER KEIBA 2") {
|
||||
//Super Keiba 2 behaves incorrectly if save ram is filled with 0s
|
||||
SnesConfig cfg = _emu->GetSettings()->GetSnesConfig();
|
||||
SnesConfig& cfg = _emu->GetSettings()->GetSnesConfig();
|
||||
cfg.RamPowerOnState = RamState::Random;
|
||||
_emu->GetSettings()->SetSnesConfig(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,7 +14,6 @@ class Emulator;
|
|||
class SystemActionManager;
|
||||
struct ControllerData;
|
||||
enum class ControllerType;
|
||||
enum class ExpansionPortDevice;
|
||||
|
||||
class SnesControlManager : public ISerializable, public BaseControlManager
|
||||
{
|
||||
|
|
|
@ -25,7 +25,8 @@ enum class ConsoleNotificationType
|
|||
BeforeGameUnload,
|
||||
BeforeGameLoad,
|
||||
GameLoadFailed,
|
||||
CheatsChanged
|
||||
CheatsChanged,
|
||||
RequestConfigChange
|
||||
};
|
||||
|
||||
class INotificationListener
|
||||
|
|
|
@ -119,7 +119,8 @@ void RecordedRomTest::Record(string filename, bool reset)
|
|||
_emu->Lock();
|
||||
Reset();
|
||||
|
||||
SnesConfig snesCfg = _emu->GetSettings()->GetSnesConfig();
|
||||
//TODO - remove snes-specific code
|
||||
SnesConfig& snesCfg = _emu->GetSettings()->GetSnesConfig();
|
||||
snesCfg.DisableFrameSkipping = true;
|
||||
snesCfg.RamPowerOnState = RamState::AllZeros;
|
||||
_emu->GetSettings()->SetSnesConfig(snesCfg);
|
||||
|
@ -178,7 +179,8 @@ int32_t RecordedRomTest::Run(string filename)
|
|||
_currentCount = _repetitionCount.front();
|
||||
_repetitionCount.pop_front();
|
||||
|
||||
SnesConfig snesCfg = _emu->GetSettings()->GetSnesConfig();
|
||||
//TODO - remove snes-specific code
|
||||
SnesConfig& snesCfg = _emu->GetSettings()->GetSnesConfig();
|
||||
snesCfg.DisableFrameSkipping = true;
|
||||
snesCfg.RamPowerOnState = RamState::AllZeros;
|
||||
_emu->GetSettings()->SetSnesConfig(snesCfg);
|
||||
|
|
|
@ -481,6 +481,7 @@ struct NesConfig
|
|||
ControllerConfig ExpPortSubPorts[4];
|
||||
|
||||
uint32_t LightDetectionRadius = 0;
|
||||
bool AutoConfigureInput = true;
|
||||
|
||||
ConsoleRegion Region = ConsoleRegion::Auto;
|
||||
bool EnableHdPacks = true;
|
||||
|
|
|
@ -72,8 +72,14 @@ extern "C" {
|
|||
_emu->GetSettings()->SetShortcutKeys(shortcutList);
|
||||
}
|
||||
|
||||
DllExport NesConfig __stdcall GetNesConfig()
|
||||
{
|
||||
return _emu->GetSettings()->GetNesConfig();
|
||||
}
|
||||
|
||||
DllExport ControllerType __stdcall GetControllerType(int player)
|
||||
{
|
||||
//TODO - used by netplay?
|
||||
BaseControlManager* controlManager = _emu->GetControlManager();
|
||||
if(controlManager) {
|
||||
shared_ptr<BaseControlDevice> device = controlManager->GetControlDevice(player);
|
||||
|
|
|
@ -28,6 +28,7 @@ namespace Mesen.Config
|
|||
[Reactive] public NesControllerConfig ExpPortD { get; set; } = new();
|
||||
|
||||
[Reactive] public UInt32 LightDetectionRadius { get; set; } = 0;
|
||||
[Reactive] public bool AutoConfigureInput { get; set; } = true;
|
||||
|
||||
//General
|
||||
[Reactive] public ConsoleRegion Region { get; set; } = ConsoleRegion.Auto;
|
||||
|
@ -140,6 +141,7 @@ namespace Mesen.Config
|
|||
ExpPortD = ExpPortD.ToInterop(),
|
||||
|
||||
LightDetectionRadius = LightDetectionRadius,
|
||||
AutoConfigureInput = AutoConfigureInput,
|
||||
|
||||
Region = Region,
|
||||
EnableHdPacks = EnableHdPacks,
|
||||
|
@ -267,6 +269,22 @@ namespace Mesen.Config
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateInputFromCoreConfig()
|
||||
{
|
||||
//Used to update input devices when the core requests changes
|
||||
InteropNesConfig cfg = ConfigApi.GetNesConfig();
|
||||
Port1.Type = cfg.Port1.Type;
|
||||
Port1A.Type = cfg.Port1A.Type;
|
||||
Port1B.Type = cfg.Port1B.Type;
|
||||
Port1C.Type = cfg.Port1C.Type;
|
||||
Port1D.Type = cfg.Port1D.Type;
|
||||
Port2.Type = cfg.Port2.Type;
|
||||
ExpPort.Type = cfg.ExpPort.Type;
|
||||
ExpPortA.Type = cfg.ExpPortA.Type;
|
||||
ExpPortB.Type = cfg.ExpPortB.Type;
|
||||
ApplyConfig();
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
|
@ -287,6 +305,7 @@ namespace Mesen.Config
|
|||
public InteropControllerConfig ExpPortD;
|
||||
|
||||
public UInt32 LightDetectionRadius;
|
||||
[MarshalAs(UnmanagedType.I1)] public bool AutoConfigureInput;
|
||||
|
||||
public ConsoleRegion Region;
|
||||
[MarshalAs(UnmanagedType.I1)] public bool EnableHdPacks;
|
||||
|
|
|
@ -136,7 +136,7 @@ namespace Mesen.Debugger.Utilities
|
|||
try {
|
||||
value();
|
||||
} catch(Exception ex) {
|
||||
MesenMsgBox.ShowException(ex);
|
||||
Dispatcher.UIThread.Post(() => MesenMsgBox.ShowException(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@ namespace Mesen.Interop
|
|||
[DllImport(DllPath)] public static extern void SetDebuggerFlag(DebuggerFlags flag, bool enabled);
|
||||
|
||||
[DllImport(DllPath)] public static extern ControllerType GetControllerType(int player);
|
||||
[DllImport(DllPath)] public static extern InteropNesConfig GetNesConfig();
|
||||
|
||||
[DllImport(DllPath, EntryPoint = "GetAudioDevices")] private static extern void GetAudioDevicesWrapper(IntPtr outDeviceList, Int32 maxSize);
|
||||
public unsafe static List<string> GetAudioDevices()
|
||||
|
|
|
@ -75,6 +75,7 @@ namespace Mesen.Interop
|
|||
BeforeGameUnload,
|
||||
BeforeGameLoad,
|
||||
GameLoadFailed,
|
||||
CheatsChanged
|
||||
CheatsChanged,
|
||||
RequestConfigChange
|
||||
}
|
||||
}
|
||||
|
|
|
@ -59,6 +59,7 @@
|
|||
<Form ID="NesInputConfigView">
|
||||
<Control ID="grpControllers">Controllers</Control>
|
||||
<Control ID="grpGeneral">General</Control>
|
||||
<Control ID="chkAutoConfigureInput">Automatically configure controllers when loading a game</Control>
|
||||
<Control ID="lblLightDetectionRadius">Light detection radius for light guns:</Control>
|
||||
<Control ID="lblSmall">Small</Control>
|
||||
<Control ID="lblLarge">Large</Control>
|
||||
|
|
|
@ -40,6 +40,10 @@ namespace Mesen.Utilities
|
|||
|
||||
private static void InternalLoadRom(ResourcePath romPath, ResourcePath? patchPath)
|
||||
{
|
||||
//Update core config before loading a new game to ensure any changes done
|
||||
//by the core (e.g ram init overrides for specific games) are reset
|
||||
ConfigManager.Config.ApplyConfig();
|
||||
|
||||
//Run in another thread to prevent deadlocks etc. when emulator notifications are processed UI-side
|
||||
Task.Run(() => {
|
||||
if(EmuApi.LoadRom(romPath, patchPath)) {
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using Mesen.Config;
|
||||
using Mesen.Controls;
|
||||
using Mesen.Interop;
|
||||
using Mesen.Utilities;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.Fody.Helpers;
|
||||
|
@ -14,6 +16,8 @@ namespace Mesen.ViewModels
|
|||
{
|
||||
public class NesConfigViewModel : DisposableViewModel
|
||||
{
|
||||
private NotificationListener? _listener = null;
|
||||
|
||||
[Reactive] public NesConfig Config { get; set; }
|
||||
|
||||
[Reactive] public bool ShowExpansionVolume { get; set; }
|
||||
|
@ -50,6 +54,9 @@ namespace Mesen.ViewModels
|
|||
return;
|
||||
}
|
||||
|
||||
_listener = AddDisposable(new NotificationListener());
|
||||
_listener.OnNotification += listener_OnNotification;
|
||||
|
||||
AddDisposable(Input);
|
||||
AddDisposable(ReactiveHelper.RegisterRecursiveObserver(Config, (s, e) => { Config.ApplyConfig(); }));
|
||||
AddDisposable(this.WhenAnyValue(x => x.Config.StereoFilter).Select(x => x == StereoFilter.Delay).ToPropertyEx(this, x => x.IsDelayStereoEffect));
|
||||
|
@ -57,6 +64,16 @@ namespace Mesen.ViewModels
|
|||
AddDisposable(this.WhenAnyValue(x => x.Config.StereoFilter).Select(x => x == StereoFilter.CombFilter).ToPropertyEx(this, x => x.IsCombStereoEffect));
|
||||
}
|
||||
|
||||
private void listener_OnNotification(NotificationEventArgs e)
|
||||
{
|
||||
if(e.NotificationType == ConsoleNotificationType.RequestConfigChange) {
|
||||
//Update configuration when game is loaded
|
||||
Dispatcher.UIThread.Post(() => {
|
||||
Config.UpdateInputFromCoreConfig();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void LoadPaletteFile(string filename)
|
||||
{
|
||||
using(FileStream paletteFile = File.OpenRead(filename)) {
|
||||
|
|
|
@ -148,7 +148,9 @@
|
|||
</Grid>
|
||||
</c:OptionSection>
|
||||
<c:OptionSection Header="{l:Translate grpGeneral}">
|
||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
|
||||
<CheckBox IsChecked="{CompiledBinding Config.AutoConfigureInput}" Content="{l:Translate chkAutoConfigureInput}" />
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto">
|
||||
<TextBlock Grid.Row="0" Text="{l:Translate lblLightDetectionRadius}" VerticalAlignment="Top" Margin="0 15 5 0" />
|
||||
<c:MesenSlider
|
||||
Margin="12 12 10 0"
|
||||
|
|
|
@ -216,6 +216,10 @@ namespace Mesen.Windows
|
|||
});
|
||||
break;
|
||||
|
||||
case ConsoleNotificationType.RequestConfigChange:
|
||||
UpdateInputConfiguration();
|
||||
break;
|
||||
|
||||
case ConsoleNotificationType.EmulationStopped:
|
||||
Dispatcher.UIThread.Post(() => {
|
||||
_model.RomInfo = new RomInfo();
|
||||
|
@ -251,6 +255,12 @@ namespace Mesen.Windows
|
|||
}
|
||||
}
|
||||
|
||||
private static void UpdateInputConfiguration()
|
||||
{
|
||||
//Used to update input devices when the core requests changes (NES-only for now)
|
||||
ConfigManager.Config.Nes.UpdateInputFromCoreConfig();
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
|
Loading…
Add table
Reference in a new issue