mirror of
https://github.com/hedge-dev/UnleashedRecomp.git
synced 2025-10-30 07:11:05 +00:00
Implemented controller LED timings for cutscenes (#83)
This commit is contained in:
parent
666f93843d
commit
63d474ce91
18 changed files with 262 additions and 23 deletions
|
|
@ -152,6 +152,7 @@ set(SWA_PATCHES_CXX_SOURCES
|
||||||
"patches/audio_patches.cpp"
|
"patches/audio_patches.cpp"
|
||||||
"patches/camera_patches.cpp"
|
"patches/camera_patches.cpp"
|
||||||
"patches/fps_patches.cpp"
|
"patches/fps_patches.cpp"
|
||||||
|
"patches/inspire_patches.cpp"
|
||||||
"patches/misc_patches.cpp"
|
"patches/misc_patches.cpp"
|
||||||
"patches/object_patches.cpp"
|
"patches/object_patches.cpp"
|
||||||
"patches/player_patches.cpp"
|
"patches/player_patches.cpp"
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,11 @@
|
||||||
#include "SWA/HUD/Sonic/HudSonicStage.h"
|
#include "SWA/HUD/Sonic/HudSonicStage.h"
|
||||||
#include "SWA/Inspire/InspireMovieOverlay.h"
|
#include "SWA/Inspire/InspireMovieOverlay.h"
|
||||||
#include "SWA/Inspire/InspireMovieOverlayInfo.h"
|
#include "SWA/Inspire/InspireMovieOverlayInfo.h"
|
||||||
|
#include "SWA/Inspire/InspireOpacityAnimationInfo.h"
|
||||||
|
#include "SWA/Inspire/InspireScene.h"
|
||||||
|
#include "SWA/Inspire/InspireSceneData.h"
|
||||||
|
#include "SWA/Inspire/InspireSceneInfo.h"
|
||||||
|
#include "SWA/Inspire/InspireTextureAnimationInfo.h"
|
||||||
#include "SWA/Inspire/InspireTextureOverlay.h"
|
#include "SWA/Inspire/InspireTextureOverlay.h"
|
||||||
#include "SWA/Inspire/InspireTextureOverlayInfo.h"
|
#include "SWA/Inspire/InspireTextureOverlayInfo.h"
|
||||||
#include "SWA/Movie/MovieDisplayer.h"
|
#include "SWA/Movie/MovieDisplayer.h"
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,10 @@ namespace SWA::Inspire
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
Hedgehog::Base::CSharedString m_MovieName;
|
Hedgehog::Base::CSharedString m_MovieName;
|
||||||
be<float> m_StartTime;
|
be<float> m_Prepare;
|
||||||
be<float> m_FadeInStartTime;
|
be<float> m_InStart;
|
||||||
be<float> m_FadeInEndTime;
|
be<float> m_InEnd;
|
||||||
be<float> m_FadeOutStartTime;
|
be<float> m_OutStart;
|
||||||
be<float> m_FadeOutEndTime;
|
be<float> m_OutEnd;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <SWA.inl>
|
||||||
|
|
||||||
|
namespace SWA::Inspire
|
||||||
|
{
|
||||||
|
class COpacityAnimationInfo
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
be<float> m_Opacity;
|
||||||
|
be<float> m_Frame;
|
||||||
|
};
|
||||||
|
}
|
||||||
21
UnleashedRecomp/api/SWA/Inspire/InspireScene.h
Normal file
21
UnleashedRecomp/api/SWA/Inspire/InspireScene.h
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <SWA.inl>
|
||||||
|
|
||||||
|
namespace SWA::Inspire
|
||||||
|
{
|
||||||
|
struct SSceneData
|
||||||
|
{
|
||||||
|
be<float> Frame;
|
||||||
|
be<uint32_t> Cut;
|
||||||
|
bool IsPlaying;
|
||||||
|
SWA_INSERT_PADDING(0x177);
|
||||||
|
};
|
||||||
|
|
||||||
|
class CScene
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
SWA_INSERT_PADDING(0xC0);
|
||||||
|
xpointer<SSceneData> m_pData;
|
||||||
|
};
|
||||||
|
}
|
||||||
13
UnleashedRecomp/api/SWA/Inspire/InspireSceneData.h
Normal file
13
UnleashedRecomp/api/SWA/Inspire/InspireSceneData.h
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <SWA.inl>
|
||||||
|
|
||||||
|
namespace SWA::Inspire
|
||||||
|
{
|
||||||
|
class CSceneData // : public Hedgehog::Database::CDatabaseData
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
SWA_INSERT_PADDING(0x80);
|
||||||
|
Hedgehog::Base::CSharedString m_ResourceName;
|
||||||
|
};
|
||||||
|
}
|
||||||
8
UnleashedRecomp/api/SWA/Inspire/InspireSceneInfo.h
Normal file
8
UnleashedRecomp/api/SWA/Inspire/InspireSceneInfo.h
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <SWA.inl>
|
||||||
|
|
||||||
|
namespace SWA::Inspire
|
||||||
|
{
|
||||||
|
class CSceneInfo {};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <SWA.inl>
|
||||||
|
|
||||||
|
namespace SWA::Inspire
|
||||||
|
{
|
||||||
|
class CTextureAnimationInfo
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
SWA_INSERT_PADDING(0x10);
|
||||||
|
Hedgehog::Base::CSharedString m_MovieTex;
|
||||||
|
Hedgehog::Base::CSharedString m_MovieSfd;
|
||||||
|
SWA_INSERT_PADDING(0x08);
|
||||||
|
be<float> m_Prepare;
|
||||||
|
be<float> m_Start;
|
||||||
|
be<float> m_End;
|
||||||
|
be<float> m_Width;
|
||||||
|
be<float> m_Height;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ namespace SWA::Inspire
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
xpointer<void> m_pVftable;
|
xpointer<void> m_pVftable;
|
||||||
boost::shared_ptr<CInspireTextureOverlayInfo> m_spInfo;
|
boost::shared_ptr<CTextureOverlayInfo> m_spInfo;
|
||||||
xpointer<CScene> m_pScene;
|
xpointer<CScene> m_pScene;
|
||||||
boost::shared_ptr<Hedgehog::Mirage::CTextureData> m_spTextureData;
|
boost::shared_ptr<Hedgehog::Mirage::CTextureData> m_spTextureData;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@
|
||||||
|
|
||||||
namespace SWA::Inspire
|
namespace SWA::Inspire
|
||||||
{
|
{
|
||||||
class CInspireTextureOverlayInfo
|
class CTextureOverlayInfo
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
Hedgehog::Base::CSharedString m_CameraName;
|
Hedgehog::Base::CSharedString m_Picture;
|
||||||
be<uint32_t> m_Unk1;
|
be<uint32_t> m_Start;
|
||||||
be<uint32_t> m_Unk2;
|
be<uint32_t> m_End;
|
||||||
be<uint32_t> m_Unk3;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,9 @@
|
||||||
|
|
||||||
namespace SWA::Sequence::Unit
|
namespace SWA::Sequence::Unit
|
||||||
{
|
{
|
||||||
class CPlayMovieUnit : public CUnitBase {};
|
class CPlayMovieUnit : public CUnitBase
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
Hedgehog::Base::CSharedString m_SceneName;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,16 @@ namespace SWA::Sequence::Utility
|
||||||
SVertexData m_TopRight;
|
SVertexData m_TopRight;
|
||||||
SVertexData m_BottomRight;
|
SVertexData m_BottomRight;
|
||||||
SVertexData m_BottomLeft;
|
SVertexData m_BottomLeft;
|
||||||
bool m_MaintainAspectRatio;
|
bool m_Field1A4;
|
||||||
SWA_INSERT_PADDING(0x18);
|
SWA_INSERT_PADDING(0x18);
|
||||||
be<float> m_TimeElapsed;
|
be<float> m_TimeElapsed;
|
||||||
};
|
};
|
||||||
|
|
||||||
SWA_INSERT_PADDING(0x18);
|
xpointer<void> m_pVftable;
|
||||||
|
Hedgehog::Base::CSharedString m_SceneName;
|
||||||
|
SWA_INSERT_PADDING(0x10);
|
||||||
xpointer<CRender> m_pRender;
|
xpointer<CRender> m_pRender;
|
||||||
|
SWA_INSERT_PADDING(0x04);
|
||||||
|
xpointer<Hedgehog::Base::CSharedString> m_pResourceName;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@
|
||||||
#include <gpu/video.h>
|
#include <gpu/video.h>
|
||||||
#include <install/installer.h>
|
#include <install/installer.h>
|
||||||
#include <kernel/function.h>
|
#include <kernel/function.h>
|
||||||
#include <ui/game_window.h>
|
|
||||||
#include <patches/audio_patches.h>
|
|
||||||
#include <user/config.h>
|
|
||||||
#include <os/process.h>
|
#include <os/process.h>
|
||||||
|
#include <patches/audio_patches.h>
|
||||||
|
#include <patches/inspire_patches.h>
|
||||||
|
#include <ui/game_window.h>
|
||||||
|
#include <user/config.h>
|
||||||
|
|
||||||
void App::Restart(std::vector<std::string> restartArgs)
|
void App::Restart(std::vector<std::string> restartArgs)
|
||||||
{
|
{
|
||||||
|
|
@ -24,7 +25,7 @@ void App::Exit()
|
||||||
std::_Exit(0);
|
std::_Exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CApplication::Ctor
|
// SWA::CApplication
|
||||||
PPC_FUNC_IMPL(__imp__sub_824EB490);
|
PPC_FUNC_IMPL(__imp__sub_824EB490);
|
||||||
PPC_FUNC(sub_824EB490)
|
PPC_FUNC(sub_824EB490)
|
||||||
{
|
{
|
||||||
|
|
@ -37,7 +38,7 @@ PPC_FUNC(sub_824EB490)
|
||||||
|
|
||||||
static std::thread::id g_mainThreadId = std::this_thread::get_id();
|
static std::thread::id g_mainThreadId = std::this_thread::get_id();
|
||||||
|
|
||||||
// CApplication::Update
|
// SWA::CApplication::Update
|
||||||
PPC_FUNC_IMPL(__imp__sub_822C1130);
|
PPC_FUNC_IMPL(__imp__sub_822C1130);
|
||||||
PPC_FUNC(sub_822C1130)
|
PPC_FUNC(sub_822C1130)
|
||||||
{
|
{
|
||||||
|
|
@ -47,6 +48,7 @@ PPC_FUNC(sub_822C1130)
|
||||||
if (Config::FPS >= FPS_MIN && Config::FPS < FPS_MAX)
|
if (Config::FPS >= FPS_MIN && Config::FPS < FPS_MAX)
|
||||||
{
|
{
|
||||||
double targetDeltaTime = 1.0 / Config::FPS;
|
double targetDeltaTime = 1.0 / Config::FPS;
|
||||||
|
|
||||||
if (abs(ctx.f1.f64 - targetDeltaTime) < 0.00001)
|
if (abs(ctx.f1.f64 - targetDeltaTime) < 0.00001)
|
||||||
ctx.f1.f64 = targetDeltaTime;
|
ctx.f1.f64 = targetDeltaTime;
|
||||||
}
|
}
|
||||||
|
|
@ -65,6 +67,7 @@ PPC_FUNC(sub_822C1130)
|
||||||
|
|
||||||
GameWindow::Update();
|
GameWindow::Update();
|
||||||
AudioPatches::Update(App::s_deltaTime);
|
AudioPatches::Update(App::s_deltaTime);
|
||||||
|
InspirePatches::Update();
|
||||||
|
|
||||||
__imp__sub_822C1130(ctx, base);
|
__imp__sub_822C1130(ctx, base);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ public:
|
||||||
static inline bool s_isInit;
|
static inline bool s_isInit;
|
||||||
static inline bool s_isMissingDLC;
|
static inline bool s_isMissingDLC;
|
||||||
static inline bool s_isLoading;
|
static inline bool s_isLoading;
|
||||||
|
static inline bool s_isWerehog;
|
||||||
|
|
||||||
static inline ELanguage s_language;
|
static inline ELanguage s_language;
|
||||||
|
|
||||||
|
|
|
||||||
134
UnleashedRecomp/patches/inspire_patches.cpp
Normal file
134
UnleashedRecomp/patches/inspire_patches.cpp
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
#include "inspire_patches.h"
|
||||||
|
#include <api/SWA.h>
|
||||||
|
#include <ui/game_window.h>
|
||||||
|
#include <ui/window_events.h>
|
||||||
|
#include <os/logger.h>
|
||||||
|
#include <app.h>
|
||||||
|
|
||||||
|
static SWA::Inspire::CScene* g_pScene;
|
||||||
|
static std::string g_sceneName;
|
||||||
|
static bool g_isFirstFrameChecked;
|
||||||
|
static uint32_t g_eventDispatchCount;
|
||||||
|
|
||||||
|
static std::array<std::string_view, 8> g_alwaysEvilSonic =
|
||||||
|
{
|
||||||
|
"evrt_m2_02", // Same As Ever
|
||||||
|
"evrt_s1_05", // Chun-nan Temple
|
||||||
|
"evrt_s3_04", // Holoskan Temple
|
||||||
|
"evrt_t0_02", // Shamaran Temple
|
||||||
|
"evrt_m7_02", // The Final Temple
|
||||||
|
"evrt_m7_04", // Congratulations
|
||||||
|
"evrt_m8_02", // The Egg Dragoon
|
||||||
|
"evrt_m8_03" // Planet's End
|
||||||
|
};
|
||||||
|
|
||||||
|
static std::unordered_map<std::string_view, std::pair<float, float>> g_evilSonicTimings =
|
||||||
|
{
|
||||||
|
{ "evrt_m0_01_05", { 8189.97f, 10821 } }, // Opening
|
||||||
|
{ "evrt_m0_06", { 0, 5104.07f } }, // A New Journey
|
||||||
|
{ "evrt_m1_02", { 1162.46f, 3513 } }, // The First Night
|
||||||
|
{ "evrt_m6_03", { 2445, 5744 } }, // No Reason
|
||||||
|
{ "evrt_m8_04", { 0, 2314 } } // Dark Gaia Appears
|
||||||
|
};
|
||||||
|
|
||||||
|
// SWA::Inspire::CScene
|
||||||
|
PPC_FUNC_IMPL(__imp__sub_82B98D80);
|
||||||
|
PPC_FUNC(sub_82B98D80)
|
||||||
|
{
|
||||||
|
__imp__sub_82B98D80(ctx, base);
|
||||||
|
|
||||||
|
g_pScene = (SWA::Inspire::CScene*)g_memory.Translate(ctx.r3.u32);
|
||||||
|
g_isFirstFrameChecked = false;
|
||||||
|
g_eventDispatchCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ~SWA::Inspire::CScene
|
||||||
|
PPC_FUNC_IMPL(__imp__sub_82B98D30);
|
||||||
|
PPC_FUNC(sub_82B98D30)
|
||||||
|
{
|
||||||
|
__imp__sub_82B98D30(ctx, base);
|
||||||
|
|
||||||
|
g_pScene = nullptr;
|
||||||
|
g_sceneName.clear();
|
||||||
|
|
||||||
|
SDL_User_EvilSonic(App::s_isWerehog);
|
||||||
|
}
|
||||||
|
|
||||||
|
PPC_FUNC_IMPL(__imp__sub_82B9BA98);
|
||||||
|
PPC_FUNC(sub_82B9BA98)
|
||||||
|
{
|
||||||
|
auto sceneName = (Hedgehog::Base::CSharedString*)g_memory.Translate(ctx.r5.u32);
|
||||||
|
|
||||||
|
g_sceneName = sceneName->c_str();
|
||||||
|
|
||||||
|
__imp__sub_82B9BA98(ctx, base);
|
||||||
|
}
|
||||||
|
|
||||||
|
void InspirePatches::DrawDebug()
|
||||||
|
{
|
||||||
|
if (!g_pScene)
|
||||||
|
{
|
||||||
|
ImGui::Text("There is no active scene.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::Text("Name: %s", g_sceneName.c_str());
|
||||||
|
ImGui::Text("Frame: %f", g_pScene->m_pData->Frame.get());
|
||||||
|
ImGui::Text("Cut: %d", g_pScene->m_pData->Cut.get());
|
||||||
|
|
||||||
|
static std::vector<float> g_loggedFrames{};
|
||||||
|
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
|
if (ImGui::Button("Log"))
|
||||||
|
g_loggedFrames.push_back(g_pScene->m_pData->Frame);
|
||||||
|
|
||||||
|
if (ImGui::Button("Clear"))
|
||||||
|
g_loggedFrames.clear();
|
||||||
|
|
||||||
|
if (g_loggedFrames.size())
|
||||||
|
{
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
|
for (auto& frame : g_loggedFrames)
|
||||||
|
ImGui::Text("%f", frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void InspirePatches::Update()
|
||||||
|
{
|
||||||
|
if (!g_pScene || !g_sceneName.size())
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!g_isFirstFrameChecked && std::find(g_alwaysEvilSonic.begin(), g_alwaysEvilSonic.end(), g_sceneName) != g_alwaysEvilSonic.end())
|
||||||
|
{
|
||||||
|
SDL_User_EvilSonic(true);
|
||||||
|
g_isFirstFrameChecked = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto findResult = g_evilSonicTimings.find(g_sceneName);
|
||||||
|
|
||||||
|
if (findResult != g_evilSonicTimings.end())
|
||||||
|
{
|
||||||
|
auto& timings = findResult->second;
|
||||||
|
auto& frame = g_pScene->m_pData->Frame;
|
||||||
|
|
||||||
|
if (!g_isFirstFrameChecked && timings.first > 0)
|
||||||
|
{
|
||||||
|
SDL_User_EvilSonic(false);
|
||||||
|
g_isFirstFrameChecked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!g_eventDispatchCount && (frame > timings.first && frame < timings.second))
|
||||||
|
{
|
||||||
|
SDL_User_EvilSonic(true);
|
||||||
|
g_eventDispatchCount++;
|
||||||
|
}
|
||||||
|
else if (g_eventDispatchCount == 1 && frame > timings.second)
|
||||||
|
{
|
||||||
|
SDL_User_EvilSonic(false);
|
||||||
|
g_eventDispatchCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
UnleashedRecomp/patches/inspire_patches.h
Normal file
8
UnleashedRecomp/patches/inspire_patches.h
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
class InspirePatches
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static void DrawDebug();
|
||||||
|
static void Update();
|
||||||
|
};
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
#include <ui/window_events.h>
|
#include <ui/window_events.h>
|
||||||
#include <user/config.h>
|
#include <user/config.h>
|
||||||
#include <os/logger.h>
|
#include <os/logger.h>
|
||||||
|
#include <app.h>
|
||||||
|
|
||||||
static uint32_t g_lastEnemyScore;
|
static uint32_t g_lastEnemyScore;
|
||||||
static uint32_t g_lastTrickScore;
|
static uint32_t g_lastTrickScore;
|
||||||
|
|
@ -122,20 +123,24 @@ void SetXButtonHomingMidAsmHook(PPCRegister& r30)
|
||||||
r30.u32 = Config::HomingAttackOnBoost;
|
r30.u32 = Config::HomingAttackOnBoost;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SWA::Player::CEvilSonicContext::Ctor
|
// SWA::Player::CEvilSonicContext
|
||||||
PPC_FUNC_IMPL(__imp__sub_823B49D8);
|
PPC_FUNC_IMPL(__imp__sub_823B49D8);
|
||||||
PPC_FUNC(sub_823B49D8)
|
PPC_FUNC(sub_823B49D8)
|
||||||
{
|
{
|
||||||
__imp__sub_823B49D8(ctx, base);
|
__imp__sub_823B49D8(ctx, base);
|
||||||
|
|
||||||
|
App::s_isWerehog = true;
|
||||||
|
|
||||||
SDL_User_EvilSonic(true);
|
SDL_User_EvilSonic(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SWA::Player::CEvilSonicContext::Dtor
|
// ~SWA::Player::CEvilSonicContext
|
||||||
PPC_FUNC_IMPL(__imp__sub_823B4590);
|
PPC_FUNC_IMPL(__imp__sub_823B4590);
|
||||||
PPC_FUNC(sub_823B4590)
|
PPC_FUNC(sub_823B4590)
|
||||||
{
|
{
|
||||||
__imp__sub_823B4590(ctx, base);
|
__imp__sub_823B4590(ctx, base);
|
||||||
|
|
||||||
|
App::s_isWerehog = false;
|
||||||
|
|
||||||
SDL_User_EvilSonic(false);
|
SDL_User_EvilSonic(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,11 @@ inline void SDL_MoveEvent(SDL_Window* pWindow, int x, int y)
|
||||||
SDL_PushEvent(&event);
|
SDL_PushEvent(&event);
|
||||||
}
|
}
|
||||||
|
|
||||||
inline void SDL_User_EvilSonic(bool isCtor)
|
inline void SDL_User_EvilSonic(bool isEvil)
|
||||||
{
|
{
|
||||||
SDL_Event event{};
|
SDL_Event event{};
|
||||||
event.type = SDL_USER_EVILSONIC;
|
event.type = SDL_USER_EVILSONIC;
|
||||||
event.user.code = isCtor;
|
event.user.code = isEvil;
|
||||||
|
|
||||||
SDL_PushEvent(&event);
|
SDL_PushEvent(&event);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue