mirror of
https://github.com/mangosfour/server.git
synced 2025-12-12 19:37:03 +00:00
Some more map extractor fixes. Patch provided by andstan.
Coding style fix. Removed hack from lava detection code. Please test water/lava detection. Should work fine now.
This commit is contained in:
parent
129c0797a7
commit
bfe899b126
6 changed files with 254 additions and 250 deletions
1
contrib/extractor/.gitignore
vendored
1
contrib/extractor/.gitignore
vendored
|
|
@ -17,3 +17,4 @@
|
||||||
debug
|
debug
|
||||||
release
|
release
|
||||||
*.user
|
*.user
|
||||||
|
*.ilk
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ typedef unsigned int uint32;
|
||||||
|
|
||||||
map_id *map_ids;
|
map_id *map_ids;
|
||||||
uint16 *areas;
|
uint16 *areas;
|
||||||
|
uint16 *LiqType;
|
||||||
char output_path[128] = ".";
|
char output_path[128] = ".";
|
||||||
char input_path[128] = ".";
|
char input_path[128] = ".";
|
||||||
uint32 maxAreaId = 0;
|
uint32 maxAreaId = 0;
|
||||||
|
|
@ -67,14 +68,13 @@ bool FileExists( const char* FileName )
|
||||||
|
|
||||||
void Usage(char* prg)
|
void Usage(char* prg)
|
||||||
{
|
{
|
||||||
printf("Usage:\n%s -[var] [value]\n-i set input path\n-o set output path\n-r set resolution\n-e extract only MAP(1)/DBC(2) - standard: both(3)\nExample: %s -r 256 -i \"c:\\games\\game\"",
|
printf("Usage:\n%s -[var] [value]\n-i set input path\n-o set output path\n-r set resolution\n-e extract only MAP(1)/DBC(2) - standard: both(3)\nExample: %s -r 256 -i \"c:\\games\\game\"", prg, prg);
|
||||||
prg,prg);
|
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
void HandleArgs(int argc, char * arg[])
|
void HandleArgs(int argc, char * arg[])
|
||||||
{
|
{
|
||||||
for(int c=1;c<argc;c++)
|
for(int c = 1; c < argc; ++c)
|
||||||
{
|
{
|
||||||
// i - input path
|
// i - input path
|
||||||
// o - output path
|
// o - output path
|
||||||
|
|
@ -123,9 +123,9 @@ uint32 ReadMapDBC()
|
||||||
DBCFile dbc("DBFilesClient\\Map.dbc");
|
DBCFile dbc("DBFilesClient\\Map.dbc");
|
||||||
dbc.open();
|
dbc.open();
|
||||||
|
|
||||||
uint32 map_count=dbc.getRecordCount();
|
size_t map_count = dbc.getRecordCount();
|
||||||
map_ids = new map_id[map_count];
|
map_ids = new map_id[map_count];
|
||||||
for(unsigned int x=0;x<map_count;x++)
|
for(uint32 x = 0; x < map_count; ++x)
|
||||||
{
|
{
|
||||||
map_ids[x].id = dbc.getRecord(x).getUInt(0);
|
map_ids[x].id = dbc.getRecord(x).getUInt(0);
|
||||||
strcpy(map_ids[x].name, dbc.getRecord(x).getString(1));
|
strcpy(map_ids[x].name, dbc.getRecord(x).getString(1));
|
||||||
|
|
@ -140,11 +140,12 @@ void ReadAreaTableDBC()
|
||||||
DBCFile dbc("DBFilesClient\\AreaTable.dbc");
|
DBCFile dbc("DBFilesClient\\AreaTable.dbc");
|
||||||
dbc.open();
|
dbc.open();
|
||||||
|
|
||||||
unsigned int area_count=dbc.getRecordCount();
|
size_t area_count = dbc.getRecordCount();
|
||||||
uint32 maxid = dbc.getMaxId();
|
size_t maxid = dbc.getMaxId();
|
||||||
areas = new uint16[maxid + 1];
|
areas = new uint16[maxid + 1];
|
||||||
memset(areas, 0xff, sizeof(areas));
|
memset(areas, 0xff, sizeof(areas));
|
||||||
for(unsigned int x=0; x<area_count;++x)
|
|
||||||
|
for(uint32 x = 0; x < area_count; ++x)
|
||||||
areas[dbc.getRecord(x).getUInt(0)] = dbc.getRecord(x).getUInt(3);
|
areas[dbc.getRecord(x).getUInt(0)] = dbc.getRecord(x).getUInt(3);
|
||||||
|
|
||||||
maxAreaId = dbc.getMaxId();
|
maxAreaId = dbc.getMaxId();
|
||||||
|
|
@ -152,6 +153,22 @@ void ReadAreaTableDBC()
|
||||||
printf("Done! (%u areas loaded)\n", area_count);
|
printf("Done! (%u areas loaded)\n", area_count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ReadLiquidTypeTableDBC()
|
||||||
|
{
|
||||||
|
printf("Read LiquidType.dbc file...");
|
||||||
|
DBCFile dbc("DBFilesClient\\LiquidType.dbc");
|
||||||
|
dbc.open();
|
||||||
|
size_t LiqType_count = dbc.getRecordCount();
|
||||||
|
size_t LiqType_maxid = dbc.getMaxId();
|
||||||
|
LiqType = new uint16[LiqType_maxid + 1];
|
||||||
|
memset(LiqType, 0xff, (LiqType_maxid + 1) * sizeof(uint16));
|
||||||
|
|
||||||
|
for(uint32 x = 0; x < LiqType_count; ++x)
|
||||||
|
LiqType[dbc.getRecord(x).getUInt(0)] = dbc.getRecord(x).getUInt(3);
|
||||||
|
|
||||||
|
printf("Done! (%u LiqTypes loaded)\n", LiqType_count);
|
||||||
|
}
|
||||||
|
|
||||||
void ExtractMapsFromMpq()
|
void ExtractMapsFromMpq()
|
||||||
{
|
{
|
||||||
char mpq_filename[1024];
|
char mpq_filename[1024];
|
||||||
|
|
@ -162,6 +179,7 @@ void ExtractMapsFromMpq()
|
||||||
uint32 map_count = ReadMapDBC();
|
uint32 map_count = ReadMapDBC();
|
||||||
|
|
||||||
ReadAreaTableDBC();
|
ReadAreaTableDBC();
|
||||||
|
ReadLiquidTypeTableDBC();
|
||||||
|
|
||||||
unsigned int total = map_count * ADT_RES * ADT_RES;
|
unsigned int total = map_count * ADT_RES * ADT_RES;
|
||||||
unsigned int done = 0;
|
unsigned int done = 0;
|
||||||
|
|
@ -170,11 +188,11 @@ void ExtractMapsFromMpq()
|
||||||
path += "/maps/";
|
path += "/maps/";
|
||||||
CreateDir(path);
|
CreateDir(path);
|
||||||
|
|
||||||
for(unsigned int x = 0; x < ADT_RES; ++x)
|
for(uint32 x = 0; x < ADT_RES; ++x)
|
||||||
{
|
{
|
||||||
for(unsigned int y = 0; y < ADT_RES; ++y)
|
for(uint32 y = 0; y < ADT_RES; ++y)
|
||||||
{
|
{
|
||||||
for(unsigned int z = 0; z < map_count; ++z)
|
for(uint32 z = 0; z < map_count; ++z)
|
||||||
{
|
{
|
||||||
sprintf(mpq_filename, "World\\Maps\\%s\\%s_%u_%u.adt", map_ids[z].name, map_ids[z].name, x, y);
|
sprintf(mpq_filename, "World\\Maps\\%s\\%s_%u_%u.adt", map_ids[z].name, map_ids[z].name, x, y);
|
||||||
sprintf(output_filename, "%s/maps/%03u%02u%02u.map", output_path, map_ids[z].id, y, x);
|
sprintf(output_filename, "%s/maps/%03u%02u%02u.map", output_path, map_ids[z].id, y, x);
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -17,13 +17,13 @@
|
||||||
|
|
||||||
unsigned int iRes = 256;
|
unsigned int iRes = 256;
|
||||||
extern uint16 *areas;
|
extern uint16 *areas;
|
||||||
|
extern uint16 *LiqType;
|
||||||
extern uint32 maxAreaId;
|
extern uint32 maxAreaId;
|
||||||
|
|
||||||
vec wmoc;
|
vec wmoc;
|
||||||
|
|
||||||
Cell *cell;
|
Cell *cell;
|
||||||
uint32 wmo_count;
|
//uint32 wmo_count;
|
||||||
mcell *mcells;
|
mcell *mcells;
|
||||||
int holetab_h[4] = {0x1111, 0x2222, 0x4444, 0x8888};
|
int holetab_h[4] = {0x1111, 0x2222, 0x4444, 0x8888};
|
||||||
int holetab_v[4] = {0x000F, 0x00F0, 0x0F00, 0xF000};
|
int holetab_v[4] = {0x000F, 0x00F0, 0x0F00, 0xF000};
|
||||||
|
|
@ -38,6 +38,15 @@ bool LoadADT(char* filename)
|
||||||
//printf("No such file %s\n", filename);
|
//printf("No such file %s\n", filename);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MapLiqFlag = new uint8[256];
|
||||||
|
for(int j = 0; j < 256; ++j)
|
||||||
|
MapLiqFlag[j] = 0; // no water
|
||||||
|
|
||||||
|
MapLiqHeight = new float[16384];
|
||||||
|
for(int j = 0; j < 16384; ++j)
|
||||||
|
MapLiqHeight[j] = -999999; // no water
|
||||||
|
|
||||||
mcells=new mcell;
|
mcells=new mcell;
|
||||||
|
|
||||||
wmoc.x =65 * TILESIZE;
|
wmoc.x =65 * TILESIZE;
|
||||||
|
|
@ -45,8 +54,8 @@ bool LoadADT(char* filename)
|
||||||
|
|
||||||
size_t mcnk_offsets[256], mcnk_sizes[256];
|
size_t mcnk_offsets[256], mcnk_sizes[256];
|
||||||
|
|
||||||
wmo_count=0;
|
//wmo_count = 0;
|
||||||
bool found=false;
|
//bool found = false;
|
||||||
|
|
||||||
MH2O_presence = false;
|
MH2O_presence = false;
|
||||||
chunk_num = 0;
|
chunk_num = 0;
|
||||||
|
|
@ -96,10 +105,12 @@ bool LoadADT(char* filename)
|
||||||
mf.seek(base_pos + LiqOffsData->offsData1);
|
mf.seek(base_pos + LiqOffsData->offsData1);
|
||||||
mf.read(LiqChunkData1, 24); // ñ÷èòûâàåì ñàìè äàííûå â ñòðóêòóðó òèïà MH2O_Data1
|
mf.read(LiqChunkData1, 24); // ñ÷èòûâàåì ñàìè äàííûå â ñòðóêòóðó òèïà MH2O_Data1
|
||||||
// çàíîñèì äàííûå ôëàãà äëÿ êóñêà
|
// çàíîñèì äàííûå ôëàãà äëÿ êóñêà
|
||||||
if(LiqChunkData1->flags & 4 || LiqChunkData1->flags & 8)
|
if(LiqType[LiqChunkData1->LiquidTypeId] == 0xffff)
|
||||||
MapLiqFlag[chunk_num] |= 1;
|
printf("\nCan't find Liquid type for map %s\nchunk %d\n", filename, chunk_num);
|
||||||
if(LiqChunkData1->flags & 16)
|
else if(LiqType[LiqChunkData1->LiquidTypeId] == LIQUID_TYPE_WATER || LiqType[LiqChunkData1->LiquidTypeId] == LIQUID_TYPE_OCEAN)
|
||||||
MapLiqFlag[chunk_num] |= 2;
|
MapLiqFlag[chunk_num] |= 1; // water/ocean
|
||||||
|
else if(LiqType[LiqChunkData1->LiquidTypeId] == LIQUID_TYPE_MAGMA || LiqType[LiqChunkData1->LiquidTypeId] == LIQUID_TYPE_SLIME)
|
||||||
|
MapLiqFlag[chunk_num] |= 2; // magma/slime
|
||||||
// ïðåäâàðèòåëüíî çàïîëíÿåì âåñü êóñîê äàííûìè - íåò âîäû
|
// ïðåäâàðèòåëüíî çàïîëíÿåì âåñü êóñîê äàííûìè - íåò âîäû
|
||||||
for(int j = 0; j < 81; ++j)
|
for(int j = 0; j < 81; ++j)
|
||||||
{
|
{
|
||||||
|
|
@ -193,6 +204,7 @@ inline void LoadMapChunk(MPQFile & mf, chunk*_chunk)
|
||||||
if(wmoc.x > xbase) wmoc.x = xbase;
|
if(wmoc.x > xbase) wmoc.x = xbase;
|
||||||
if(wmoc.z > zbase) wmoc.z = zbase;
|
if(wmoc.z > zbase) wmoc.z = zbase;
|
||||||
int chunkflags = header.flags;
|
int chunkflags = header.flags;
|
||||||
|
//printf("LMC: flags %X\n", chunkflags);
|
||||||
float zmin = 999999999.0f;
|
float zmin = 999999999.0f;
|
||||||
float zmax = -999999999.0f;
|
float zmax = -999999999.0f;
|
||||||
// must be there, bl!zz uses some crazy format
|
// must be there, bl!zz uses some crazy format
|
||||||
|
|
@ -264,9 +276,9 @@ inline void LoadMapChunk(MPQFile & mf, chunk*_chunk)
|
||||||
}
|
}
|
||||||
|
|
||||||
if(chunkflags & 4 || chunkflags & 8)
|
if(chunkflags & 4 || chunkflags & 8)
|
||||||
MapLiqFlag[chunk_num] |= 1;
|
MapLiqFlag[chunk_num] |= 1; // water
|
||||||
if(chunkflags & 16)
|
if(chunkflags & 16)
|
||||||
MapLiqFlag[chunk_num] |= 2;
|
MapLiqFlag[chunk_num] |= 2; // magma/slime
|
||||||
}
|
}
|
||||||
// çàïîëíåì òàê æå êàê â MH2O
|
// çàïîëíåì òàê æå êàê â MH2O
|
||||||
if(!(chunk_num % 16))
|
if(!(chunk_num % 16))
|
||||||
|
|
@ -325,11 +337,11 @@ inline double GetZ(double x, double z)
|
||||||
{
|
{
|
||||||
v[1].x = UNITSIZE;
|
v[1].x = UNITSIZE;
|
||||||
v[1].y = cell->v9[xc + 1][zc];
|
v[1].y = cell->v9[xc + 1][zc];
|
||||||
v[1].z=0;
|
v[1].z = 0.0f;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
v[1].x=0.0;
|
v[1].x = 0.0f;
|
||||||
v[1].y = cell->v9[xc][zc + 1];
|
v[1].y = cell->v9[xc][zc + 1];
|
||||||
v[1].z = UNITSIZE;
|
v[1].z = UNITSIZE;
|
||||||
}
|
}
|
||||||
|
|
@ -342,9 +354,9 @@ inline double GetZ(double x, double z)
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
v[2].x=0;
|
v[2].x = 0.0f;
|
||||||
v[2].y = cell->v9[xc][zc];
|
v[2].y = cell->v9[xc][zc];
|
||||||
v[2].z=0;
|
v[2].z = 0.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
return -solve(v, &p);
|
return -solve(v, &p);
|
||||||
|
|
@ -355,9 +367,9 @@ inline void TransformData()
|
||||||
{
|
{
|
||||||
cell = new Cell;
|
cell = new Cell;
|
||||||
|
|
||||||
for(int x=0;x<128;++x)
|
for(uint32 x = 0; x < 128; ++x)
|
||||||
{
|
{
|
||||||
for(int y=0;y<128;++y)
|
for(uint32 y = 0; y < 128; ++y)
|
||||||
{
|
{
|
||||||
cell->v8[x][y] = (float)mcells->ch[x / 8][y / 8].v8[x % 8][y % 8];
|
cell->v8[x][y] = (float)mcells->ch[x / 8][y / 8].v8[x % 8][y % 8];
|
||||||
cell->v9[x][y] = (float)mcells->ch[x / 8][y / 8].v9[x % 8][y % 8];
|
cell->v9[x][y] = (float)mcells->ch[x / 8][y / 8].v9[x % 8][y % 8];
|
||||||
|
|
@ -380,19 +392,8 @@ const char MAP_MAGIC[] = "MAP_2.01";
|
||||||
|
|
||||||
bool ConvertADT(char *filename, char *filename2)
|
bool ConvertADT(char *filename, char *filename2)
|
||||||
{
|
{
|
||||||
MapLiqHeight = new float[16384];
|
|
||||||
MapLiqFlag = new char[256];
|
|
||||||
for(int j = 0; j < 256; ++j)
|
|
||||||
MapLiqFlag[j] = 0;
|
|
||||||
for(int j = 0; j < 16384; ++j)
|
|
||||||
MapLiqHeight[j] = -999999;
|
|
||||||
|
|
||||||
if(!LoadADT(filename))
|
if(!LoadADT(filename))
|
||||||
{
|
|
||||||
delete [] MapLiqHeight;
|
|
||||||
delete [] MapLiqFlag;
|
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
FILE *output=fopen(filename2, "wb");
|
FILE *output=fopen(filename2, "wb");
|
||||||
if(!output)
|
if(!output)
|
||||||
|
|
@ -406,9 +407,9 @@ bool ConvertADT(char * filename,char * filename2)
|
||||||
// write magic header
|
// write magic header
|
||||||
fwrite(MAP_MAGIC, 1, 8, output);
|
fwrite(MAP_MAGIC, 1, 8, output);
|
||||||
|
|
||||||
for(unsigned int x=0;x<16;++x)
|
for(uint32 x = 0; x < 16; ++x)
|
||||||
{
|
{
|
||||||
for(unsigned int y=0;y<16;++y)
|
for(uint32 y = 0; y < 16; ++y)
|
||||||
{
|
{
|
||||||
if(mcells->ch[y][x].area_id && mcells->ch[y][x].area_id <= maxAreaId)
|
if(mcells->ch[y][x].area_id && mcells->ch[y][x].area_id <= maxAreaId)
|
||||||
{
|
{
|
||||||
|
|
@ -433,9 +434,9 @@ bool ConvertADT(char * filename,char * filename2)
|
||||||
|
|
||||||
TransformData();
|
TransformData();
|
||||||
|
|
||||||
for(unsigned int x=0;x<iRes;++x)
|
for(uint32 x = 0; x < iRes; ++x)
|
||||||
{
|
{
|
||||||
for(unsigned int y=0;y<iRes;++y)
|
for(uint32 y = 0; y < iRes; ++y)
|
||||||
{
|
{
|
||||||
float z = (float)GetZ(
|
float z = (float)GetZ(
|
||||||
(((double)(y)) * TILESIZE) / ((double)(iRes - 1)),
|
(((double)(y)) * TILESIZE) / ((double)(iRes - 1)),
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ typedef struct {
|
||||||
} MH2O_offsData;
|
} MH2O_offsData;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
uint16 flags;
|
uint16 LiquidTypeId;
|
||||||
uint16 type;
|
uint16 type;
|
||||||
float heightLevel1;
|
float heightLevel1;
|
||||||
float heightLevel2;
|
float heightLevel2;
|
||||||
|
|
@ -100,13 +100,21 @@ typedef struct {
|
||||||
uint32 ofsData2b;
|
uint32 ofsData2b;
|
||||||
} MH2O_Data1;
|
} MH2O_Data1;
|
||||||
|
|
||||||
|
enum LiquidType
|
||||||
|
{
|
||||||
|
LIQUID_TYPE_WATER = 0,
|
||||||
|
LIQUID_TYPE_OCEAN = 1,
|
||||||
|
LIQUID_TYPE_MAGMA = 2,
|
||||||
|
LIQUID_TYPE_SLIME = 3
|
||||||
|
};
|
||||||
|
|
||||||
class MPQFile;
|
class MPQFile;
|
||||||
|
|
||||||
bool MH2O_presence;
|
bool MH2O_presence;
|
||||||
MH2O_offsData *LiqOffsData;
|
MH2O_offsData *LiqOffsData;
|
||||||
MH2O_Data1 *LiqChunkData1;
|
MH2O_Data1 *LiqChunkData1;
|
||||||
float *ChunkLiqHeight, *MapLiqHeight;
|
float *ChunkLiqHeight, *MapLiqHeight;
|
||||||
char* MapLiqFlag;
|
uint8* MapLiqFlag;
|
||||||
uint32 k, m, chunk_num;
|
uint32 k, m, chunk_num;
|
||||||
void LoadMapChunk(MPQFile &, chunk*);
|
void LoadMapChunk(MPQFile &, chunk*);
|
||||||
bool LoadWMO(char* filename);
|
bool LoadWMO(char* filename);
|
||||||
|
|
|
||||||
|
|
@ -907,8 +907,6 @@ void Player::HandleDrowning()
|
||||||
|
|
||||||
void Player::HandleLava()
|
void Player::HandleLava()
|
||||||
{
|
{
|
||||||
bool ValidArea = false;
|
|
||||||
|
|
||||||
if ((m_isunderwater & 0x80) && isAlive())
|
if ((m_isunderwater & 0x80) && isAlive())
|
||||||
{
|
{
|
||||||
// Single trigger Set BreathTimer
|
// Single trigger Set BreathTimer
|
||||||
|
|
@ -917,43 +915,21 @@ void Player::HandleLava()
|
||||||
m_isunderwater|= 0x04;
|
m_isunderwater|= 0x04;
|
||||||
m_breathTimer = 1000;
|
m_breathTimer = 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset BreathTimer and still in the lava
|
// Reset BreathTimer and still in the lava
|
||||||
if (!m_breathTimer)
|
if (!m_breathTimer)
|
||||||
{
|
{
|
||||||
uint64 guid = GetGUID();
|
uint64 guid = GetGUID();
|
||||||
uint32 damage = urand(600, 700); // TODO: Get more detailed information about lava damage
|
uint32 damage = urand(600, 700); // TODO: Get more detailed information about lava damage
|
||||||
uint32 dmgZone = GetZoneId(); // TODO: Find correct "lava dealing zone" flag in Area Table
|
|
||||||
|
|
||||||
// Deal lava damage only in lava zones.
|
// if not gamemaster then deal damage
|
||||||
switch(dmgZone)
|
if ( !isGameMaster() )
|
||||||
{
|
|
||||||
case 0x8D:
|
|
||||||
ValidArea = false;
|
|
||||||
break;
|
|
||||||
case 0x94:
|
|
||||||
ValidArea = false;
|
|
||||||
break;
|
|
||||||
case 0x2CE:
|
|
||||||
ValidArea = false;
|
|
||||||
break;
|
|
||||||
case 0x2CF:
|
|
||||||
ValidArea = false;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
if (dmgZone / 5 & 0x408)
|
|
||||||
ValidArea = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if is valid area and is not gamemaster then deal damage
|
|
||||||
if ( ValidArea && !isGameMaster() )
|
|
||||||
EnvironmentalDamage(guid, DAMAGE_LAVA, damage);
|
EnvironmentalDamage(guid, DAMAGE_LAVA, damage);
|
||||||
|
|
||||||
m_breathTimer = 1000;
|
m_breathTimer = 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
//Death timer disabled and WaterFlags reset
|
else if (m_deathState == DEAD) // Disable breath timer and reset underwater flags
|
||||||
else if (m_deathState == DEAD)
|
|
||||||
{
|
{
|
||||||
m_breathTimer = 0;
|
m_breathTimer = 0;
|
||||||
m_isunderwater = 0;
|
m_isunderwater = 0;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue