You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
594 lines
17 KiB
594 lines
17 KiB
/** |
|
* @file clx_render.cpp |
|
* |
|
* CL2 rendering. |
|
*/ |
|
#include "clx_render.hpp" |
|
|
|
#include <algorithm> |
|
#include <bitset> |
|
#include <cstdint> |
|
|
|
#include "engine/point.hpp" |
|
#include "engine/render/blit_impl.hpp" |
|
#include "engine/render/scrollrt.h" |
|
#include "utils/attributes.h" |
|
#include "utils/clx_decode.hpp" |
|
#include "utils/static_bit_vector.hpp" |
|
#include "utils/static_vector.hpp" |
|
|
|
#ifdef DEBUG_CLX |
|
#include <fmt/format.h> |
|
|
|
#include "utils/str_cat.hpp" |
|
#endif |
|
|
|
namespace devilution { |
|
namespace { |
|
|
|
/** |
|
* CL2 is similar to CEL, with the following differences: |
|
* |
|
* 1. Transparent runs can cross line boundaries. |
|
* 2. Control bytes are different, and the [0x80, 0xBE] control byte range |
|
* indicates a fill-N command. |
|
*/ |
|
|
|
struct BlitCommandInfo { |
|
const uint8_t *srcEnd; |
|
unsigned length; |
|
}; |
|
|
|
BlitCommandInfo ClxBlitInfo(const uint8_t *src) |
|
{ |
|
const uint8_t control = *src; |
|
if (!IsClxOpaque(control)) |
|
return { src + 1, control }; |
|
if (IsClxOpaqueFill(control)) { |
|
const uint8_t width = GetClxOpaqueFillWidth(control); |
|
return { src + 2, width }; |
|
} |
|
const uint8_t width = GetClxOpaquePixelsWidth(control); |
|
return { src + 1 + width, width }; |
|
} |
|
|
|
struct ClipX { |
|
int_fast16_t left; |
|
int_fast16_t right; |
|
int_fast16_t width; |
|
}; |
|
|
|
DVL_ALWAYS_INLINE DVL_ATTRIBUTE_HOT ClipX CalculateClipX(int_fast16_t x, std::size_t w, const Surface &out) |
|
{ |
|
ClipX clip; |
|
clip.left = static_cast<int_fast16_t>(x < 0 ? -x : 0); |
|
clip.right = static_cast<int_fast16_t>(static_cast<int_fast16_t>(x + w) > out.w() ? x + w - out.w() : 0); |
|
clip.width = static_cast<int_fast16_t>(w - clip.left - clip.right); |
|
return clip; |
|
} |
|
|
|
// Source data for rendering backwards: first line of input -> last line of output. |
|
struct RenderSrc { |
|
const uint8_t *begin; |
|
const uint8_t *end; |
|
uint_fast16_t width; |
|
}; |
|
|
|
struct SkipSize { |
|
int_fast16_t wholeLines; |
|
int_fast16_t xOffset; |
|
}; |
|
|
|
DVL_ALWAYS_INLINE DVL_ATTRIBUTE_HOT SkipSize GetSkipSize(int_fast16_t remainingWidth, int_fast16_t srcWidth) |
|
{ |
|
if (remainingWidth < 0) { |
|
// If `remainingWidth` is negative, `-remainingWidth` is the overrun. |
|
const int_fast16_t overrunLines = -remainingWidth / srcWidth; |
|
return { |
|
static_cast<int_fast16_t>(1 + overrunLines), |
|
static_cast<int_fast16_t>(-remainingWidth - srcWidth * overrunLines) |
|
}; |
|
} |
|
// If `remainingWidth` is non-negative, then it is 0, meaning we drew a whole line. |
|
return { 1, 0 }; |
|
} |
|
|
|
DVL_ALWAYS_INLINE DVL_ATTRIBUTE_HOT const uint8_t *SkipRestOfLineWithOverrun( |
|
const uint8_t *src, int_fast16_t srcWidth, SkipSize &skipSize) |
|
{ |
|
int_fast16_t remainingWidth = srcWidth - skipSize.xOffset; |
|
while (remainingWidth > 0) { |
|
const auto [srcEnd, length] = ClxBlitInfo(src); |
|
src = srcEnd; |
|
remainingWidth -= length; |
|
} |
|
skipSize = GetSkipSize(remainingWidth, srcWidth); |
|
return src; |
|
} |
|
|
|
// Returns the horizontal overrun. |
|
DVL_ALWAYS_INLINE DVL_ATTRIBUTE_HOT int_fast16_t SkipLinesForRenderBackwardsWithOverrun( |
|
Point &position, RenderSrc &src, int_fast16_t dstHeight) |
|
{ |
|
SkipSize skipSize { 0, 0 }; |
|
while (position.y >= dstHeight && src.begin != src.end) { |
|
src.begin = SkipRestOfLineWithOverrun( |
|
src.begin, static_cast<int_fast16_t>(src.width), skipSize); |
|
position.y -= static_cast<int>(skipSize.wholeLines); |
|
} |
|
return skipSize.xOffset; |
|
} |
|
|
|
template <typename BlitFn> |
|
void DoRenderBackwardsClipY( |
|
const Surface &out, Point position, RenderSrc src, BlitFn &&blitFn) |
|
{ |
|
// Skip the bottom clipped lines. |
|
int_fast16_t xOffset = SkipLinesForRenderBackwardsWithOverrun(position, src, out.h()); |
|
if (src.begin >= src.end) |
|
return; |
|
|
|
auto *dst = &out[position]; |
|
const auto *dstBegin = out.begin(); |
|
const int dstPitch = out.pitch(); |
|
while (src.begin != src.end && dst >= dstBegin) { |
|
auto remainingWidth = static_cast<int_fast16_t>(src.width) - xOffset; |
|
dst += xOffset; |
|
while (remainingWidth > 0) { |
|
uint8_t v = *src.begin++; |
|
if (IsClxOpaque(v)) { |
|
if (IsClxOpaqueFill(v)) { |
|
v = GetClxOpaqueFillWidth(v); |
|
const uint8_t color = *src.begin++; |
|
blitFn(v, color, dst); |
|
} else { |
|
v = GetClxOpaquePixelsWidth(v); |
|
blitFn(v, dst, src.begin); |
|
src.begin += v; |
|
} |
|
} |
|
dst += v; |
|
remainingWidth -= v; |
|
} |
|
|
|
const SkipSize skipSize = GetSkipSize(remainingWidth, static_cast<int_fast16_t>(src.width)); |
|
xOffset = skipSize.xOffset; |
|
dst -= skipSize.wholeLines * dstPitch + src.width - remainingWidth; |
|
} |
|
} |
|
|
|
template <typename BlitFn> |
|
void DoRenderBackwardsClipXY( |
|
const Surface &out, Point position, RenderSrc src, ClipX clipX, BlitFn &&blitFn) |
|
{ |
|
// Skip the bottom clipped lines. |
|
int_fast16_t xOffset = SkipLinesForRenderBackwardsWithOverrun(position, src, out.h()); |
|
if (src.begin >= src.end) |
|
return; |
|
|
|
position.x += static_cast<int>(clipX.left); |
|
auto *dst = &out[position]; |
|
const auto *dstBegin = out.begin(); |
|
const int dstPitch = out.pitch(); |
|
|
|
while (src.begin != src.end && dst >= dstBegin) { |
|
// Skip initial src if clipping on the left. |
|
// Handles overshoot, i.e. when the RLE segment goes into the unclipped area. |
|
int_fast16_t remainingWidth = clipX.width; |
|
int_fast16_t remainingLeftClip = clipX.left - xOffset; |
|
if (remainingLeftClip < 0) { |
|
dst += std::min<unsigned>(remainingWidth, -remainingLeftClip); |
|
remainingWidth += remainingLeftClip; |
|
} |
|
while (remainingLeftClip > 0) { |
|
auto [srcEnd, length] = ClxBlitInfo(src.begin); |
|
if (static_cast<int_fast16_t>(length) > remainingLeftClip) { |
|
const uint8_t control = *src.begin; |
|
const auto overshoot = static_cast<int>(length - remainingLeftClip); |
|
length = std::min<unsigned>(remainingWidth, overshoot); |
|
if (IsClxOpaque(control)) { |
|
if (IsClxOpaqueFill(control)) { |
|
blitFn(length, src.begin[1], dst); |
|
} else { |
|
blitFn(length, dst, src.begin + 1 + remainingLeftClip); |
|
} |
|
} |
|
dst += length; |
|
remainingWidth -= overshoot; |
|
src.begin = srcEnd; |
|
break; |
|
} |
|
src.begin = srcEnd; |
|
remainingLeftClip -= length; |
|
} |
|
while (remainingWidth > 0) { |
|
auto [srcEnd, length] = ClxBlitInfo(src.begin); |
|
const uint8_t control = *src.begin; |
|
const unsigned unclippedLength = length; |
|
length = std::min<unsigned>(remainingWidth, length); |
|
if (IsClxOpaque(control)) { |
|
if (IsClxOpaqueFill(control)) { |
|
blitFn(length, src.begin[1], dst); |
|
} else { |
|
blitFn(length, dst, src.begin + 1); |
|
} |
|
} |
|
src.begin = srcEnd; |
|
dst += length; |
|
remainingWidth -= unclippedLength; // result can be negative |
|
} |
|
|
|
// `remainingWidth` can be negative, in which case it is the amount of pixels |
|
// that the source has overran the line. |
|
remainingWidth += clipX.right; |
|
SkipSize skipSize; |
|
if (remainingWidth > 0) { |
|
skipSize.xOffset = static_cast<int_fast16_t>(src.width) - remainingWidth; |
|
src.begin = SkipRestOfLineWithOverrun( |
|
src.begin, static_cast<int_fast16_t>(src.width), skipSize); |
|
} else { |
|
skipSize = GetSkipSize(remainingWidth, static_cast<int_fast16_t>(src.width)); |
|
} |
|
xOffset = skipSize.xOffset; |
|
dst -= dstPitch * skipSize.wholeLines + clipX.width; |
|
} |
|
} |
|
|
|
template <typename BlitFn> |
|
void DoRenderBackwards( |
|
const Surface &out, Point position, const uint8_t *src, size_t srcSize, |
|
unsigned srcWidth, unsigned srcHeight, BlitFn &&blitFn) |
|
{ |
|
if (position.y < 0 || position.y + 1 >= static_cast<int>(out.h() + srcHeight)) |
|
return; |
|
const ClipX clipX = CalculateClipX(position.x, srcWidth, out); |
|
if (clipX.width <= 0) |
|
return; |
|
RenderSrc srcForBackwards { src, src + srcSize, static_cast<uint_fast16_t>(srcWidth) }; |
|
if (static_cast<std::size_t>(clipX.width) == srcWidth) { |
|
DoRenderBackwardsClipY( |
|
out, position, srcForBackwards, std::forward<BlitFn>(blitFn)); |
|
} else { |
|
DoRenderBackwardsClipXY( |
|
out, position, srcForBackwards, clipX, std::forward<BlitFn>(blitFn)); |
|
} |
|
} |
|
|
|
constexpr size_t MaxOutlinePixels = 1024; |
|
constexpr size_t MaxOutlineSpriteWidth = 256; |
|
using OutlinePixels = StaticVector<PointOf<int16_t>, MaxOutlinePixels>; |
|
using OutlineRowSolidRuns = StaticVector<std::pair<int16_t, int16_t>, MaxOutlineSpriteWidth / 2 + 1>; |
|
|
|
void PopulateOutlinePixelsForRow( |
|
const StaticBitVector<MaxOutlineSpriteWidth> &rowBelow, |
|
const StaticBitVector<MaxOutlineSpriteWidth> &rowAbove, |
|
const OutlineRowSolidRuns &solidRuns, |
|
int y, OutlinePixels &result) |
|
{ |
|
for (auto [xBegin, xEnd] : solidRuns) { |
|
result.emplace_back(static_cast<int16_t>(xBegin - 1), static_cast<int16_t>(y)); |
|
for (int xi = xBegin; xi < xEnd; ++xi) { |
|
if (!rowAbove.test(xi)) { |
|
result.emplace_back(static_cast<int16_t>(xi), static_cast<int16_t>(y - 1)); |
|
} |
|
if (!rowBelow.test(xi)) { |
|
result.emplace_back(static_cast<int16_t>(xi), static_cast<int16_t>(y + 1)); |
|
} |
|
} |
|
result.emplace_back(static_cast<int16_t>(xEnd), static_cast<int16_t>(y)); |
|
} |
|
} |
|
|
|
void AppendOutlineRowSolidRuns(int x, int w, OutlineRowSolidRuns &solidRuns) |
|
{ |
|
if (solidRuns.empty() || solidRuns.back().second != x) { |
|
solidRuns.emplace_back(x, x + w); |
|
} else { |
|
solidRuns.back().second = static_cast<int16_t>(x + w); |
|
} |
|
} |
|
|
|
template <bool SkipColorIndexZero> |
|
OutlinePixels GetOutline(ClxSprite sprite) // NOLINT(readability-function-cognitive-complexity) |
|
{ |
|
const unsigned width = sprite.width(); |
|
assert(width < MaxOutlineSpriteWidth); |
|
OutlinePixels result; |
|
StaticBitVector<MaxOutlineSpriteWidth> rows[3] = { |
|
StaticBitVector<MaxOutlineSpriteWidth>(width), |
|
StaticBitVector<MaxOutlineSpriteWidth>(width), |
|
StaticBitVector<MaxOutlineSpriteWidth>(width) |
|
}; |
|
StaticBitVector<MaxOutlineSpriteWidth> *rowAbove = &rows[0]; |
|
StaticBitVector<MaxOutlineSpriteWidth> *row = &rows[1]; |
|
StaticBitVector<MaxOutlineSpriteWidth> *rowBelow = &rows[2]; |
|
|
|
int x = 0; |
|
int y = sprite.height() - 1; |
|
|
|
OutlineRowSolidRuns solidRuns[2]; |
|
OutlineRowSolidRuns *solidRunAbove = &solidRuns[0]; |
|
OutlineRowSolidRuns *solidRun = &solidRuns[1]; |
|
|
|
const uint8_t *src = sprite.pixelData(); |
|
const uint8_t *const end = src + sprite.pixelDataSize(); |
|
while (src < end) { |
|
while (x < static_cast<int>(width)) { |
|
const auto v = static_cast<uint8_t>(*src++); |
|
uint8_t w; |
|
if (IsClxOpaque(v)) { |
|
if constexpr (SkipColorIndexZero) { |
|
if (IsClxOpaqueFill(v)) { |
|
w = GetClxOpaqueFillWidth(v); |
|
const auto color = static_cast<uint8_t>(*src++); |
|
if (color != 0) { |
|
AppendOutlineRowSolidRuns(x, w, *solidRunAbove); |
|
} |
|
} else { |
|
w = GetClxOpaquePixelsWidth(v); |
|
bool prevZero = solidRunAbove->empty() || solidRunAbove->back().second != x; |
|
for (unsigned i = 0; i < w; ++i) { |
|
const auto color = static_cast<uint8_t>(src[i]); |
|
if (color == 0) { |
|
if (!prevZero) ++solidRunAbove->back().second; |
|
prevZero = true; |
|
} else { |
|
if (prevZero) solidRunAbove->emplace_back(x + i, x + i); |
|
++solidRunAbove->back().second; |
|
prevZero = false; |
|
} |
|
} |
|
src += w; |
|
} |
|
} else { |
|
if (IsClxOpaqueFill(v)) { |
|
w = GetClxOpaqueFillWidth(v); |
|
++src; |
|
} else { |
|
w = GetClxOpaquePixelsWidth(v); |
|
src += w; |
|
} |
|
AppendOutlineRowSolidRuns(x, w, *solidRunAbove); |
|
} |
|
} else { |
|
w = v; |
|
} |
|
x += w; |
|
} |
|
|
|
for (const auto &[xBegin, xEnd] : *solidRunAbove) { |
|
rowAbove->set(xBegin, xEnd - xBegin); |
|
} |
|
PopulateOutlinePixelsForRow(*rowBelow, *rowAbove, *solidRun, y + 1, result); |
|
|
|
// (0, 1, 2) => (2, 0, 1) |
|
std::swap(row, rowBelow); |
|
std::swap(row, rowAbove); |
|
rowAbove->reset(); |
|
|
|
std::swap(solidRunAbove, solidRun); |
|
solidRunAbove->clear(); |
|
|
|
if (x > static_cast<int>(width)) { |
|
// Transparent overrun. |
|
const unsigned numWholeTransparentLines = x / width; |
|
if (numWholeTransparentLines > 1) { |
|
PopulateOutlinePixelsForRow(*rowBelow, *rowAbove, *solidRun, y, result); |
|
solidRun->clear(); |
|
row->reset(); |
|
} |
|
if (numWholeTransparentLines > 2) rowBelow->reset(); |
|
y -= static_cast<int16_t>(numWholeTransparentLines); |
|
x = static_cast<int16_t>(x % width); |
|
} else { |
|
--y; |
|
x = 0; |
|
} |
|
} |
|
rowAbove->reset(); |
|
PopulateOutlinePixelsForRow(*rowBelow, *rowAbove, *solidRun, y + 1, result); |
|
return result; |
|
} |
|
|
|
template <bool SkipColorIndexZero> |
|
void RenderClxOutline(const Surface &out, Point position, ClxSprite sprite, uint8_t color) |
|
{ |
|
const OutlinePixels pixels = GetOutline<SkipColorIndexZero>(sprite); |
|
position.y -= sprite.height() - 1; |
|
if (position.x > 0 && position.x + sprite.width() < out.w() |
|
&& position.y > 0 && position.y + sprite.height() < out.h()) { |
|
for (auto [x, y] : pixels) { |
|
*out.at(position.x + x, position.y + y) = color; |
|
} |
|
} else { |
|
for (auto [x, y] : pixels) { |
|
out.SetPixel(Point(position.x + x, position.y + y), color); |
|
} |
|
} |
|
} |
|
|
|
void ClxApplyTrans(ClxSprite sprite, const uint8_t *trn) |
|
{ |
|
// A bit of a hack but this is the only place in the code where we need mutable sprites. |
|
auto *dst = const_cast<uint8_t *>(sprite.pixelData()); |
|
uint16_t remaining = sprite.pixelDataSize(); |
|
while (remaining != 0) { |
|
uint8_t val = *dst++; |
|
--remaining; |
|
if (!IsClxOpaque(val)) |
|
continue; |
|
if (IsClxOpaqueFill(val)) { |
|
--remaining; |
|
*dst = trn[*dst]; |
|
dst++; |
|
} else { |
|
val = GetClxOpaquePixelsWidth(val); |
|
remaining -= val; |
|
while (val-- > 0) { |
|
*dst = trn[*dst]; |
|
dst++; |
|
} |
|
} |
|
} |
|
} |
|
|
|
} // namespace |
|
|
|
void ClxApplyTrans(ClxSpriteList list, const uint8_t *trn) |
|
{ |
|
for (ClxSprite sprite : list) { |
|
ClxApplyTrans(sprite, trn); |
|
} |
|
} |
|
|
|
void ClxApplyTrans(ClxSpriteSheet sheet, const uint8_t *trn) |
|
{ |
|
for (ClxSpriteList list : sheet) { |
|
ClxApplyTrans(list, trn); |
|
} |
|
} |
|
|
|
bool IsPointWithinClx(Point position, ClxSprite clx) |
|
{ |
|
const uint8_t *src = clx.pixelData(); |
|
const uint8_t *end = src + clx.pixelDataSize(); |
|
const uint16_t width = clx.width(); |
|
|
|
int xCur = 0; |
|
int yCur = clx.height() - 1; |
|
while (src < end) { |
|
if (yCur != position.y) { |
|
SkipSize skipSize {}; |
|
skipSize.xOffset = xCur; |
|
src = SkipRestOfLineWithOverrun(src, width, skipSize); |
|
yCur -= skipSize.wholeLines; |
|
xCur = skipSize.xOffset; |
|
if (yCur < position.y) |
|
return false; |
|
continue; |
|
} |
|
|
|
while (xCur < width) { |
|
uint8_t val = *src++; |
|
if (!IsClxOpaque(val)) { |
|
// ignore transparent |
|
xCur += val; |
|
if (xCur > position.x) |
|
return false; |
|
continue; |
|
} |
|
|
|
if (IsClxOpaqueFill(val)) { |
|
val = GetClxOpaqueFillWidth(val); |
|
uint8_t color = *src++; |
|
if (xCur <= position.x && position.x < xCur + val) |
|
return color != 0; // ignore shadows |
|
xCur += val; |
|
} else { |
|
val = GetClxOpaquePixelsWidth(val); |
|
for (uint8_t pixel = 0; pixel < val; pixel++) { |
|
uint8_t color = *src++; |
|
if (xCur == position.x) |
|
return color != 0; // ignore shadows |
|
xCur++; |
|
} |
|
} |
|
} |
|
|
|
return false; |
|
} |
|
|
|
return false; |
|
} |
|
|
|
std::pair<int, int> ClxMeasureSolidHorizontalBounds(ClxSprite clx) |
|
{ |
|
const uint8_t *src = clx.pixelData(); |
|
const uint8_t *end = src + clx.pixelDataSize(); |
|
const uint16_t width = clx.width(); |
|
|
|
int xBegin = width; |
|
int xEnd = 0; |
|
int xCur = 0; |
|
while (src < end) { |
|
while (xCur < width) { |
|
auto val = *src++; |
|
if (!IsClxOpaque(val)) { |
|
xCur += val; |
|
continue; |
|
} |
|
if (IsClxOpaqueFill(val)) { |
|
val = GetClxOpaqueFillWidth(val); |
|
++src; |
|
} else { |
|
val = GetClxOpaquePixelsWidth(val); |
|
src += val; |
|
} |
|
xBegin = std::min(xBegin, xCur); |
|
xCur += val; |
|
xEnd = std::max(xEnd, xCur); |
|
} |
|
while (xCur >= width) |
|
xCur -= width; |
|
if (xBegin == 0 && xEnd == width) |
|
break; |
|
} |
|
return { xBegin, xEnd }; |
|
} |
|
|
|
#ifdef DEBUG_CLX |
|
std::string ClxDescribe(ClxSprite clx) |
|
{ |
|
std::string out = StrCat( |
|
"CLX sprite: ", clx.width(), "x", clx.height(), " pixelDataSize=", clx.pixelDataSize(), |
|
"b\n\n" |
|
"command | width | bytes | color(s)\n" |
|
"--------|------:|------:|---------\n"); |
|
const uint8_t *src = clx.pixelData(); |
|
const uint8_t *end = src + clx.pixelDataSize(); |
|
while (src < end) { |
|
const uint8_t control = *src++; |
|
if (IsClxOpaque(control)) { |
|
if (IsClxOpaqueFill(control)) { |
|
const uint8_t length = GetClxOpaqueFillWidth(control); |
|
out.append(fmt::format("Fill | {:>5} | {:>5} | {}\n", length, 2, src[1])); |
|
++src; |
|
} else { |
|
const uint8_t length = GetClxOpaquePixelsWidth(control); |
|
out.append(fmt::format("Pixels | {:>5} | {:>5} | {}\n", length, length + 1, fmt::join(src + 1, src + 1 + length, " "))); |
|
src += length; |
|
} |
|
} else { |
|
out.append(fmt::format("Transp. | {:>5} | {:>5} |\n", control, 1)); |
|
} |
|
} |
|
return out; |
|
} |
|
#endif // DEBUG_CLX |
|
|
|
void ClxDraw(const Surface &out, Point position, ClxSprite clx) |
|
{ |
|
DoRenderBackwards(out, position, clx.pixelData(), clx.pixelDataSize(), clx.width(), clx.height(), BlitDirect {}); |
|
} |
|
|
|
void ClxDrawTRN(const Surface &out, Point position, ClxSprite clx, const uint8_t *trn) |
|
{ |
|
DoRenderBackwards(out, position, clx.pixelData(), clx.pixelDataSize(), clx.width(), clx.height(), BlitWithMap { trn }); |
|
} |
|
|
|
void ClxDrawBlendedTRN(const Surface &out, Point position, ClxSprite clx, const uint8_t *trn) |
|
{ |
|
DoRenderBackwards(out, position, clx.pixelData(), clx.pixelDataSize(), clx.width(), clx.height(), BlitBlendedWithMap { trn }); |
|
} |
|
|
|
void ClxDrawOutline(const Surface &out, uint8_t col, Point position, ClxSprite clx) |
|
{ |
|
RenderClxOutline</*SkipColorIndexZero=*/false>(out, position, clx, col); |
|
} |
|
|
|
void ClxDrawOutlineSkipColorZero(const Surface &out, uint8_t col, Point position, ClxSprite clx) |
|
{ |
|
RenderClxOutline</*SkipColorIndexZero=*/true>(out, position, clx, col); |
|
} |
|
|
|
} // namespace devilution
|
|
|