mirror of
https://gitlab.com/OpenMW/openmw.git
synced 2025-04-28 21:07:59 +03:00
Merge branch 'esm4sound' into 'master'
Some checks failed
Build and test / Ubuntu (push) Has been cancelled
Build and test / MacOS (push) Has been cancelled
Build and test / Read .env file and expose it as output (push) Has been cancelled
Build and test / Windows (2019) (push) Has been cancelled
Build and test / Windows (2022) (push) Has been cancelled
Some checks failed
Build and test / Ubuntu (push) Has been cancelled
Build and test / MacOS (push) Has been cancelled
Build and test / Read .env file and expose it as output (push) Has been cancelled
Build and test / Windows (2019) (push) Has been cancelled
Build and test / Windows (2022) (push) Has been cancelled
Initial support of ESM4 sounds See merge request OpenMW/openmw!3784
This commit is contained in:
commit
04d492be4f
11 changed files with 96 additions and 28 deletions
|
@ -26,6 +26,15 @@ namespace
|
||||||
std::unique_ptr<VFS::Manager> mVFS = TestingOpenMW::createTestVFS({});
|
std::unique_ptr<VFS::Manager> mVFS = TestingOpenMW::createTestVFS({});
|
||||||
constexpr VFS::Path::NormalizedView path("sound/foo.wav");
|
constexpr VFS::Path::NormalizedView path("sound/foo.wav");
|
||||||
EXPECT_EQ(correctSoundPath(path, *mVFS), "sound/foo.mp3");
|
EXPECT_EQ(correctSoundPath(path, *mVFS), "sound/foo.mp3");
|
||||||
|
|
||||||
|
auto correctESM4SoundPath = [](auto path, auto* vfs) {
|
||||||
|
return Misc::ResourceHelpers::correctResourcePath({ { "sound" } }, path, vfs, ".mp3");
|
||||||
|
};
|
||||||
|
|
||||||
|
EXPECT_EQ(correctESM4SoundPath("foo.WAV", mVFS.get()), "sound\\foo.mp3");
|
||||||
|
EXPECT_EQ(correctESM4SoundPath("SOUND/foo.WAV", mVFS.get()), "sound\\foo.mp3");
|
||||||
|
EXPECT_EQ(correctESM4SoundPath("DATA\\SOUND\\foo.WAV", mVFS.get()), "sound\\foo.mp3");
|
||||||
|
EXPECT_EQ(correctESM4SoundPath("\\Data/Sound\\foo.WAV", mVFS.get()), "sound\\foo.mp3");
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
|
|
|
@ -149,5 +149,9 @@ namespace MWLua
|
||||||
addModelProperty(record);
|
addModelProperty(record);
|
||||||
record["isAutomatic"] = sol::readonly_property(
|
record["isAutomatic"] = sol::readonly_property(
|
||||||
[](const ESM4::Door& rec) -> bool { return rec.mDoorFlags & ESM4::Door::Flag_AutomaticDoor; });
|
[](const ESM4::Door& rec) -> bool { return rec.mDoorFlags & ESM4::Door::Flag_AutomaticDoor; });
|
||||||
|
record["openSound"] = sol::readonly_property(
|
||||||
|
[](const ESM4::Door& rec) -> std::string { return ESM::RefId(rec.mOpenSound).serializeText(); });
|
||||||
|
record["closeSound"] = sol::readonly_property(
|
||||||
|
[](const ESM4::Door& rec) -> std::string { return ESM::RefId(rec.mCloseSound).serializeText(); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,10 @@
|
||||||
|
|
||||||
#include <components/debug/debuglog.hpp>
|
#include <components/debug/debuglog.hpp>
|
||||||
#include <components/esm3/loadsoun.hpp>
|
#include <components/esm3/loadsoun.hpp>
|
||||||
|
#include <components/esm4/loadsndr.hpp>
|
||||||
|
#include <components/esm4/loadsoun.hpp>
|
||||||
#include <components/misc/resourcehelpers.hpp>
|
#include <components/misc/resourcehelpers.hpp>
|
||||||
|
#include <components/resource/resourcesystem.hpp>
|
||||||
#include <components/settings/values.hpp>
|
#include <components/settings/values.hpp>
|
||||||
#include <components/vfs/pathutil.hpp>
|
#include <components/vfs/pathutil.hpp>
|
||||||
|
|
||||||
|
@ -99,7 +102,12 @@ namespace MWSound
|
||||||
{
|
{
|
||||||
if (mBufferNameMap.empty())
|
if (mBufferNameMap.empty())
|
||||||
{
|
{
|
||||||
for (const ESM::Sound& sound : MWBase::Environment::get().getESMStore()->get<ESM::Sound>())
|
const MWWorld::ESMStore* esmstore = MWBase::Environment::get().getESMStore();
|
||||||
|
for (const ESM::Sound& sound : esmstore->get<ESM::Sound>())
|
||||||
|
insertSound(sound.mId, sound);
|
||||||
|
for (const ESM4::Sound& sound : esmstore->get<ESM4::Sound>())
|
||||||
|
insertSound(sound.mId, sound);
|
||||||
|
for (const ESM4::SoundReference& sound : esmstore->get<ESM4::SoundReference>())
|
||||||
insertSound(sound.mId, sound);
|
insertSound(sound.mId, sound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -190,6 +198,28 @@ namespace MWSound
|
||||||
return &sfx;
|
return &sfx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Sound_Buffer* SoundBufferPool::insertSound(const ESM::RefId& soundId, const ESM4::Sound& sound)
|
||||||
|
{
|
||||||
|
std::string path = Misc::ResourceHelpers::correctResourcePath(
|
||||||
|
{ { "sound" } }, sound.mSoundFile, MWBase::Environment::get().getResourceSystem()->getVFS(), ".mp3");
|
||||||
|
float volume = 1, min = 1, max = 255; // TODO: needs research
|
||||||
|
Sound_Buffer& sfx = mSoundBuffers.emplace_back(VFS::Path::Normalized(std::move(path)), volume, min, max);
|
||||||
|
mBufferNameMap.emplace(soundId, &sfx);
|
||||||
|
return &sfx;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sound_Buffer* SoundBufferPool::insertSound(const ESM::RefId& soundId, const ESM4::SoundReference& sound)
|
||||||
|
{
|
||||||
|
std::string path = Misc::ResourceHelpers::correctResourcePath(
|
||||||
|
{ { "sound" } }, sound.mSoundFile, MWBase::Environment::get().getResourceSystem()->getVFS(), ".mp3");
|
||||||
|
float volume = 1, min = 1, max = 255; // TODO: needs research
|
||||||
|
// TODO: sound.mSoundId can link to another SoundReference, probably we will need to add additional lookups to
|
||||||
|
// ESMStore.
|
||||||
|
Sound_Buffer& sfx = mSoundBuffers.emplace_back(VFS::Path::Normalized(std::move(path)), volume, min, max);
|
||||||
|
mBufferNameMap.emplace(soundId, &sfx);
|
||||||
|
return &sfx;
|
||||||
|
}
|
||||||
|
|
||||||
void SoundBufferPool::unloadUnused()
|
void SoundBufferPool::unloadUnused()
|
||||||
{
|
{
|
||||||
while (!mUnusedBuffers.empty() && mBufferCacheSize > mBufferCacheMin)
|
while (!mUnusedBuffers.empty() && mBufferCacheSize > mBufferCacheMin)
|
||||||
|
|
|
@ -14,6 +14,12 @@ namespace ESM
|
||||||
struct Sound;
|
struct Sound;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace ESM4
|
||||||
|
{
|
||||||
|
struct Sound;
|
||||||
|
struct SoundReference;
|
||||||
|
}
|
||||||
|
|
||||||
namespace VFS
|
namespace VFS
|
||||||
{
|
{
|
||||||
class Manager;
|
class Manager;
|
||||||
|
@ -111,10 +117,12 @@ namespace MWSound
|
||||||
// NOTE: unused buffers are stored in front-newest order.
|
// NOTE: unused buffers are stored in front-newest order.
|
||||||
std::deque<Sound_Buffer*> mUnusedBuffers;
|
std::deque<Sound_Buffer*> mUnusedBuffers;
|
||||||
|
|
||||||
inline Sound_Buffer* insertSound(const ESM::RefId& soundId, const ESM::Sound& sound);
|
Sound_Buffer* insertSound(const ESM::RefId& soundId, const ESM::Sound& sound);
|
||||||
inline Sound_Buffer* insertSound(std::string_view fileName);
|
Sound_Buffer* insertSound(const ESM::RefId& soundId, const ESM4::Sound& sound);
|
||||||
|
Sound_Buffer* insertSound(const ESM::RefId& soundId, const ESM4::SoundReference& sound);
|
||||||
|
Sound_Buffer* insertSound(std::string_view fileName);
|
||||||
|
|
||||||
inline void unloadUnused();
|
void unloadUnused();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -105,6 +105,8 @@ namespace ESM4
|
||||||
struct Potion;
|
struct Potion;
|
||||||
struct Race;
|
struct Race;
|
||||||
struct Reference;
|
struct Reference;
|
||||||
|
struct Sound;
|
||||||
|
struct SoundReference;
|
||||||
struct Static;
|
struct Static;
|
||||||
struct StaticCollection;
|
struct StaticCollection;
|
||||||
struct Terminal;
|
struct Terminal;
|
||||||
|
@ -146,8 +148,8 @@ namespace MWWorld
|
||||||
Store<ESM4::Land>, Store<ESM4::LandTexture>, Store<ESM4::LevelledCreature>, Store<ESM4::LevelledItem>,
|
Store<ESM4::Land>, Store<ESM4::LandTexture>, Store<ESM4::LevelledCreature>, Store<ESM4::LevelledItem>,
|
||||||
Store<ESM4::LevelledNpc>, Store<ESM4::Light>, Store<ESM4::MiscItem>, Store<ESM4::MovableStatic>,
|
Store<ESM4::LevelledNpc>, Store<ESM4::Light>, Store<ESM4::MiscItem>, Store<ESM4::MovableStatic>,
|
||||||
Store<ESM4::Npc>, Store<ESM4::Outfit>, Store<ESM4::Potion>, Store<ESM4::Race>, Store<ESM4::Reference>,
|
Store<ESM4::Npc>, Store<ESM4::Outfit>, Store<ESM4::Potion>, Store<ESM4::Race>, Store<ESM4::Reference>,
|
||||||
Store<ESM4::Static>, Store<ESM4::StaticCollection>, Store<ESM4::Terminal>, Store<ESM4::Tree>,
|
Store<ESM4::Sound>, Store<ESM4::SoundReference>, Store<ESM4::Static>, Store<ESM4::StaticCollection>,
|
||||||
Store<ESM4::Weapon>, Store<ESM4::World>>;
|
Store<ESM4::Terminal>, Store<ESM4::Tree>, Store<ESM4::Weapon>, Store<ESM4::World>>;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
template <typename T>
|
template <typename T>
|
||||||
|
|
|
@ -1349,6 +1349,8 @@ template class MWWorld::TypedDynamicStore<ESM4::Npc>;
|
||||||
template class MWWorld::TypedDynamicStore<ESM4::Outfit>;
|
template class MWWorld::TypedDynamicStore<ESM4::Outfit>;
|
||||||
template class MWWorld::TypedDynamicStore<ESM4::Potion>;
|
template class MWWorld::TypedDynamicStore<ESM4::Potion>;
|
||||||
template class MWWorld::TypedDynamicStore<ESM4::Race>;
|
template class MWWorld::TypedDynamicStore<ESM4::Race>;
|
||||||
|
template class MWWorld::TypedDynamicStore<ESM4::Sound>;
|
||||||
|
template class MWWorld::TypedDynamicStore<ESM4::SoundReference>;
|
||||||
template class MWWorld::TypedDynamicStore<ESM4::Static>;
|
template class MWWorld::TypedDynamicStore<ESM4::Static>;
|
||||||
template class MWWorld::TypedDynamicStore<ESM4::StaticCollection>;
|
template class MWWorld::TypedDynamicStore<ESM4::StaticCollection>;
|
||||||
template class MWWorld::TypedDynamicStore<ESM4::Terminal>;
|
template class MWWorld::TypedDynamicStore<ESM4::Terminal>;
|
||||||
|
|
|
@ -74,6 +74,8 @@
|
||||||
#include <components/esm4/loadrace.hpp>
|
#include <components/esm4/loadrace.hpp>
|
||||||
#include <components/esm4/loadrefr.hpp>
|
#include <components/esm4/loadrefr.hpp>
|
||||||
#include <components/esm4/loadscol.hpp>
|
#include <components/esm4/loadscol.hpp>
|
||||||
|
#include <components/esm4/loadsndr.hpp>
|
||||||
|
#include <components/esm4/loadsoun.hpp>
|
||||||
#include <components/esm4/loadstat.hpp>
|
#include <components/esm4/loadstat.hpp>
|
||||||
#include <components/esm4/loadterm.hpp>
|
#include <components/esm4/loadterm.hpp>
|
||||||
#include <components/esm4/loadtree.hpp>
|
#include <components/esm4/loadtree.hpp>
|
||||||
|
|
|
@ -33,14 +33,10 @@ bool Misc::ResourceHelpers::changeExtensionToDds(std::string& path)
|
||||||
return changeExtension(path, ".dds");
|
return changeExtension(path, ".dds");
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string Misc::ResourceHelpers::correctResourcePath(
|
// If `ext` is not empty we first search file with extension `ext`, then if not found fallback to original extension.
|
||||||
std::span<const std::string_view> topLevelDirectories, std::string_view resPath, const VFS::Manager* vfs)
|
std::string Misc::ResourceHelpers::correctResourcePath(std::span<const std::string_view> topLevelDirectories,
|
||||||
|
std::string_view resPath, const VFS::Manager* vfs, std::string_view ext)
|
||||||
{
|
{
|
||||||
/* Bethesda at some point converted all their BSA
|
|
||||||
* textures from tga to dds for increased load speed, but all
|
|
||||||
* texture file name references were kept as .tga.
|
|
||||||
*/
|
|
||||||
|
|
||||||
std::string correctedPath = Misc::StringUtils::lowerCase(resPath);
|
std::string correctedPath = Misc::StringUtils::lowerCase(resPath);
|
||||||
|
|
||||||
// Flatten slashes
|
// Flatten slashes
|
||||||
|
@ -80,14 +76,14 @@ std::string Misc::ResourceHelpers::correctResourcePath(
|
||||||
|
|
||||||
std::string origExt = correctedPath;
|
std::string origExt = correctedPath;
|
||||||
|
|
||||||
// since we know all (GOTY edition or less) textures end
|
// replace extension if `ext` is specified (used for .tga -> .dds, .wav -> .mp3)
|
||||||
// in .dds, we change the extension
|
bool isExtChanged = !ext.empty() && changeExtension(correctedPath, ext);
|
||||||
bool changedToDds = changeExtensionToDds(correctedPath);
|
|
||||||
if (vfs->exists(correctedPath))
|
if (vfs->exists(correctedPath))
|
||||||
return correctedPath;
|
return correctedPath;
|
||||||
// if it turns out that the above wasn't true in all cases (not for vanilla, but maybe mods)
|
|
||||||
// verify, and revert if false (this call succeeds quickly, but fails slowly)
|
// fall back to original extension
|
||||||
if (changedToDds && vfs->exists(origExt))
|
if (isExtChanged && vfs->exists(origExt))
|
||||||
return origExt;
|
return origExt;
|
||||||
|
|
||||||
// fall back to a resource in the top level directory if it exists
|
// fall back to a resource in the top level directory if it exists
|
||||||
|
@ -98,7 +94,7 @@ std::string Misc::ResourceHelpers::correctResourcePath(
|
||||||
if (vfs->exists(fallback))
|
if (vfs->exists(fallback))
|
||||||
return fallback;
|
return fallback;
|
||||||
|
|
||||||
if (changedToDds)
|
if (isExtChanged)
|
||||||
{
|
{
|
||||||
fallback = topLevelDirectories.front();
|
fallback = topLevelDirectories.front();
|
||||||
fallback += '\\';
|
fallback += '\\';
|
||||||
|
@ -110,19 +106,23 @@ std::string Misc::ResourceHelpers::correctResourcePath(
|
||||||
return correctedPath;
|
return correctedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: Bethesda at some point converted all their BSA textures from tga to dds for increased load speed,
|
||||||
|
// but all texture file name references were kept as .tga. So we pass ext=".dds" to all helpers
|
||||||
|
// looking for textures.
|
||||||
|
|
||||||
std::string Misc::ResourceHelpers::correctTexturePath(std::string_view resPath, const VFS::Manager* vfs)
|
std::string Misc::ResourceHelpers::correctTexturePath(std::string_view resPath, const VFS::Manager* vfs)
|
||||||
{
|
{
|
||||||
return correctResourcePath({ { "textures", "bookart" } }, resPath, vfs);
|
return correctResourcePath({ { "textures", "bookart" } }, resPath, vfs, ".dds");
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string Misc::ResourceHelpers::correctIconPath(std::string_view resPath, const VFS::Manager* vfs)
|
std::string Misc::ResourceHelpers::correctIconPath(std::string_view resPath, const VFS::Manager* vfs)
|
||||||
{
|
{
|
||||||
return correctResourcePath({ { "icons" } }, resPath, vfs);
|
return correctResourcePath({ { "icons" } }, resPath, vfs, ".dds");
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string Misc::ResourceHelpers::correctBookartPath(std::string_view resPath, const VFS::Manager* vfs)
|
std::string Misc::ResourceHelpers::correctBookartPath(std::string_view resPath, const VFS::Manager* vfs)
|
||||||
{
|
{
|
||||||
return correctResourcePath({ { "bookart", "textures" } }, resPath, vfs);
|
return correctResourcePath({ { "bookart", "textures" } }, resPath, vfs, ".dds");
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string Misc::ResourceHelpers::correctBookartPath(
|
std::string Misc::ResourceHelpers::correctBookartPath(
|
||||||
|
@ -199,6 +199,12 @@ std::string_view Misc::ResourceHelpers::meshPathForESM3(std::string_view resPath
|
||||||
VFS::Path::Normalized Misc::ResourceHelpers::correctSoundPath(
|
VFS::Path::Normalized Misc::ResourceHelpers::correctSoundPath(
|
||||||
VFS::Path::NormalizedView resPath, const VFS::Manager& vfs)
|
VFS::Path::NormalizedView resPath, const VFS::Manager& vfs)
|
||||||
{
|
{
|
||||||
|
// Note: likely should be replaced with
|
||||||
|
// return correctResourcePath({ { "sound" } }, resPath, vfs, ".mp3");
|
||||||
|
// but there is a slight difference in behaviour:
|
||||||
|
// - `correctResourcePath(..., ".mp3")` first checks `.mp3`, then tries the original extension
|
||||||
|
// - the implementation below first tries the original extension, then falls back to `.mp3`.
|
||||||
|
|
||||||
// Workaround: Bethesda at some point converted some of the files to mp3, but the references were kept as .wav.
|
// Workaround: Bethesda at some point converted some of the files to mp3, but the references were kept as .wav.
|
||||||
if (!vfs.exists(resPath))
|
if (!vfs.exists(resPath))
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
#ifndef MISC_RESOURCEHELPERS_H
|
#ifndef MISC_RESOURCEHELPERS_H
|
||||||
#define MISC_RESOURCEHELPERS_H
|
#define MISC_RESOURCEHELPERS_H
|
||||||
|
|
||||||
#include <components/vfs/pathutil.hpp>
|
|
||||||
|
|
||||||
#include <span>
|
#include <span>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
|
||||||
|
#include <components/vfs/pathutil.hpp>
|
||||||
|
|
||||||
namespace VFS
|
namespace VFS
|
||||||
{
|
{
|
||||||
class Manager;
|
class Manager;
|
||||||
|
@ -25,8 +25,8 @@ namespace Misc
|
||||||
namespace ResourceHelpers
|
namespace ResourceHelpers
|
||||||
{
|
{
|
||||||
bool changeExtensionToDds(std::string& path);
|
bool changeExtensionToDds(std::string& path);
|
||||||
std::string correctResourcePath(
|
std::string correctResourcePath(std::span<const std::string_view> topLevelDirectories, std::string_view resPath,
|
||||||
std::span<const std::string_view> topLevelDirectories, std::string_view resPath, const VFS::Manager* vfs);
|
const VFS::Manager* vfs, std::string_view ext = "");
|
||||||
std::string correctTexturePath(std::string_view resPath, const VFS::Manager* vfs);
|
std::string correctTexturePath(std::string_view resPath, const VFS::Manager* vfs);
|
||||||
std::string correctIconPath(std::string_view resPath, const VFS::Manager* vfs);
|
std::string correctIconPath(std::string_view resPath, const VFS::Manager* vfs);
|
||||||
std::string correctBookartPath(std::string_view resPath, const VFS::Manager* vfs);
|
std::string correctBookartPath(std::string_view resPath, const VFS::Manager* vfs);
|
||||||
|
@ -49,6 +49,7 @@ namespace Misc
|
||||||
std::string_view meshPathForESM3(std::string_view resPath);
|
std::string_view meshPathForESM3(std::string_view resPath);
|
||||||
|
|
||||||
VFS::Path::Normalized correctSoundPath(VFS::Path::NormalizedView resPath, const VFS::Manager& vfs);
|
VFS::Path::Normalized correctSoundPath(VFS::Path::NormalizedView resPath, const VFS::Manager& vfs);
|
||||||
|
std::string correctESM4SoundPath(std::string_view resPath, const VFS::Manager* vfs);
|
||||||
|
|
||||||
/// marker objects that have a hardcoded function in the game logic, should be hidden from the player
|
/// marker objects that have a hardcoded function in the game logic, should be hidden from the player
|
||||||
bool isHiddenMarker(const ESM::RefId& id);
|
bool isHiddenMarker(const ESM::RefId& id);
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
local async = require('openmw.async')
|
local async = require('openmw.async')
|
||||||
|
local core = require('openmw.core')
|
||||||
local types = require('openmw.types')
|
local types = require('openmw.types')
|
||||||
local world = require('openmw.world')
|
local world = require('openmw.world')
|
||||||
|
|
||||||
|
@ -6,8 +7,9 @@ local EnableObject = async:registerTimerCallback('EnableObject', function(obj) o
|
||||||
|
|
||||||
local function ESM4DoorActivation(door, actor)
|
local function ESM4DoorActivation(door, actor)
|
||||||
-- TODO: Implement lockpicking minigame
|
-- TODO: Implement lockpicking minigame
|
||||||
-- TODO: Play door opening animation and sound
|
-- TODO: Play door opening animation
|
||||||
local Door4 = types.ESM4Door
|
local Door4 = types.ESM4Door
|
||||||
|
core.sound.playSound3d(Door4.record(door).openSound, actor)
|
||||||
if Door4.isTeleport(door) then
|
if Door4.isTeleport(door) then
|
||||||
actor:teleport(Door4.destCell(door), Door4.destPosition(door), Door4.destRotation(door))
|
actor:teleport(Door4.destCell(door), Door4.destPosition(door), Door4.destRotation(door))
|
||||||
else
|
else
|
||||||
|
|
|
@ -2449,5 +2449,7 @@
|
||||||
-- @field #string id Record id
|
-- @field #string id Record id
|
||||||
-- @field #string name Human-readable name
|
-- @field #string name Human-readable name
|
||||||
-- @field #string model VFS path to the model
|
-- @field #string model VFS path to the model
|
||||||
|
-- @field #string openSound FormId of the door opening sound
|
||||||
|
-- @field #string closeSound FormId of the door closing sound
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue