mirror of https://github.com/OpenTTD/OpenTTD
Change: Simplify sprite cache memory management
* Remove custom allocator * Use std::unique_ptr for sprite data * Perform LRU cache eviction in a single passpull/13565/merge
parent
1d67094863
commit
7744f49a9e
|
@ -8,11 +8,8 @@
|
|||
/** @file spritecache.cpp Caching of sprites. */
|
||||
|
||||
#include "stdafx.h"
|
||||
#include "random_access_file_type.h"
|
||||
#include "spriteloader/grf.hpp"
|
||||
#include "spriteloader/makeindexed.h"
|
||||
#include "gfx_func.h"
|
||||
#include "error.h"
|
||||
#include "error_func.h"
|
||||
#include "strings_func.h"
|
||||
#include "zoom_func.h"
|
||||
|
@ -24,7 +21,6 @@
|
|||
#include "spritecache_internal.h"
|
||||
|
||||
#include "table/sprites.h"
|
||||
#include "table/strings.h"
|
||||
#include "table/palette_convert.h"
|
||||
|
||||
#include "safeguards.h"
|
||||
|
@ -34,6 +30,8 @@ uint _sprite_cache_size = 4;
|
|||
|
||||
|
||||
static std::vector<SpriteCache> _spritecache;
|
||||
static size_t _spritecache_bytes_used = 0;
|
||||
static uint32_t _sprite_lru_counter;
|
||||
static std::vector<std::unique_ptr<SpriteFile>> _sprite_files;
|
||||
|
||||
static inline SpriteCache *GetSpriteCache(uint index)
|
||||
|
@ -97,18 +95,6 @@ SpriteFile &OpenCachedSpriteFile(const std::string &filename, Subdirectory subdi
|
|||
return *file;
|
||||
}
|
||||
|
||||
struct MemBlock {
|
||||
size_t size;
|
||||
uint8_t data[];
|
||||
};
|
||||
|
||||
static uint _sprite_lru_counter;
|
||||
static MemBlock *_spritecache_ptr;
|
||||
static uint _allocated_sprite_cache_size = 0;
|
||||
static int _compact_cache_counter;
|
||||
|
||||
static void CompactSpriteCache();
|
||||
|
||||
/**
|
||||
* Skip the given amount of sprite graphics data.
|
||||
* @param type the type of sprite (compressed etc)
|
||||
|
@ -641,7 +627,6 @@ bool LoadNextSprite(SpriteID load_index, SpriteFile &file, uint file_sprite_id)
|
|||
uint8_t grf_type = file.ReadByte();
|
||||
|
||||
SpriteType type;
|
||||
void *data = nullptr;
|
||||
uint8_t control_flags = 0;
|
||||
if (grf_type == 0xFF) {
|
||||
/* Some NewGRF files have "empty" pseudo-sprites which are 1
|
||||
|
@ -692,7 +677,6 @@ bool LoadNextSprite(SpriteID load_index, SpriteFile &file, uint file_sprite_id)
|
|||
sc->file = &file;
|
||||
sc->file_pos = file_pos;
|
||||
sc->length = num;
|
||||
sc->ptr = data;
|
||||
sc->lru = 0;
|
||||
sc->id = file_sprite_id;
|
||||
sc->type = type;
|
||||
|
@ -710,7 +694,7 @@ void DupSprite(SpriteID old_spr, SpriteID new_spr)
|
|||
|
||||
scnew->file = scold->file;
|
||||
scnew->file_pos = scold->file_pos;
|
||||
scnew->ptr = nullptr;
|
||||
scnew->ClearSpriteData();
|
||||
scnew->id = scold->id;
|
||||
scnew->type = scold->type;
|
||||
scnew->warned = false;
|
||||
|
@ -718,188 +702,103 @@ void DupSprite(SpriteID old_spr, SpriteID new_spr)
|
|||
}
|
||||
|
||||
/**
|
||||
* S_FREE_MASK is used to mask-out lower bits of MemBlock::size
|
||||
* If they are non-zero, the block is free.
|
||||
* S_FREE_MASK has to ensure MemBlock is correctly aligned -
|
||||
* it means 8B (S_FREE_MASK == 7) on 64bit systems!
|
||||
* Delete entries from the sprite cache to remove the requested number of bytes.
|
||||
* Sprite data is removed in order of LRU values.
|
||||
* The total number of bytes removed may be larger than the number requested.
|
||||
* @param to_remove Requested number of bytes to remove.
|
||||
*/
|
||||
static const size_t S_FREE_MASK = sizeof(size_t) - 1;
|
||||
|
||||
/* to make sure nobody adds things to MemBlock without checking S_FREE_MASK first */
|
||||
static_assert(sizeof(MemBlock) == sizeof(size_t));
|
||||
/* make sure it's a power of two */
|
||||
static_assert((sizeof(size_t) & (sizeof(size_t) - 1)) == 0);
|
||||
|
||||
static inline MemBlock *NextBlock(MemBlock *block)
|
||||
static void DeleteEntriesFromSpriteCache(size_t to_remove)
|
||||
{
|
||||
return (MemBlock*)((uint8_t*)block + (block->size & ~S_FREE_MASK));
|
||||
}
|
||||
const size_t initial_in_use = _spritecache_bytes_used;
|
||||
|
||||
static size_t GetSpriteCacheUsage()
|
||||
{
|
||||
size_t tot_size = 0;
|
||||
MemBlock *s;
|
||||
struct SpriteInfo {
|
||||
uint32_t lru;
|
||||
SpriteID id;
|
||||
size_t size;
|
||||
|
||||
for (s = _spritecache_ptr; s->size != 0; s = NextBlock(s)) {
|
||||
if (!(s->size & S_FREE_MASK)) tot_size += s->size;
|
||||
bool operator<(const SpriteInfo &other) const
|
||||
{
|
||||
return this->lru < other.lru;
|
||||
}
|
||||
};
|
||||
|
||||
std::vector<SpriteInfo> candidates; // max heap, ordered by LRU
|
||||
size_t candidate_bytes = 0; // total bytes that would be released when clearing all sprites in candidates
|
||||
|
||||
auto push = [&](SpriteInfo info) {
|
||||
candidates.push_back(info);
|
||||
std::push_heap(candidates.begin(), candidates.end());
|
||||
candidate_bytes += info.size;
|
||||
};
|
||||
|
||||
auto pop = [&]() {
|
||||
candidate_bytes -= candidates.front().size;
|
||||
std::pop_heap(candidates.begin(), candidates.end());
|
||||
candidates.pop_back();
|
||||
};
|
||||
|
||||
SpriteID i = 0;
|
||||
for (; i != static_cast<SpriteID>(_spritecache.size()) && candidate_bytes < to_remove; i++) {
|
||||
const SpriteCache *sc = GetSpriteCache(i);
|
||||
if (sc->ptr != nullptr) {
|
||||
push({ sc->lru, i, sc->length });
|
||||
if (candidate_bytes >= to_remove) break;
|
||||
}
|
||||
}
|
||||
/* candidates now contains enough bytes to meet to_remove.
|
||||
* only sprites with LRU values <= the maximum (i.e. the top of the heap) need to be considered */
|
||||
for (; i != static_cast<SpriteID>(_spritecache.size()); i++) {
|
||||
const SpriteCache *sc = GetSpriteCache(i);
|
||||
if (sc->ptr != nullptr && sc->lru <= candidates.front().lru) {
|
||||
push({ sc->lru, i, sc->length });
|
||||
while (!candidates.empty() && candidate_bytes - candidates.front().size >= to_remove) {
|
||||
pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tot_size;
|
||||
}
|
||||
for (const auto &it : candidates) {
|
||||
GetSpriteCache(it.id)->ClearSpriteData();
|
||||
}
|
||||
|
||||
Debug(sprite, 3, "DeleteEntriesFromSpriteCache, deleted: {}, freed: {}, in use: {} --> {}, requested: {}",
|
||||
candidates.size(), candidate_bytes, initial_in_use, _spritecache_bytes_used, to_remove);
|
||||
}
|
||||
|
||||
void IncreaseSpriteLRU()
|
||||
{
|
||||
/* Increase all LRU values */
|
||||
if (_sprite_lru_counter > 16384) {
|
||||
Debug(sprite, 5, "Fixing lru {}, inuse={}", _sprite_lru_counter, GetSpriteCacheUsage());
|
||||
int bpp = BlitterFactory::GetCurrentBlitter()->GetScreenDepth();
|
||||
uint target_size = (bpp > 0 ? _sprite_cache_size * bpp / 8 : 1) * 1024 * 1024;
|
||||
if (_spritecache_bytes_used > target_size) {
|
||||
DeleteEntriesFromSpriteCache(_spritecache_bytes_used - target_size + 512 * 1024);
|
||||
}
|
||||
|
||||
if (_sprite_lru_counter >= 0xC0000000) {
|
||||
Debug(sprite, 3, "Fixing lru {}, inuse={}", _sprite_lru_counter, _spritecache_bytes_used);
|
||||
|
||||
for (SpriteCache &sc : _spritecache) {
|
||||
if (sc.ptr != nullptr) {
|
||||
if (sc.lru >= 0) {
|
||||
sc.lru = -1;
|
||||
} else if (sc.lru != -32768) {
|
||||
sc.lru--;
|
||||
if (sc.lru > 0x80000000) {
|
||||
sc.lru -= 0x80000000;
|
||||
} else {
|
||||
sc.lru = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
_sprite_lru_counter = 0;
|
||||
}
|
||||
|
||||
/* Compact sprite cache every now and then. */
|
||||
if (++_compact_cache_counter >= 740) {
|
||||
CompactSpriteCache();
|
||||
_compact_cache_counter = 0;
|
||||
_sprite_lru_counter -= 0x80000000;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when holes in the sprite cache should be removed.
|
||||
* That is accomplished by moving the cached data.
|
||||
*/
|
||||
static void CompactSpriteCache()
|
||||
void SpriteCache::ClearSpriteData()
|
||||
{
|
||||
MemBlock *s;
|
||||
|
||||
Debug(sprite, 3, "Compacting sprite cache, inuse={}", GetSpriteCacheUsage());
|
||||
|
||||
for (s = _spritecache_ptr; s->size != 0;) {
|
||||
if (s->size & S_FREE_MASK) {
|
||||
MemBlock *next = NextBlock(s);
|
||||
MemBlock temp;
|
||||
SpriteID i;
|
||||
|
||||
/* Since free blocks are automatically coalesced, this should hold true. */
|
||||
assert(!(next->size & S_FREE_MASK));
|
||||
|
||||
/* If the next block is the sentinel block, we can safely return */
|
||||
if (next->size == 0) break;
|
||||
|
||||
/* Locate the sprite belonging to the next pointer. */
|
||||
for (i = 0; GetSpriteCache(i)->ptr != next->data; i++) {
|
||||
assert(i != _spritecache.size());
|
||||
}
|
||||
|
||||
GetSpriteCache(i)->ptr = s->data; // Adjust sprite array entry
|
||||
/* Swap this and the next block */
|
||||
temp = *s;
|
||||
std::byte *p = reinterpret_cast<std::byte *>(next);
|
||||
std::move(p, &p[next->size], reinterpret_cast<std::byte *>(s));
|
||||
s = NextBlock(s);
|
||||
*s = temp;
|
||||
|
||||
/* Coalesce free blocks */
|
||||
while (NextBlock(s)->size & S_FREE_MASK) {
|
||||
s->size += NextBlock(s)->size & ~S_FREE_MASK;
|
||||
}
|
||||
} else {
|
||||
s = NextBlock(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single entry from the sprite cache.
|
||||
* @param item Entry to delete.
|
||||
*/
|
||||
static void DeleteEntryFromSpriteCache(SpriteCache *item)
|
||||
{
|
||||
/* Mark the block as free (the block must be in use) */
|
||||
MemBlock *s = static_cast<MemBlock *>(item->ptr) - 1;
|
||||
assert(!(s->size & S_FREE_MASK));
|
||||
s->size |= S_FREE_MASK;
|
||||
item->ptr = nullptr;
|
||||
|
||||
/* And coalesce adjacent free blocks */
|
||||
for (s = _spritecache_ptr; s->size != 0; s = NextBlock(s)) {
|
||||
if (s->size & S_FREE_MASK) {
|
||||
while (NextBlock(s)->size & S_FREE_MASK) {
|
||||
s->size += NextBlock(s)->size & ~S_FREE_MASK;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void DeleteEntryFromSpriteCache()
|
||||
{
|
||||
Debug(sprite, 3, "DeleteEntryFromSpriteCache, inuse={}", GetSpriteCacheUsage());
|
||||
|
||||
SpriteCache *best = nullptr;
|
||||
int cur_lru = 0xffff;
|
||||
for (SpriteCache &sc : _spritecache) {
|
||||
if (sc.ptr != nullptr && sc.lru < cur_lru) {
|
||||
cur_lru = sc.lru;
|
||||
best = ≻
|
||||
}
|
||||
}
|
||||
|
||||
/* Display an error message and die, in case we found no sprite at all.
|
||||
* This shouldn't really happen, unless all sprites are locked. */
|
||||
if (best == nullptr) FatalError("Out of sprite memory");
|
||||
|
||||
DeleteEntryFromSpriteCache(best);
|
||||
}
|
||||
|
||||
void *CacheSpriteAllocator::AllocatePtr(size_t mem_req)
|
||||
{
|
||||
mem_req += sizeof(MemBlock);
|
||||
|
||||
/* Align this to correct boundary. This also makes sure at least one
|
||||
* bit is not used, so we can use it for other things. */
|
||||
mem_req = Align(mem_req, S_FREE_MASK + 1);
|
||||
|
||||
for (;;) {
|
||||
MemBlock *s;
|
||||
|
||||
for (s = _spritecache_ptr; s->size != 0; s = NextBlock(s)) {
|
||||
if (s->size & S_FREE_MASK) {
|
||||
size_t cur_size = s->size & ~S_FREE_MASK;
|
||||
|
||||
/* Is the block exactly the size we need or
|
||||
* big enough for an additional free block? */
|
||||
if (cur_size == mem_req ||
|
||||
cur_size >= mem_req + sizeof(MemBlock)) {
|
||||
/* Set size and in use */
|
||||
s->size = mem_req;
|
||||
|
||||
/* Do we need to inject a free block too? */
|
||||
if (cur_size != mem_req) {
|
||||
NextBlock(s)->size = (cur_size - mem_req) | S_FREE_MASK;
|
||||
}
|
||||
|
||||
return s->data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Reached sentinel, but no block found yet. Delete some old entry. */
|
||||
DeleteEntryFromSpriteCache();
|
||||
}
|
||||
_spritecache_bytes_used -= this->length;
|
||||
this->ptr.reset();
|
||||
}
|
||||
|
||||
void *UniquePtrSpriteAllocator::AllocatePtr(size_t size)
|
||||
{
|
||||
this->data = std::make_unique<std::byte[]>(size);
|
||||
this->size = size;
|
||||
return this->data.get();
|
||||
}
|
||||
|
||||
|
@ -975,83 +874,38 @@ void *GetRawSprite(SpriteID sprite, SpriteType type, SpriteAllocator *allocator,
|
|||
|
||||
if (allocator == nullptr && encoder == nullptr) {
|
||||
/* Load sprite into/from spritecache */
|
||||
CacheSpriteAllocator cache_allocator;
|
||||
|
||||
/* Update LRU */
|
||||
sc->lru = ++_sprite_lru_counter;
|
||||
|
||||
/* Load the sprite, if it is not loaded, yet */
|
||||
if (sc->ptr == nullptr) {
|
||||
UniquePtrSpriteAllocator cache_allocator;
|
||||
if (sc->type == SpriteType::Recolour) {
|
||||
sc->ptr = ReadRecolourSprite(*sc->file, sc->file_pos, sc->length, cache_allocator);
|
||||
ReadRecolourSprite(*sc->file, sc->file_pos, sc->length, cache_allocator);
|
||||
} else {
|
||||
sc->ptr = ReadSprite(sc, sprite, type, cache_allocator, nullptr);
|
||||
ReadSprite(sc, sprite, type, cache_allocator, nullptr);
|
||||
}
|
||||
sc->ptr = std::move(cache_allocator.data);
|
||||
sc->length = static_cast<uint32_t>(cache_allocator.size);
|
||||
_spritecache_bytes_used += sc->length;
|
||||
}
|
||||
|
||||
return sc->ptr;
|
||||
return static_cast<void *>(sc->ptr.get());
|
||||
} else {
|
||||
/* Do not use the spritecache, but a different allocator. */
|
||||
return ReadSprite(sc, sprite, type, *allocator, encoder);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static void GfxInitSpriteCache()
|
||||
{
|
||||
/* initialize sprite cache heap */
|
||||
int bpp = BlitterFactory::GetCurrentBlitter()->GetScreenDepth();
|
||||
uint target_size = (bpp > 0 ? _sprite_cache_size * bpp / 8 : 1) * 1024 * 1024;
|
||||
|
||||
/* Remember 'target_size' from the previous allocation attempt, so we do not try to reach the target_size multiple times in case of failure. */
|
||||
static uint last_alloc_attempt = 0;
|
||||
|
||||
if (_spritecache_ptr == nullptr || (_allocated_sprite_cache_size != target_size && target_size != last_alloc_attempt)) {
|
||||
delete[] reinterpret_cast<uint8_t *>(_spritecache_ptr);
|
||||
|
||||
last_alloc_attempt = target_size;
|
||||
_allocated_sprite_cache_size = target_size;
|
||||
|
||||
do {
|
||||
/* Try to allocate 50% more to make sure we do not allocate almost all available. */
|
||||
_spritecache_ptr = reinterpret_cast<MemBlock *>(new(std::nothrow) uint8_t[_allocated_sprite_cache_size + _allocated_sprite_cache_size / 2]);
|
||||
|
||||
if (_spritecache_ptr != nullptr) {
|
||||
/* Allocation succeeded, but we wanted less. */
|
||||
delete[] reinterpret_cast<uint8_t *>(_spritecache_ptr);
|
||||
_spritecache_ptr = reinterpret_cast<MemBlock *>(new uint8_t[_allocated_sprite_cache_size]);
|
||||
} else if (_allocated_sprite_cache_size < 2 * 1024 * 1024) {
|
||||
UserError("Cannot allocate spritecache");
|
||||
} else {
|
||||
/* Try again to allocate half. */
|
||||
_allocated_sprite_cache_size >>= 1;
|
||||
}
|
||||
} while (_spritecache_ptr == nullptr);
|
||||
|
||||
if (_allocated_sprite_cache_size != target_size) {
|
||||
Debug(misc, 0, "Not enough memory to allocate {} MiB of spritecache. Spritecache was reduced to {} MiB.", target_size / 1024 / 1024, _allocated_sprite_cache_size / 1024 / 1024);
|
||||
|
||||
ErrorMessageData msg(GetEncodedString(STR_CONFIG_ERROR_OUT_OF_MEMORY), GetEncodedString(STR_CONFIG_ERROR_SPRITECACHE_TOO_BIG, target_size, _allocated_sprite_cache_size));
|
||||
ScheduleErrorMessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/* A big free block */
|
||||
_spritecache_ptr->size = (_allocated_sprite_cache_size - sizeof(MemBlock)) | S_FREE_MASK;
|
||||
/* Sentinel block (identified by size == 0) */
|
||||
NextBlock(_spritecache_ptr)->size = 0;
|
||||
}
|
||||
|
||||
void GfxInitSpriteMem()
|
||||
{
|
||||
GfxInitSpriteCache();
|
||||
|
||||
/* Reset the spritecache 'pool' */
|
||||
_spritecache.clear();
|
||||
_spritecache.shrink_to_fit();
|
||||
|
||||
_compact_cache_counter = 0;
|
||||
_sprite_files.clear();
|
||||
_spritecache_bytes_used = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1062,7 +916,7 @@ void GfxClearSpriteCache()
|
|||
{
|
||||
/* Clear sprite ptr for all cached items */
|
||||
for (SpriteCache &sc : _spritecache) {
|
||||
if (sc.ptr != nullptr) DeleteEntryFromSpriteCache(&sc);
|
||||
if (sc.ptr != nullptr) sc.ClearSpriteData();
|
||||
}
|
||||
|
||||
VideoDriver::GetInstance()->ClearSystemSprites();
|
||||
|
@ -1076,7 +930,7 @@ void GfxClearFontSpriteCache()
|
|||
{
|
||||
/* Clear sprite ptr for all cached font items */
|
||||
for (SpriteCache &sc : _spritecache) {
|
||||
if (sc.type == SpriteType::Font && sc.ptr != nullptr) DeleteEntryFromSpriteCache(&sc);
|
||||
if (sc.type == SpriteType::Font && sc.ptr != nullptr) sc.ClearSpriteData();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -35,6 +35,7 @@ extern uint _sprite_cache_size;
|
|||
class UniquePtrSpriteAllocator : public SpriteAllocator {
|
||||
public:
|
||||
std::unique_ptr<std::byte[]> data;
|
||||
size_t size;
|
||||
protected:
|
||||
void *AllocatePtr(size_t size) override;
|
||||
};
|
||||
|
|
|
@ -19,21 +19,17 @@
|
|||
/* These declarations are internal to spritecache but need to be exposed for unit-tests. */
|
||||
|
||||
struct SpriteCache {
|
||||
void *ptr;
|
||||
size_t file_pos;
|
||||
SpriteFile *file; ///< The file the sprite in this entry can be found in.
|
||||
std::unique_ptr<std::byte[]> ptr;
|
||||
size_t file_pos = 0;
|
||||
SpriteFile *file = nullptr; ///< The file the sprite in this entry can be found in.
|
||||
uint32_t length; ///< Length of sprite data.
|
||||
uint32_t id;
|
||||
int16_t lru;
|
||||
SpriteType type; ///< In some cases a single sprite is misused by two NewGRFs. Once as real sprite and once as recolour sprite. If the recolour sprite gets into the cache it might be drawn as real sprite which causes enormous trouble.
|
||||
bool warned; ///< True iff the user has been warned about incorrect use of this sprite
|
||||
uint8_t control_flags; ///< Control flags, see SpriteCacheCtrlFlags
|
||||
};
|
||||
uint32_t id = 0;
|
||||
uint32_t lru = 0;
|
||||
SpriteType type = SpriteType::Invalid; ///< In some cases a single sprite is misused by two NewGRFs. Once as real sprite and once as recolour sprite. If the recolour sprite gets into the cache it might be drawn as real sprite which causes enormous trouble.
|
||||
bool warned = false; ///< True iff the user has been warned about incorrect use of this sprite
|
||||
uint8_t control_flags = 0; ///< Control flags, see SpriteCacheCtrlFlags
|
||||
|
||||
/** SpriteAllocator that allocates memory from the sprite cache. */
|
||||
class CacheSpriteAllocator : public SpriteAllocator {
|
||||
protected:
|
||||
void *AllocatePtr(size_t size) override;
|
||||
void ClearSpriteData();
|
||||
};
|
||||
|
||||
inline bool IsMapgenSpriteID(SpriteID sprite)
|
||||
|
|
|
@ -19,15 +19,16 @@
|
|||
|
||||
static bool MockLoadNextSprite(SpriteID load_index)
|
||||
{
|
||||
static UniquePtrSpriteAllocator allocator;
|
||||
static Sprite *sprite = allocator.Allocate<Sprite>(sizeof(*sprite));
|
||||
UniquePtrSpriteAllocator allocator;
|
||||
allocator.Allocate<Sprite>(sizeof(Sprite));
|
||||
|
||||
bool is_mapgen = IsMapgenSpriteID(load_index);
|
||||
|
||||
SpriteCache *sc = AllocateSpriteCache(load_index);
|
||||
sc->file = nullptr;
|
||||
sc->file_pos = 0;
|
||||
sc->ptr = sprite;
|
||||
sc->ptr = std::move(allocator.data);
|
||||
sc->length = static_cast<uint32_t>(allocator.size);
|
||||
sc->lru = 0;
|
||||
sc->id = 0;
|
||||
sc->type = is_mapgen ? SpriteType::MapGen : SpriteType::Normal;
|
||||
|
|
Loading…
Reference in New Issue