From debe33ca7bdbbb95ef0cf636b3a5dfc931b0e67b Mon Sep 17 00:00:00 2001 From: Gleb Mazovetskiy Date: Sun, 11 Oct 2026 17:45:38 +0000 Subject: [PATCH] Extract baked sprite shadows and draw them separately The original sprites have their shadow drawn into them as black pixels. All of these shadows were made by one function: the sprite squashed to a third of its height and sheared to the left, measured from a baseline row B: v = (B - y) / 3 ys = B - 2 - v xs = x - (3 * v) / 4 Only the choice of baseline differs between sprites. It is now recorded per sprite in a new `shadowRule` column of the monster, object, towner and class sprite data files: * `FirstFrame`: the lowest opaque row of the first frame * `EachFrame`: the lowest opaque row of each frame * `FirstFrame+N`: the same plus N rows, for flying monsters * `None`: no shadow * `Baked`: a shadow that cannot be told apart from black parts of the sprite, so it is left in the sprite A rule can be followed by `|Unexplained` and `|Stray` for the few sprites that the function does not reproduce exactly. The rule is empty for the Hellfire bard and barbarian, whose sprites were not available. When sprites are loaded, `ExtractClxShadows` removes the shadow from each sprite and stores it as a mask of horizontal spans in the CLX frame header, so it is available wherever the sprite is. The shadow is drawn right before its sprite, which gives the same picture as before. A new "Transparent Shadows" graphics option blends the shadows with what is underneath instead of covering it. It is off by default. In debug builds, `dev.display.shadows()` toggles drawing them. Transparent shadows are not perfect. A black pixel of a sprite that lies inside the sprite's own shadow looks exactly like shadow, so it is extracted with it and becomes see-through. For example, the warrior in light armor with a sword and shield, facing southwest, is missing a few pixels from his left knee. Getting this perfect will likely require overriding the shadow masks of a handful of sprites, which is something that can be done with AI in the future. The rules were found and checked with `tools/shadow_search`, against all player, monster, towner and object sprites of Diablo and Hellfire: 139,943 frames in 1,438 files. Of the 136,918 frames that have an extractable shadow, 99.77% are reproduced pixel for pixel. Extraction adds about 14 us per frame when a sprite is loaded (about 14 ms for all animations of a character). For the original player sprites, the sprites with their shadow masks take 0.3% less memory than before. Drawing a sprite with its shadow, per sprite, for all sprites of each kind (`shadow_extract_check render`, release build, fully visible sprites, Ryzen AI Max+ 395): Players Monsters Monk Without lighting Before 2,111 ns 2,444 ns 2,109 ns Opaque shadow -9.5% -10.1% -6.1% Transparent shadow +10.0% +7.5% +9.0% With a light table Before 2,659 ns 2,983 ns 2,548 ns Opaque shadow -5.2% -4.5% -2.3% Transparent shadow +10.9% +10.2% +10.4% Opaque shadows are a little faster to draw than before: a shadow is now a short list of spans instead of many commands in the sprite. Blending a shadow costs about as much again as that saves. Either way the frame rate is practically unchanged: with about 10 such sprites per frame, it comes to about +0.2% FPS with opaque shadows and -0.3% with transparent ones in an unthrottled demo playback, where the 3 modes measured within noise of each other. Selection outlines can differ by a pixel where a shadow touches the sprite: their shape used to depend on how the black pixels of the shadow were encoded. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + CMake/Tests.cmake | 14 + CMakeLists.txt | 5 + Source/CMakeLists.txt | 11 + Source/debug.cpp | 1 + Source/debug.h | 2 + Source/engine/clx_sprite.hpp | 19 +- Source/engine/render/clx_render.cpp | 62 + Source/engine/render/clx_render.hpp | 17 + Source/engine/render/scrollrt.cpp | 39 +- Source/engine/shadow_mask.hpp | 140 + Source/engine/shadow_rule.hpp | 148 + Source/lua/modules/dev/display.cpp | 7 + Source/monster.cpp | 49 + Source/objects.cpp | 5 + Source/options.cpp | 2 + Source/options.h | 2 + Source/panels/partypanel.cpp | 3 +- Source/player.cpp | 2 + Source/tables/monstdat.cpp | 1 + Source/tables/monstdat.h | 6 +- Source/tables/objdat.cpp | 1 + Source/tables/objdat.h | 6 +- Source/tables/playerdat.cpp | 1 + Source/tables/playerdat.hpp | 3 + Source/tables/townerdat.cpp | 2 + Source/tables/townerdat.hpp | 3 + Source/towners.cpp | 16 +- Source/utils/clx_shadow.cpp | 417 ++ Source/utils/clx_shadow.hpp | 56 + assets/txtdata/classes/barbarian/sprites.tsv | 1 + assets/txtdata/classes/bard/sprites.tsv | 1 + assets/txtdata/classes/monk/sprites.tsv | 1 + assets/txtdata/classes/rogue/sprites.tsv | 1 + assets/txtdata/classes/sorcerer/sprites.tsv | 1 + assets/txtdata/classes/warrior/sprites.tsv | 1 + assets/txtdata/monsters/monstdat.tsv | 226 +- assets/txtdata/objects/objdat.tsv | 220 +- assets/txtdata/towners/towners.tsv | 26 +- mods/hf/txtdata/monsters/monstdat.tsv | 278 +- mods/hf/txtdata/towners/towners.tsv | 26 +- test/clx_shadow_test.cpp | 529 +++ test/player_test.cpp | 38 + test/shadow_rule_test.cpp | 67 + test/townerdat_test.cpp | 6 + tools/shadow_search/CMakeLists.txt | 30 + tools/shadow_search/demo_template.html | 606 +++ tools/shadow_search/extract_check.cpp | 675 ++++ tools/shadow_search/shadow_search.cpp | 3568 ++++++++++++++++++ tools/shadow_search/sprites.hpp | 487 +++ 50 files changed, 7429 insertions(+), 400 deletions(-) create mode 100644 Source/engine/shadow_mask.hpp create mode 100644 Source/engine/shadow_rule.hpp create mode 100644 Source/utils/clx_shadow.cpp create mode 100644 Source/utils/clx_shadow.hpp create mode 100644 test/clx_shadow_test.cpp create mode 100644 test/shadow_rule_test.cpp create mode 100644 tools/shadow_search/CMakeLists.txt create mode 100644 tools/shadow_search/demo_template.html create mode 100644 tools/shadow_search/extract_check.cpp create mode 100644 tools/shadow_search/shadow_search.cpp create mode 100644 tools/shadow_search/sprites.hpp diff --git a/.gitignore b/.gitignore index 44b134eb8..47432d809 100644 --- a/.gitignore +++ b/.gitignore @@ -475,3 +475,4 @@ uwp-project/Assets/ui_art /tmp/ /compile_commands.json /tools/tmp +/.claude/ diff --git a/CMake/Tests.cmake b/CMake/Tests.cmake index 9e21f9b5f..c59459e7c 100644 --- a/CMake/Tests.cmake +++ b/CMake/Tests.cmake @@ -51,6 +51,7 @@ set(tests ) set(standalone_tests cl2_to_clx_test + clx_shadow_test codec_test crawl_test data_file_test @@ -64,6 +65,7 @@ set(standalone_tests vision_test random_test rectangle_test + shadow_rule_test sheen_bidi_test static_vector_test str_cat_test @@ -125,6 +127,17 @@ target_link_dependencies(cl2_to_clx_test libdevilutionx_log libdevilutionx_surface ) +target_link_dependencies(clx_shadow_test + PRIVATE + DevilutionX::SDL + app_fatal_for_testing + language_for_testing + libdevilutionx_clx_render + libdevilutionx_clx_shadow + libdevilutionx_endian_write + libdevilutionx_log + libdevilutionx_surface +) target_link_dependencies(codec_test PRIVATE libdevilutionx_codec app_fatal_for_testing) add_custom_target(clx_render_benchmark_resources @@ -174,6 +187,7 @@ target_link_dependencies(palette_blending_benchmark app_fatal_for_testing ) target_link_dependencies(parse_int_test PRIVATE libdevilutionx_parse_int) +target_link_dependencies(shadow_rule_test PRIVATE libdevilutionx_strings) target_link_dependencies(path_test PRIVATE libdevilutionx_pathfinding libdevilutionx_direction app_fatal_for_testing) target_link_dependencies(vision_test PRIVATE libdevilutionx_vision) target_link_dependencies(path_benchmark PRIVATE libdevilutionx_pathfinding app_fatal_for_testing) diff --git a/CMakeLists.txt b/CMakeLists.txt index da178b1de..f994301d9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,6 +39,7 @@ if(CMAKE_TOOLCHAIN_FILE MATCHES "vcpkg.cmake$") option(USE_GETTEXT_FROM_VCPKG "Add vcpkg dependency for gettext[tools] for compiling translations" OFF) endif() option(BUILD_TESTING "Build tests." ON) +option(BUILD_SHADOW_SEARCH "Build the player sprite shadow transform search tool (tools/shadow_search)." OFF) # These must be included after the options above but before the `project` call. include(VcPkgManifestFeatures) @@ -443,6 +444,10 @@ if(BUILD_TESTING) include(Tests) endif() +if(BUILD_SHADOW_SEARCH) + add_subdirectory(tools/shadow_search) +endif() + include(functions/set_relative_file_macro) set_relative_file_macro(${BIN_TARGET}) diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index 1dc992602..7cf43efc5 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -228,6 +228,16 @@ target_link_dependencies(libdevilutionx_cl2_to_clx libdevilutionx_endian_write ) +add_devilutionx_object_library(libdevilutionx_clx_shadow + utils/clx_shadow.cpp +) +target_link_dependencies(libdevilutionx_clx_shadow + PUBLIC + libdevilutionx_strings + PRIVATE + libdevilutionx_endian_write +) + add_devilutionx_object_library(libdevilutionx_clx_render engine/render/clx_render.cpp ) @@ -1060,6 +1070,7 @@ target_link_dependencies(libdevilutionx PUBLIC unordered_dense::unordered_dense libdevilutionx_assets libdevilutionx_clx_render + libdevilutionx_clx_shadow libdevilutionx_codec libdevilutionx_config libdevilutionx_control diff --git a/Source/debug.cpp b/Source/debug.cpp index 9ed91a7e2..b3630835f 100644 --- a/Source/debug.cpp +++ b/Source/debug.cpp @@ -35,6 +35,7 @@ bool DebugInvisible = false; bool DebugVision = false; bool DebugPath = false; bool DebugGrid = false; +bool DebugHideShadows = false; ankerl::unordered_dense::map DebugCoordsMap; bool DebugScrollViewEnabled = false; std::string debugTRN; diff --git a/Source/debug.h b/Source/debug.h index 9f16387c2..5bca7c2d9 100644 --- a/Source/debug.h +++ b/Source/debug.h @@ -25,6 +25,8 @@ extern bool DebugInvisible; extern bool DebugVision; extern bool DebugPath; extern bool DebugGrid; +/** Hides the shadows that are drawn separately from their sprites (see `shadow_mask.hpp`). */ +extern bool DebugHideShadows; extern ankerl::unordered_dense::map DebugCoordsMap; extern bool DebugScrollViewEnabled; extern std::string debugTRN; diff --git a/Source/engine/clx_sprite.hpp b/Source/engine/clx_sprite.hpp index eaf2ca4d0..2b1c561cc 100644 --- a/Source/engine/clx_sprite.hpp +++ b/Source/engine/clx_sprite.hpp @@ -12,7 +12,7 @@ * 1. CLX frame header stores frame width and height. * 2. CLX frame header does not store 32-pixel block offsets. * - * CLX frame header is 6 bytes: + * CLX frame header is at least 6 bytes: * * Bytes | Type | Value * :-----:|:--------:|------------- @@ -20,6 +20,8 @@ * 2..4 | uint16_t | width * 4..6 | uint16_t | height * + * A header larger than 6 bytes holds the shadow mask of the sprite, see `shadow_mask.hpp`. + * * CL2 reference: https://github.com/savagesteel/d1-file-formats/blob/master/PC-Mac/CL2.md#2-file-structure */ @@ -42,6 +44,9 @@ class OptionalClxSprite; */ class ClxSprite { public: + /** The size of a frame header without an extension. */ + static constexpr uint16_t BaseHeaderSize = 6; + explicit constexpr ClxSprite(const uint8_t *data, uint32_t dataSize) : data_(data) , pixel_data_size_(dataSize - LoadLE16(data)) @@ -59,6 +64,18 @@ public: return LoadLE16(&data_[4]); } + /** @brief The size of the frame header, including anything that follows the height. */ + [[nodiscard]] constexpr uint16_t headerSize() const + { + return LoadLE16(data_); + } + + /** @brief The part of the frame header that follows the height. Empty for most sprites. */ + [[nodiscard]] constexpr const uint8_t *headerExtension() const + { + return &data_[BaseHeaderSize]; + } + /** * @brief The raw pixel data (CL2 frame data). * diff --git a/Source/engine/render/clx_render.cpp b/Source/engine/render/clx_render.cpp index d24fdeae3..8b33dbb1f 100644 --- a/Source/engine/render/clx_render.cpp +++ b/Source/engine/render/clx_render.cpp @@ -7,10 +7,12 @@ #include #include +#include #include #include "engine/point.hpp" #include "engine/render/blit_impl.hpp" +#include "engine/shadow_mask.hpp" #include "engine/surface.hpp" #include "utils/attributes.h" #include "utils/clx_decode.hpp" @@ -604,6 +606,66 @@ void ClxDrawBlended(const Surface &out, Point position, ClxSprite clx) DoRenderBackwards(out, position, clx.pixelData(), clx.pixelDataSize(), clx.width(), clx.height(), BlitBlended {}); } +namespace { + +/** + * @brief Calls `spanFn(dst, width)` for every span of the shadow mask, clipped to the output buffer. + */ +template +DVL_ALWAYS_INLINE void DoRenderShadow(const Surface &out, Point position, ShadowMask mask, SpanFn &&spanFn) +{ + const int outWidth = out.w(); + const int outHeight = out.h(); + mask.forEachSpan([&](uint16_t rowFromBottom, ShadowSpan span) { + const int y = position.y - rowFromBottom; + if (y < 0 || y >= outHeight) return; + int x = position.x + span.x; + int width = span.width; + if (x < 0) { + width += x; + x = 0; + } + if (x + width > outWidth) width = outWidth - x; + if (width <= 0) return; + spanFn(out.at(x, y), static_cast(width)); + }); +} + +/** Blends `width` pixels with black. */ +DVL_ALWAYS_INLINE DVL_ATTRIBUTE_HOT void BlendWithBlack(uint8_t *dst, unsigned width) +{ +#if DEVILUTIONX_PALETTE_TRANSPARENCY_BLACK_16_LUT + // Two pixels per lookup. Unlike the rectangle blend in `primitive_render.cpp`, this does + // not need the pixels to be aligned, which matters because shadow spans are short. + for (; width >= 2; width -= 2, dst += 2) { + const uint16_t blended = paletteTransparencyLookupBlack16[dst[0] | (dst[1] << 8U)]; + dst[0] = static_cast(blended); + dst[1] = static_cast(blended >> 8U); + } + if (width != 0) *dst = paletteTransparencyLookup[0][*dst]; +#else + const uint8_t *const lookupTable = paletteTransparencyLookup[0]; + for (; width != 0; --width, ++dst) + *dst = lookupTable[*dst]; +#endif +} + +} // namespace + +void ClxDrawShadow(const Surface &out, Point position, ClxSprite clx) +{ + if (!HasShadowMask(clx)) return; + DoRenderShadow(out, position, GetShadowMask(clx), [](uint8_t *dst, unsigned width) { + std::memset(dst, 0, width); + }); +} + +void ClxDrawShadowBlended(const Surface &out, Point position, ClxSprite clx) +{ + if (!HasShadowMask(clx)) return; + DoRenderShadow(out, position, GetShadowMask(clx), BlendWithBlack); +} + 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 }); diff --git a/Source/engine/render/clx_render.hpp b/Source/engine/render/clx_render.hpp index 9db4a909a..fdc8b8cbf 100644 --- a/Source/engine/render/clx_render.hpp +++ b/Source/engine/render/clx_render.hpp @@ -88,6 +88,23 @@ void ClxDrawBlendedTRN(const Surface &out, Point position, ClxSprite clx, const */ void ClxDrawBlended(const Surface &out, Point position, ClxSprite clx); +/** + * @brief Draws the shadow of the sprite in black. + * + * Does nothing if the sprite has no shadow mask (see `shadow_mask.hpp`). + * Drawing the shadow and then the sprite itself gives the sprite as it was before its shadow was extracted. + * + * @param out Output buffer + * @param position Target buffer coordinate, the same as for drawing the sprite + * @param clx CLX frame + */ +void ClxDrawShadow(const Surface &out, Point position, ClxSprite clx); + +/** + * @brief Same as `ClxDrawShadow` but darkens what is underneath instead of covering it with black. + */ +void ClxDrawShadowBlended(const Surface &out, Point position, ClxSprite clx); + /** * @brief Blit CL2 sprite, and apply lighting, to the given buffer at the given coordinates * @param out Output buffer diff --git a/Source/engine/render/scrollrt.cpp b/Source/engine/render/scrollrt.cpp index 9ba248427..ac156f6e0 100644 --- a/Source/engine/render/scrollrt.cpp +++ b/Source/engine/render/scrollrt.cpp @@ -244,13 +244,36 @@ bool ShouldShowCursor() } /** - * @brief Blit CL2 sprite, and apply lighting, to the given buffer at the given coordinates + * @brief Draws the shadow of a sprite whose shadow is stored separately (see `shadow_mask.hpp`). + * + * Must be called right before drawing the sprite itself, after its outline: + * a shadow that is baked into a sprite covers the outline, and so must this one. + * Does nothing for sprites without a separate shadow. + */ +inline void DrawShadow(const Surface &out, Point position, ClxSprite clx) +{ + static const bool skipDraw = getenv("SHADOW_NO_DRAW") != nullptr; + if (skipDraw) return; +#ifdef _DEBUG + if (DebugHideShadows) + return; +#endif + if (*GetOptions().Graphics.transparentShadows) { + ClxDrawShadowBlended(out, position, clx); + } else { + ClxDrawShadow(out, position, clx); + } +} + +/** + * @brief Blit CL2 sprite and its shadow, and apply lighting, to the given buffer at the given coordinates * @param out Output buffer * @param position Target buffer coordinate * @param clx CLX frame */ inline void ClxDrawLight(const Surface &out, Point position, ClxSprite clx, int lightTableIndex) { + DrawShadow(out, position, clx); if (lightTableIndex != 0) { ClxDrawTRN(out, position, clx, LightTables[lightTableIndex].data()); } else { @@ -344,10 +367,12 @@ void DrawMissilePrivate(const Surface &out, const Missile &missile, Point target const Point missileRenderPosition { targetBufferPosition + missile.position.offsetForRendering - Displacement { missile._miAnimWidth2, 0 } }; const ClxSprite sprite = (*missile._miAnimData)[missile._miAnimFrame - 1]; if (missile._miUniqTrans != 0) { + DrawShadow(out, missileRenderPosition, sprite); ClxDrawTRN(out, missileRenderPosition, sprite, Monsters[missile._misource].uniqueMonsterTRN.get()); } else if (missile._miLightFlag) { ClxDrawLight(out, missileRenderPosition, sprite, lightTableIndex); } else { + DrawShadow(out, missileRenderPosition, sprite); ClxDraw(out, missileRenderPosition, sprite); } } @@ -385,6 +410,7 @@ void DrawMonster(const Surface &out, Point tilePosition, Point targetBufferPosit const ClxSprite sprite = monster.animInfo.currentSprite(); if (!IsTileLit(tilePosition)) { + DrawShadow(out, targetBufferPosition, sprite); ClxDrawTRN(out, targetBufferPosition, sprite, GetInfravisionTRN()); return; } @@ -395,10 +421,12 @@ void DrawMonster(const Surface &out, Point tilePosition, Point targetBufferPosit trn = GetStoneTRN(); if (MyPlayer->_pInfraFlag && lightTableIndex > 8) trn = GetInfravisionTRN(); - if (trn != nullptr) + if (trn != nullptr) { + DrawShadow(out, targetBufferPosition, sprite); ClxDrawTRN(out, targetBufferPosition, sprite, trn); - else + } else { ClxDrawLight(out, targetBufferPosition, sprite, lightTableIndex); + } } /** @@ -478,12 +506,14 @@ void DrawPlayer(const Surface &out, const Player &player, Point tilePosition, Po ClxDrawOutlineSkipColorZero(out, GetPlayerOutlineColor(player.getId()), spriteBufferPosition, sprite); if (&player == MyPlayer && IsNoneOf(leveltype, DTYPE_NEST, DTYPE_CRYPT)) { + DrawShadow(out, spriteBufferPosition, sprite); ClxDraw(out, spriteBufferPosition, sprite); DrawPlayerIcons(out, player, targetBufferPosition, /*infraVision=*/false, lightTableIndex); return; } if (!IsTileLit(tilePosition) || ((MyPlayer->_pInfraFlag || MyPlayer->isOnArenaLevel()) && lightTableIndex > 8)) { + DrawShadow(out, spriteBufferPosition, sprite); ClxDrawTRN(out, spriteBufferPosition, sprite, GetInfravisionTRN()); DrawPlayerIcons(out, player, targetBufferPosition, /*infraVision=*/true, lightTableIndex); return; @@ -533,6 +563,7 @@ void DrawObject(const Surface &out, const Object &objectToDraw, Point tilePositi if (objectToDraw.applyLighting) { ClxDrawLight(out, screenPosition, sprite, lightTableIndex); } else { + DrawShadow(out, screenPosition, sprite); ClxDraw(out, screenPosition, sprite); } } @@ -743,6 +774,7 @@ void DrawMonsterHelper(const Surface &out, Point tilePosition, Point targetBuffe if (mi == pcursmonst) { ClxDrawOutlineSkipColorZero(out, OutlineColorsTowner, position, sprite); } + DrawShadow(out, position, sprite); ClxDraw(out, position, sprite); return; } @@ -804,6 +836,7 @@ void DrawDungeon(const Surface &out, const Lightmap &lightmap, Point tilePositio const ClxSprite sprite = corpse.spritesForDirection(static_cast((bDead >> 5) & 7))[corpse.frame]; if (corpse.translationPaletteIndex != 0) { const uint8_t *trn = Monsters[corpse.translationPaletteIndex - 1].uniqueMonsterTRN.get(); + DrawShadow(out, position, sprite); ClxDrawTRN(out, position, sprite, trn); } else { ClxDrawLight(out, position, sprite, lightTableIndex); diff --git a/Source/engine/shadow_mask.hpp b/Source/engine/shadow_mask.hpp new file mode 100644 index 000000000..7af48d27a --- /dev/null +++ b/Source/engine/shadow_mask.hpp @@ -0,0 +1,140 @@ +#pragma once +/** + * @file shadow_mask.hpp + * + * @brief Shadow masks: the shadows extracted from sprites, stored as horizontal spans. + * + * A shadow is a single-colour shape that is drawn by darkening what is underneath it, + * so it is stored as spans rather than as pixels. + * + * Shadow mask of a single sprite: + * + * Bytes | Type | Value + * :-----:|:-------:|------------------------------------------------------ + * 0 | uint8_t | number of sprite rows below the lowest shadow row + * 1 | uint8_t | number of shadow rows + * 2.. | rows | from the lowest row upwards + * + * Each row is a sequence of spans sorted by x. A span is 2 bytes: + * + * Byte | Bits | Value + * :----:|:----:|-------------------------------------------- + * 0 | 0..7 | x coordinate of the first pixel + * 1 | 0..6 | width + * 1 | 7 | set if this is the last span of the row + * + * A row without shadow pixels is a single span of width 0. + * Runs longer than `MaxShadowSpanWidth` are stored as several spans. + * + * Rows keep their own spans, with absolute x coordinates, instead of letting runs flow from + * one row into the next like CLX does: that way a span can be drawn or clipped on its own, + * without tracking where the previous one ended. + * + * Like CLX sprites, masks are stored bottom-up, so that a mask can be drawn knowing only the + * position of the bottom-left corner of its sprite. + * + * The mask of a sprite is stored in the CLX frame header of that sprite, after the height, + * so it is available wherever the sprite is. Sprites without a shadow have a plain header. + */ + +#include +#include + +#include "appfat.h" +#include "engine/clx_sprite.hpp" + +namespace devilution { + +constexpr size_t ShadowMaskHeaderSize = 2; + +/** The largest sprite that a shadow mask can describe. */ +constexpr unsigned MaxShadowMaskSpriteWidth = 256; +constexpr unsigned MaxShadowMaskSpriteHeight = 255; + +/** The largest shadow mask, in bytes: it has to fit in a CLX frame header. */ +constexpr size_t MaxShadowMaskSize = 0xFFFF - ClxSprite::BaseHeaderSize; + +constexpr uint8_t MaxShadowSpanWidth = 0x7F; +constexpr uint8_t ShadowSpanEndOfRow = 0x80; + +struct ShadowSpan { + uint8_t x; + uint8_t width; +}; + +/** + * @brief The shadow mask of a single sprite. + */ +class ShadowMask { +public: + explicit constexpr ShadowMask(const uint8_t *data) + : data_(data) + { + assert(data != nullptr); + } + + /** @brief The number of sprite rows below the lowest shadow row. */ + [[nodiscard]] constexpr uint8_t bottomOffset() const + { + return data_[0]; + } + + [[nodiscard]] constexpr uint8_t numRows() const + { + return data_[1]; + } + + [[nodiscard]] constexpr bool empty() const + { + return numRows() == 0; + } + + /** @brief The rows, from the lowest one upwards. */ + [[nodiscard]] constexpr const uint8_t *rows() const + { + return &data_[ShadowMaskHeaderSize]; + } + + /** + * @brief Calls `fn(rowFromBottom, span)` for every span. + * + * `rowFromBottom` is the number of sprite rows below the span. + */ + template + constexpr void forEachSpan(F &&fn) const + { + const uint8_t *src = rows(); + uint16_t row = bottomOffset(); + for (unsigned i = numRows(); i != 0; --i, ++row) { + uint8_t widthAndFlag; + do { + widthAndFlag = src[1]; + const uint8_t width = widthAndFlag & MaxShadowSpanWidth; + if (width != 0) fn(row, ShadowSpan { src[0], width }); + src += 2; + } while ((widthAndFlag & ShadowSpanEndOfRow) == 0); + } + } + +private: + const uint8_t *data_; +}; + +/** @brief Whether the sprite carries a shadow mask. */ +[[nodiscard]] constexpr bool HasShadowMask(ClxSprite sprite) +{ + return sprite.headerSize() > ClxSprite::BaseHeaderSize; +} + +/** + * @brief The shadow mask of the sprite. + * + * Requires `HasShadowMask(sprite)`. + */ +[[nodiscard]] constexpr ShadowMask GetShadowMask(ClxSprite sprite) +{ + assert(HasShadowMask(sprite)); + return ShadowMask { sprite.headerExtension() }; +} + +} // namespace devilution diff --git a/Source/engine/shadow_rule.hpp b/Source/engine/shadow_rule.hpp new file mode 100644 index 000000000..0ea21c491 --- /dev/null +++ b/Source/engine/shadow_rule.hpp @@ -0,0 +1,148 @@ +#pragma once +/** + * @file shadow_rule.hpp + * + * @brief Describes how the shadow baked into a sprite was generated. + * + * The original sprites have their shadow drawn into them as black (palette index 0) pixels. + * Every shadow was cast by the same function, measured from a baseline row `B`: + * + * v = (B - y) / 3 + * ys = B - 2 - v + * xs = x - (3 * v) / 4 + * + * where `(x, y)` is a pixel of the sprite at or above the baseline, `(xs, ys)` is the shadow + * pixel it casts, and both divisions are integer divisions. + * + * The sprites only differ in how the baseline was chosen. That choice is the shadow rule. + */ + +#include +#include +#include +#include +#include + +#include "utils/parse_int.hpp" +#include "utils/str_cat.hpp" + +namespace devilution { + +enum class ShadowRuleType : uint8_t { + /** The sprite has black pixels but no known rule reproduces them, or it was never analysed. */ + Unknown, + /** The sprite has no shadow. */ + None, + /** The baseline is the lowest opaque row of the first frame of the animation (per direction). */ + FirstFrame, + /** The baseline is the lowest opaque row of each frame. */ + EachFrame, + /** + * The sprite has a shadow, but it cannot be told apart from the black pixels of the sprite itself: + * part of the sprite is black and lies inside its own shadow. The shadow stays part of the sprite. + */ + Baked, +}; + +/** + * Notes on how well the shadow rule reproduces the shadow of the sprite. + * + * They do not change how the shadow is extracted. + */ +enum class ShadowRuleFlags : uint8_t { + // clang-format off + None = 0, + /** Some black pixels of the sprite are neither a shadow that the rule casts nor plausibly part of the sprite itself. */ + Unexplained = 1 << 0, + /** The rule casts a shadow onto some pixels that are transparent in the sprite. */ + Stray = 1 << 1, + // clang-format on +}; + +[[nodiscard]] constexpr ShadowRuleFlags operator|(ShadowRuleFlags a, ShadowRuleFlags b) +{ + return static_cast(static_cast(a) | static_cast(b)); +} + +[[nodiscard]] constexpr bool HasAnyOf(ShadowRuleFlags flags, ShadowRuleFlags test) +{ + return (static_cast(flags) & static_cast(test)) != 0; +} + +struct ShadowRule { + ShadowRuleType type = ShadowRuleType::Unknown; + + /** + * @brief Number of rows added to the baseline. + * + * Used by flying monsters, whose shadow is detached from the body. Only valid with `FirstFrame`. + */ + uint8_t baselineOffset = 0; + + ShadowRuleFlags flags = ShadowRuleFlags::None; + + constexpr bool operator==(const ShadowRule &other) const = default; + + /** @brief Whether the sprite has a shadow that can be separated from it. */ + [[nodiscard]] constexpr bool canExtractShadow() const + { + return type == ShadowRuleType::FirstFrame || type == ShadowRuleType::EachFrame; + } +}; + +/** + * @brief Parses a shadow rule as written in the TSV data files. + * + * A rule is one of: an empty string (same as `Unknown`), `Unknown`, `None`, `Baked`, `FirstFrame`, + * `EachFrame`, and `FirstFrame+N` where `N` is the baseline offset (1 to 255). + * + * It can be followed by the tags `Unexplained` and `Stray` (see `ShadowRuleFlags`), + * each preceded by `|`, e.g. `FirstFrame|Stray`. + */ +inline std::expected ParseShadowRule(std::string_view value) +{ + constexpr char Separator = '|'; + const size_t ruleEnd = std::min(value.find(Separator), value.size()); + const std::string_view ruleStr = value.substr(0, ruleEnd); + + ShadowRule result; + constexpr std::string_view FirstFrame = "FirstFrame"; + if (ruleStr.empty() || ruleStr == "Unknown") { + result.type = ShadowRuleType::Unknown; + } else if (ruleStr == "None") { + result.type = ShadowRuleType::None; + } else if (ruleStr == "Baked") { + result.type = ShadowRuleType::Baked; + } else if (ruleStr == "EachFrame") { + result.type = ShadowRuleType::EachFrame; + } else if (ruleStr == FirstFrame) { + result.type = ShadowRuleType::FirstFrame; + } else if (ruleStr.starts_with(FirstFrame) && ruleStr.size() > FirstFrame.size() + 1 && ruleStr[FirstFrame.size()] == '+') { + const std::string_view offsetStr = ruleStr.substr(FirstFrame.size() + 1); + const char *end = nullptr; + const ParseIntResult offset = ParseInt(offsetStr, 1, 255, &end); + if (!offset.has_value() || end != offsetStr.data() + offsetStr.size()) + return std::unexpected(StrCat("Unknown shadow rule: ", ruleStr)); + result.type = ShadowRuleType::FirstFrame; + result.baselineOffset = *offset; + } else { + return std::unexpected(StrCat("Unknown shadow rule: ", ruleStr)); + } + + for (size_t pos = ruleEnd; pos < value.size();) { + const size_t tagBegin = pos + 1; + const size_t tagEnd = std::min(value.find(Separator, tagBegin), value.size()); + const std::string_view tag = value.substr(tagBegin, tagEnd - tagBegin); + if (tag == "Unexplained") { + result.flags = result.flags | ShadowRuleFlags::Unexplained; + } else if (tag == "Stray") { + result.flags = result.flags | ShadowRuleFlags::Stray; + } else { + return std::unexpected(StrCat("Unknown shadow rule tag: ", tag)); + } + pos = tagEnd; + } + return result; +} + +} // namespace devilution diff --git a/Source/lua/modules/dev/display.cpp b/Source/lua/modules/dev/display.cpp index c00f9965a..d33f3b615 100644 --- a/Source/lua/modules/dev/display.cpp +++ b/Source/lua/modules/dev/display.cpp @@ -34,6 +34,12 @@ std::string DebugCmdPath(std::optional on) return StrCat("Path highlighting: ", DebugPath ? "On" : "Off"); } +std::string DebugCmdShadows(std::optional on) +{ + DebugHideShadows = !on.value_or(DebugHideShadows); + return StrCat("Sprite shadows: ", DebugHideShadows ? "Off" : "On"); +} + std::string DebugCmdFullbright(std::optional on) { ToggleLighting(); @@ -126,6 +132,7 @@ sol::table LuaDevDisplayModule(sol::state_view &lua) LuaSetDocFn(table, "grid", "(on: boolean = nil)", "Toggle showing the grid.", &DebugCmdShowGrid); LuaSetDocFn(table, "path", "(on: boolean = nil)", "Toggle path debug rendering.", &DebugCmdPath); LuaSetDocFn(table, "scrollView", "(on: boolean = nil)", "Toggle view scrolling via Shift+Mouse.", &DebugCmdScrollView); + LuaSetDocFn(table, "shadows", "(on: boolean = nil)", "Toggle drawing the shadows of sprites. Shadows that are still part of their sprite stay visible.", &DebugCmdShadows); LuaSetDocFn(table, "tileData", "(name: string = nil)", "Toggle showing tile data.", &DebugCmdShowTileData); LuaSetDocFn(table, "vision", "(on: boolean = nil)", "Toggle vision debug rendering.", &DebugCmdVision); return table; diff --git a/Source/monster.cpp b/Source/monster.cpp index 19c219244..cf8777296 100644 --- a/Source/monster.cpp +++ b/Source/monster.cpp @@ -90,6 +90,7 @@ #include "utils/algorithm/container.hpp" #include "utils/attributes.h" #include "utils/cl2_to_clx.hpp" +#include "utils/clx_shadow.hpp" #include "utils/endian_swap.hpp" #include "utils/enum_traits.h" #include "utils/file_name_generator.hpp" @@ -3211,6 +3212,52 @@ bool UpdateModeStance(Monster &monster) } } +namespace { + +/** + * @brief Separates the shadows baked into the monster's sprites (see `utils/clx_shadow.hpp`). + * + * Each animation has its own shadow rule. + */ +void ExtractMonsterShadows(const MonsterData &monsterData, MonsterSpritesData &sprites) +{ + const size_t numAnims = GetNumAnims(monsterData); + std::array, MonsterSpritesData::MaxAnims> extracted; + std::array offsets; + bool anyExtracted = false; + size_t size = 0; + size_t j = 0; + for (size_t i = 0; i < numAnims; ++i) { + if (!monsterData.hasAnim(i)) + continue; + const auto *data = reinterpret_cast(&sprites.data[sprites.offsets[j]]); + const size_t dataSize = sprites.offsets[j + 1] - sprites.offsets[j]; + const uint16_t numLists = GetNumListsFromClxListOrSheetBuffer(data, dataSize); + extracted[j] = ExtractClxShadows(ClxSpriteListOrSheet { data, numLists }, monsterData.shadowRules[i]); + anyExtracted = anyExtracted || extracted[j].has_value(); + offsets[j] = static_cast(size); + size += extracted[j].has_value() ? extracted[j]->dataSize() : dataSize; + ++j; + } + if (!anyExtracted) + return; + offsets[j] = static_cast(size); + + std::unique_ptr data { new std::byte[size] }; + for (size_t k = 0; k < j; ++k) { + if (extracted[k].has_value()) { + const ClxSpriteListOrSheet clx { *extracted[k] }; + memcpy(&data[offsets[k]], clx.isSheet() ? clx.sheet().data() : clx.list().data(), offsets[k + 1] - offsets[k]); + } else { + memcpy(&data[offsets[k]], &sprites.data[sprites.offsets[k]], offsets[k + 1] - offsets[k]); + } + } + sprites.data = std::move(data); + sprites.offsets = offsets; +} + +} // namespace + MonsterSpritesData LoadMonsterSpritesData(const MonsterData &monsterData) { const size_t numAnims = GetNumAnims(monsterData); @@ -3246,6 +3293,8 @@ MonsterSpritesData LoadMonsterSpritesData(const MonsterData &monsterData) } #endif + ExtractMonsterShadows(monsterData, result); + return result; } diff --git a/Source/objects.cpp b/Source/objects.cpp index b8d705d1f..c1753e0eb 100644 --- a/Source/objects.cpp +++ b/Source/objects.cpp @@ -44,6 +44,7 @@ #include "towners.h" #include "track.h" #include "utils/algorithm/container.hpp" +#include "utils/clx_shadow.hpp" #include "utils/endian_swap.hpp" #include "utils/format.hpp" #include "utils/is_of.hpp" @@ -3705,6 +3706,10 @@ std::expected LoadLevelObjects(uint16_t filesWidths[65]) char filestr[32]; *BufCopy(filestr, "objects\\", ObjMasterLoadList[i]) = '\0'; ASSIGN_OR_RETURN(pObjCels[numobjfiles], LoadCelWithStatus(filestr, filesWidths[i])); + // Objects that share a sprite file share its shadow rule. + const auto objectDataIt = std::find_if(AllObjects.begin(), AllObjects.end(), [i](const ObjectData &objectData) { return objectData.ofindex == i; }); + if (objectDataIt != AllObjects.end()) + ExtractClxShadowsInPlace(*pObjCels[numobjfiles], objectDataIt->shadowRule); numobjfiles++; } return {}; diff --git a/Source/options.cpp b/Source/options.cpp index d7ed9b4a8..00dd2a943 100644 --- a/Source/options.cpp +++ b/Source/options.cpp @@ -797,6 +797,7 @@ GraphicsOptions::GraphicsOptions() , zoom("Zoom", OptionEntryFlags::None, N_("Zoom"), N_("Zoom on when enabled."), false) , perPixelLighting("Per-pixel Lighting", OptionEntryFlags::None, N_("Per-pixel Lighting"), N_("Subtile lighting for smoother light gradients."), DEFAULT_PER_PIXEL_LIGHTING) , colorCycling("Color Cycling", OptionEntryFlags::None, N_("Color Cycling"), N_("Color cycling effect used for water, lava, and acid animation."), true) + , transparentShadows("Transparent Shadows", OptionEntryFlags::None, N_("Transparent Shadows"), N_("Shadows of characters, monsters and objects darken the ground instead of covering it."), false) , alternateNestArt("Alternate nest art", OptionEntryFlags::OnlyHellfire | OptionEntryFlags::CantChangeInGame, N_("Alternate nest art"), N_("The game will use an alternative palette for Hellfire’s nest tileset."), false) #if SDL_VERSION_ATLEAST(2, 0, 0) , hardwareCursor("Hardware Cursor", OptionEntryFlags::CantChangeInGame | OptionEntryFlags::RecreateUI | (HardwareCursorSupported() ? OptionEntryFlags::None : OptionEntryFlags::Invisible), N_("Hardware Cursor"), N_("Use a hardware cursor"), HardwareCursorDefault()) @@ -828,6 +829,7 @@ std::vector GraphicsOptions::GetEntries() &showFPS, &perPixelLighting, &colorCycling, + &transparentShadows, &alternateNestArt, #if SDL_VERSION_ATLEAST(2, 0, 0) &hardwareCursor, diff --git a/Source/options.h b/Source/options.h index 8f674f9f7..9fffe3f7e 100644 --- a/Source/options.h +++ b/Source/options.h @@ -562,6 +562,8 @@ struct GraphicsOptions : OptionCategoryBase { OptionEntryBoolean perPixelLighting; /** @brief Enable color cycling animations. */ OptionEntryBoolean colorCycling; + /** @brief Draw the shadows of sprites by darkening what is underneath instead of covering it. */ + OptionEntryBoolean transparentShadows; /** @brief Use alternate nest palette. */ OptionEntryBoolean alternateNestArt; #if SDL_VERSION_ATLEAST(2, 0, 0) diff --git a/Source/panels/partypanel.cpp b/Source/panels/partypanel.cpp index 5327a3a45..655184fbf 100644 --- a/Source/panels/partypanel.cpp +++ b/Source/panels/partypanel.cpp @@ -312,7 +312,8 @@ void DrawPartyMemberInfoPanel(const Surface &out) PortraitFrameSize.width - (FrameBorderSize * 2), PortraitFrameSize.height - (FrameBorderSize * 2)); - // Draw the portrait sprite + // Draw the portrait sprite, with its shadow + ClxDrawShadow(frameSubregion, { portraitPos.x, portraitPos.y + static_cast(monsterPortraitSprite.height()) - 1 }, monsterPortraitSprite); RenderClxSprite( frameSubregion, monsterPortraitSprite, diff --git a/Source/player.cpp b/Source/player.cpp index 5b79f8195..c7242a0b5 100644 --- a/Source/player.cpp +++ b/Source/player.cpp @@ -52,6 +52,7 @@ #include "spells.h" #include "stores.h" #include "towners.h" +#include "utils/clx_shadow.hpp" #include "utils/format.hpp" #include "utils/is_of.hpp" #include "utils/language.h" @@ -2160,6 +2161,7 @@ void LoadPlrGFX(Player &player, player_graphic graphic) GetPlayerGraphicsPath(path, std::string_view(prefixBuf, 3), szCel, pszName); const uint16_t animationWidth = GetPlayerSpriteWidth(cls, graphic, animWeaponId); animationData.sprites = LoadCl2Sheet(pszName, animationWidth); + ExtractClxShadowsInPlace(*animationData.sprites, spriteData.shadowRule); std::optional> graphicTRN = GetPlayerGraphicTRN(pszName); if (graphicTRN) { ClxApplyTrans(*animationData.sprites, graphicTRN->data()); diff --git a/Source/tables/monstdat.cpp b/Source/tables/monstdat.cpp index 5c6d1c171..403bc2a39 100644 --- a/Source/tables/monstdat.cpp +++ b/Source/tables/monstdat.cpp @@ -399,6 +399,7 @@ void LoadMonstDatFromFile(DataFile &dataFile, const std::string_view filename, b reader.read("treasure", monster.treasure, ParseMonsterTreasure); reader.readInt("exp", monster.exp); + reader.readEnumArray("shadowRule", std::optional { ShadowRule {} }, monster.shadowRules, ParseShadowRule); } } diff --git a/Source/tables/monstdat.h b/Source/tables/monstdat.h index da982c5af..5ad5782c6 100644 --- a/Source/tables/monstdat.h +++ b/Source/tables/monstdat.h @@ -13,7 +13,9 @@ #include #include "cursor_defs.hpp" +#include "engine/shadow_rule.hpp" #include "tables/textdat.h" +#include "utils/attributes.h" namespace devilution { @@ -143,6 +145,8 @@ struct MonsterData { /** Using monster_treasure */ uint16_t treasure = 0; uint16_t exp = 0; + /** How the shadow baked into each animation's sprites was generated, indexed like `frames`. */ + ShadowRule shadowRules[6] {}; [[nodiscard]] const char *spritePath() const; @@ -347,7 +351,7 @@ struct UniqueMonsterData { _speech_id mtalkmsg; }; -extern std::vector MonstersData; +extern DVL_API_FOR_TEST std::vector MonstersData; extern const _monster_id MonstConvTbl[]; extern std::vector UniqueMonstersData; diff --git a/Source/tables/objdat.cpp b/Source/tables/objdat.cpp index f40e2dc6d..0bb2d03ee 100644 --- a/Source/tables/objdat.cpp +++ b/Source/tables/objdat.cpp @@ -292,6 +292,7 @@ void LoadObjectData() reader.readInt("animLen", item.animLen); reader.readInt("animWidth", item.animWidth); reader.readEnumList("selectionRegion", item.selectionRegion, ParseSelectionRegion); + reader.read("shadowRule", item.shadowRule, ParseShadowRule); } // Sanity check because we do not actually parse the IDs yet. diff --git a/Source/tables/objdat.h b/Source/tables/objdat.h index 5ca47cda5..c8b6ced7f 100644 --- a/Source/tables/objdat.h +++ b/Source/tables/objdat.h @@ -10,8 +10,10 @@ #include #include "cursor_defs.hpp" +#include "engine/shadow_rule.hpp" #include "levels/gendung_defs.hpp" #include "tables/questdat.hpp" +#include "utils/attributes.h" namespace devilution { @@ -177,6 +179,8 @@ struct ObjectData { uint8_t animLen; // Number of frames in current animation uint8_t animWidth; SelectionRegion selectionRegion; + /** How the shadow baked into the object's sprite was generated. */ + ShadowRule shadowRule; [[nodiscard]] bool isAnimated() const { @@ -210,7 +214,7 @@ struct ObjectData { }; extern const _object_id ObjTypeConv[]; -extern std::vector AllObjects; +extern DVL_API_FOR_TEST std::vector AllObjects; extern std::vector ObjMasterLoadList; void LoadObjectData(); diff --git a/Source/tables/playerdat.cpp b/Source/tables/playerdat.cpp index 8df02b3c5..2cbe6a730 100644 --- a/Source/tables/playerdat.cpp +++ b/Source/tables/playerdat.cpp @@ -250,6 +250,7 @@ void LoadClassSpriteData(std::string_view classPath, PlayerSpriteData &spriteDat reader.readInt("fire", spriteData.fire); reader.readInt("magic", spriteData.magic); reader.readInt("death", spriteData.death); + reader.read("shadowRule", spriteData.shadowRule, ParseShadowRule); } void LoadClassAnimData(std::string_view classPath, PlayerAnimData &animData) diff --git a/Source/tables/playerdat.hpp b/Source/tables/playerdat.hpp index 6c1a121a9..4cb83e044 100644 --- a/Source/tables/playerdat.hpp +++ b/Source/tables/playerdat.hpp @@ -10,6 +10,7 @@ #include +#include "engine/shadow_rule.hpp" #include "sound_effect_enums.h" #include "tables/itemdat.h" #include "tables/spelldat.h" @@ -173,6 +174,8 @@ struct PlayerSpriteData { uint8_t magic; /* Sprite width: Death */ uint8_t death; + /* How the shadow baked into the class sprites was generated */ + ShadowRule shadowRule; }; struct PlayerAnimData { diff --git a/Source/tables/townerdat.cpp b/Source/tables/townerdat.cpp index 5d6e367d0..2bce62683 100644 --- a/Source/tables/townerdat.cpp +++ b/Source/tables/townerdat.cpp @@ -125,6 +125,8 @@ void LoadTownersFromFile() std::string animOrderStr; reader.readString("animOrder", animOrderStr); ParseAnimOrder(animOrderStr, entry.animOrder); + + reader.read("shadowRule", entry.shadowRule, ParseShadowRule); } TownersDataEntries.shrink_to_fit(); diff --git a/Source/tables/townerdat.hpp b/Source/tables/townerdat.hpp index 08a011b66..adf0f4367 100644 --- a/Source/tables/townerdat.hpp +++ b/Source/tables/townerdat.hpp @@ -11,6 +11,7 @@ #include #include "engine/direction.hpp" +#include "engine/shadow_rule.hpp" #include "levels/gendung.h" #include "tables/objdat.h" #include "tables/textdat.h" @@ -33,6 +34,8 @@ struct TownerDataEntry { int16_t animDelay; std::vector<_speech_id> gossipTexts; std::vector animOrder; + /** How the shadow baked into the towner's sprite was generated. */ + ShadowRule shadowRule; }; /** Contains the data for all towners loaded from TSV. */ diff --git a/Source/towners.cpp b/Source/towners.cpp index b02293144..ceec62b20 100644 --- a/Source/towners.cpp +++ b/Source/towners.cpp @@ -10,12 +10,14 @@ #include "engine/load_cel.hpp" #include "engine/load_file.hpp" #include "engine/random.hpp" +#include "engine/shadow_rule.hpp" #include "game_mode.hpp" #include "inv.h" #include "minitext.h" #include "stores.h" #include "tables/textdat.h" #include "tables/townerdat.hpp" +#include "utils/clx_shadow.hpp" #include "utils/is_of.hpp" #include "utils/language.h" #include "utils/str_case.hpp" @@ -104,10 +106,11 @@ void InitTownerInfo(Towner &towner, const TownerData &townerData, const TownerDa } } -void LoadTownerAnimations(Towner &towner, const char *path, int frames, int delay) +void LoadTownerAnimations(Towner &towner, const char *path, int frames, int delay, ShadowRule shadowRule) { towner.ownedAnim = std::nullopt; towner.ownedAnim = LoadCel(path, towner._tAnimWidth); + ExtractClxShadowsInPlace(*towner.ownedAnim, shadowRule); NewTownerAnim(towner, *towner.ownedAnim, frames, delay); } @@ -127,7 +130,7 @@ void InitTownerFromData(Towner &towner, const TownerDataEntry &entry) } if (!entry.animPath.empty()) { - LoadTownerAnimations(towner, entry.animPath.c_str(), entry.animFrames, entry.animDelay); + LoadTownerAnimations(towner, entry.animPath.c_str(), entry.animFrames, entry.animDelay, entry.shadowRule); } // Set gossip from TSV data @@ -182,10 +185,13 @@ void InitCowFarmer(Towner &towner, const TownerDataEntry &entry) // CowFarmer has special logic for quest state const char *celPath = "towners\\farmer\\cfrmrn2"; + // The data file only describes the shadow of the first sprite. + ShadowRule shadowRule = entry.shadowRule; if (Quests[Q_JERSEY]._qactive == QUEST_DONE) { celPath = "towners\\farmer\\mfrmrn2"; + shadowRule = {}; } - LoadTownerAnimations(towner, celPath, 15, 3); + LoadTownerAnimations(towner, celPath, 15, 3, shadowRule); } void TownDead(Towner &towner) @@ -858,7 +864,7 @@ void UpdateGirlAnimAfterQuestComplete() if (girl == nullptr || !girl->ownedAnim) return; // Girl is not spawned in town yet auto curFrame = girl->_tAnimFrame; - LoadTownerAnimations(*girl, "towners\\girl\\girls1", 20, 6); + LoadTownerAnimations(*girl, "towners\\girl\\girls1", 20, 6, /*shadowRule=*/ {}); girl->_tAnimFrame = std::min(curFrame, girl->_tAnimLen - 1); } @@ -866,7 +872,7 @@ void UpdateCowFarmerAnimAfterQuestComplete() { Towner *cowFarmer = GetTowner(TOWN_COWFARM); auto curFrame = cowFarmer->_tAnimFrame; - LoadTownerAnimations(*cowFarmer, "towners\\farmer\\mfrmrn2", 15, 3); + LoadTownerAnimations(*cowFarmer, "towners\\farmer\\mfrmrn2", 15, 3, /*shadowRule=*/ {}); cowFarmer->_tAnimFrame = std::min(curFrame, cowFarmer->_tAnimLen - 1); } diff --git a/Source/utils/clx_shadow.cpp b/Source/utils/clx_shadow.cpp new file mode 100644 index 000000000..9b4dbabb2 --- /dev/null +++ b/Source/utils/clx_shadow.cpp @@ -0,0 +1,417 @@ +#include "utils/clx_shadow.hpp" + +#include +#include +#include +#include +#include +#include + +#include "appfat.h" +#include "utils/clx_decode.hpp" +#include "utils/clx_encode.hpp" +#include "utils/endian_write.hpp" + +namespace devilution { + +namespace { + +enum PixelFlags : uint8_t { + Opaque = 1 << 0, + /** Part of the sprite itself. */ + Body = 1 << 1, + /** Covered by the shadow of a body pixel. */ + Shadow = 1 << 2, + /** A black pixel that is neither covered by a shadow nor plausibly a body pixel. */ + Orphan = 1 << 3, +}; + +/** The columns `[begin, end)` of a row. Empty if `begin >= end`. */ +struct Extent { + uint16_t begin; + uint16_t end; + + void include(int from, int to) + { + if (from < begin) begin = static_cast(from); + if (to > end) end = static_cast(to); + } +}; + +/** + * A decoded sprite. Rows are stored top-down. + * + * Most of a sprite is empty, so the rows that have pixels and the extent of the pixels + * of each row are kept, and everything works on those instead of on whole rows. + */ +struct Frame { + int width = 0; + int height = 0; + /** Only valid for opaque pixels. */ + std::vector colors; + std::vector flags; + /** The extent of the opaque pixels of each row. */ + std::vector opaque; + /** An extent that contains the pixels of each row that have the `Shadow` flag. */ + std::vector shadow; + /** The indices of the black pixels, in order. Filled by `SeparateShadow`. */ + std::vector blackPixels; + /** Scratch space of `SeparateShadow`: columns of a row. */ + std::vector uncovered; + /** The first and the last row that have an opaque pixel. `bottom` is -1 if there are none. */ + int top = 0; + int bottom = -1; + + [[nodiscard]] size_t index(int x, int y) const + { + return static_cast(y) * width + x; + } + + [[nodiscard]] bool isBlack(size_t i) const + { + return (flags[i] & Opaque) != 0 && colors[i] == 0; + } + + void setShadow(int x, int y) + { + flags[index(x, y)] |= Shadow; + shadow[y].include(x, x + 1); + } +}; + +void DecodeFrame(ClxSprite sprite, Frame &frame) +{ + const int width = sprite.width(); + const int height = sprite.height(); + frame.width = width; + frame.height = height; + const size_t numPixels = static_cast(width) * height; + frame.colors.resize(numPixels); + frame.flags.assign(numPixels, 0); + const Extent empty { static_cast(width), 0 }; + frame.opaque.assign(height, empty); + frame.shadow.assign(height, empty); + frame.top = height; + frame.bottom = -1; + + // CLX pixel data is stored bottom-up. Transparent runs can continue on the next row. + // Opaque runs must not, but a run that does is taken to continue there too, like the + // run of the original image that it was made from. + int x = 0; + int y = height - 1; + // Writes an opaque run, either the given pixels or `length` pixels of the fill color. + const auto opaqueRun = [&](unsigned length, const uint8_t *pixels, uint8_t fillColor) { + while (length != 0 && y >= 0) { + const unsigned chunk = std::min(length, static_cast(width - x)); + const size_t i = frame.index(x, y); + if (pixels != nullptr) { + memcpy(&frame.colors[i], pixels, chunk); + pixels += chunk; + } else { + memset(&frame.colors[i], fillColor, chunk); + } + memset(&frame.flags[i], Opaque, chunk); + frame.opaque[y].include(x, x + static_cast(chunk)); + if (y > frame.bottom) frame.bottom = y; + if (y < frame.top) frame.top = y; + x += static_cast(chunk); + length -= chunk; + if (x == width) { + x = 0; + --y; + } + } + }; + const uint8_t *src = sprite.pixelData(); + const uint8_t *end = src + sprite.pixelDataSize(); + while (src < end) { + const uint8_t control = *src++; + if (!IsClxOpaque(control)) { + x += control; + if (x >= width) { + y -= x / width; + x %= width; + } + } else if (IsClxOpaqueFill(control)) { + opaqueRun(GetClxOpaqueFillWidth(control), nullptr, *src++); + } else { + const unsigned length = GetClxOpaquePixelsWidth(control); + opaqueRun(length, src, 0); + src += length; + } + } +} + +/** + * @brief The shadow function for a row: where the pixels of the row cast their shadow. + */ +struct RowShadow { + /** Whether the pixels of the row cast a shadow inside the frame. */ + bool casts; + /** The row that the shadow falls on. */ + int y; + /** The number of pixels that the shadow is shifted to the left. */ + int shift; +}; + +RowShadow ShadowOfRow(const Frame &frame, int baseline, int y) +{ + if (y > baseline) return { false, 0, 0 }; + const int v = (baseline - y) / 3; + const int shadowY = baseline - 2 - v; + return { shadowY >= 0 && shadowY < frame.height, shadowY, (3 * v) / 4 }; +} + +/** + * @brief Sets the `Body`, `Shadow` and `Orphan` flags of the frame. + */ +void SeparateShadow(Frame &frame, int baseline) +{ + // Everything that is not black is certainly not a shadow. + // The black pixels are collected for the second pass. + frame.blackPixels.clear(); + for (int y = frame.top; y <= frame.bottom; ++y) { + const Extent extent = frame.opaque[y]; + if (extent.begin >= extent.end) continue; + const RowShadow rowShadow = ShadowOfRow(frame, baseline, y); + const size_t row = frame.index(0, y); + // The shadow of the pixels to the left of `shift` falls outside of the frame. + const size_t shadowRow = rowShadow.casts ? frame.index(0, rowShadow.y) : 0; + const int shift = rowShadow.casts ? rowShadow.shift : frame.width; + for (int x = extent.begin; x < extent.end; ++x) { + if ((frame.flags[row + x] & Opaque) == 0) continue; + if (frame.colors[row + x] == 0) { + frame.blackPixels.push_back(static_cast(row + x)); + continue; + } + frame.flags[row + x] |= Body; + if (x >= shift) frame.flags[shadowRow + x - shift] |= Shadow; + } + if (rowShadow.casts && extent.end > shift) frame.shadow[rowShadow.y].include(std::max(extent.begin, shift) - shift, extent.end - shift); + } + + // A black pixel that is not in the shadow of the pixels above it must be a body pixel, + // and so it casts a shadow too. Shadows only fall downwards (except within the lowest + // rows, where a pixel shadows its own column), so one top-down pass settles them all. + std::vector &uncovered = frame.uncovered; + const size_t numBlackPixels = frame.blackPixels.size(); + for (size_t next = 0; next < numBlackPixels;) { + // The black pixels of one row. + const int y = static_cast(frame.blackPixels[next] / frame.width); + const size_t row = frame.index(0, y); + const size_t rowEnd = row + frame.width; + uncovered.clear(); + for (; next < numBlackPixels && frame.blackPixels[next] < rowEnd; ++next) { + const size_t i = frame.blackPixels[next]; + if ((frame.flags[i] & Shadow) == 0) uncovered.push_back(static_cast(i - row)); + } + if (uncovered.empty()) continue; + + const RowShadow rowShadow = ShadowOfRow(frame, baseline, y); + for (const int x : uncovered) { + const size_t i = row + x; + // A pixel whose shadow would fall on a transparent pixel is not a body pixel. + if (!rowShadow.casts || x < rowShadow.shift || (frame.flags[frame.index(x - rowShadow.shift, rowShadow.y)] & Opaque) != 0) { + frame.flags[i] |= Body; + continue; + } + + // It may be the shadow of a black body pixel that we took for a shadow because it lies + // inside the shadow of the pixels above it. Look for one in the 3 rows that shadow this row. + const int v = baseline - 2 - y; + const int sourceX = x + (3 * v) / 4; + bool found = false; + for (int sourceY = baseline - 3 * v - 2; v >= 2 && sourceY <= baseline - 3 * v && !found; ++sourceY) { + if (sourceX >= frame.width || sourceY < 0) continue; + const size_t source = frame.index(sourceX, sourceY); + if (!frame.isBlack(source) || (frame.flags[source] & (Body | Orphan)) != 0) continue; + frame.flags[source] |= Body; + frame.setShadow(x, y); + found = true; + } + if (!found) frame.flags[i] |= Orphan; + } + if (!rowShadow.casts) continue; + for (const int x : uncovered) { + if ((frame.flags[row + x] & Body) != 0 && x >= rowShadow.shift) frame.setShadow(x - rowShadow.shift, rowShadow.y); + } + } +} + +/** Whether the pixel stays in the sprite once its shadow is removed. */ +bool IsKept(const Frame &frame, size_t i) +{ + const uint8_t flags = frame.flags[i]; + if ((flags & Opaque) == 0) return false; + return frame.colors[i] != 0 || (flags & (Body | Orphan)) != 0; +} + +/** Appends the shadow mask of the frame. Appends nothing if the frame has no shadow. */ +void AppendShadowMask(const Frame &frame, std::vector &out) +{ + constexpr uint8_t Mask = Opaque | Shadow; + // The mask is limited to opaque pixels, so only look where a row has both. + const auto maskExtent = [&](int y) { + return Extent { std::max(frame.opaque[y].begin, frame.shadow[y].begin), std::min(frame.opaque[y].end, frame.shadow[y].end) }; + }; + const auto rowHasShadow = [&](int y) { + const Extent extent = maskExtent(y); + const size_t row = frame.index(0, y); + for (int x = extent.begin; x < extent.end; ++x) { + if ((frame.flags[row + x] & Mask) == Mask) return true; + } + return false; + }; + int lowest = frame.bottom; + while (lowest >= frame.top && !rowHasShadow(lowest)) + --lowest; + if (lowest < frame.top) return; + int highest = frame.top; + while (highest < lowest && !rowHasShadow(highest)) + ++highest; + + [[maybe_unused]] const size_t begin = out.size(); + out.push_back(static_cast(frame.height - 1 - lowest)); + out.push_back(static_cast(lowest - highest + 1)); + + for (int y = lowest; y >= highest; --y) { + const size_t rowBegin = out.size(); + const Extent extent = maskExtent(y); + const size_t row = frame.index(0, y); + int x = extent.begin; + while (x < extent.end) { + if ((frame.flags[row + x] & Mask) != Mask) { + ++x; + continue; + } + int runEnd = x + 1; + while (runEnd < extent.end && (frame.flags[row + runEnd] & Mask) == Mask) + ++runEnd; + while (x < runEnd) { + const int width = runEnd - x > MaxShadowSpanWidth ? MaxShadowSpanWidth : runEnd - x; + out.push_back(static_cast(x)); + out.push_back(static_cast(width)); + x += width; + } + } + // A row without shadow pixels is a span of width 0. + if (out.size() == rowBegin) { + out.push_back(0); + out.push_back(0); + } + out.back() |= ShadowSpanEndOfRow; + } + // At most one span for every 2 pixels of a row. + static_assert(ShadowMaskHeaderSize + MaxShadowMaskSpriteHeight * MaxShadowMaskSpriteWidth <= MaxShadowMaskSize); + assert(out.size() - begin <= MaxShadowMaskSize); +} + +/** Appends the frame without its shadow, with the shadow mask in the frame header. */ +void AppendSpriteWithoutShadow(const Frame &frame, std::vector &out) +{ + const size_t headerPos = out.size(); + out.resize(out.size() + ClxFrameHeaderSize); + AppendShadowMask(frame, out); + WriteLE16(&out[headerPos], static_cast(out.size() - headerPos)); + WriteLE16(&out[headerPos + 2], static_cast(frame.width)); + WriteLE16(&out[headerPos + 4], static_cast(frame.height)); + + // Opaque runs end with their row: only transparent runs can continue on the next one. + unsigned transparentRunWidth = 0; + for (int y = frame.height - 1; y >= 0; --y) { + const Extent extent = frame.opaque[y]; + if (extent.begin >= extent.end) { + transparentRunWidth += frame.width; + continue; + } + const size_t rowBegin = frame.index(0, y); + transparentRunWidth += extent.begin; + int x = extent.begin; + while (x < extent.end) { + if (!IsKept(frame, rowBegin + x)) { + ++transparentRunWidth; + ++x; + continue; + } + int runEnd = x + 1; + while (runEnd < extent.end && IsKept(frame, rowBegin + runEnd)) + ++runEnd; + AppendClxTransparentRun(transparentRunWidth, out); + transparentRunWidth = 0; + AppendClxPixelsOrFillRun(&frame.colors[rowBegin + x], static_cast(runEnd - x), out); + x = runEnd; + } + transparentRunWidth += frame.width - extent.end; + } + AppendClxTransparentRun(transparentRunWidth, out); +} + +/** + * @brief Extracts the shadows of a list, appending the new list to the output. + * + * @return false if a sprite is too large. + */ +bool ExtractList(ClxSpriteList list, ShadowRule rule, std::vector &clxOut) +{ + const uint32_t numSprites = list.numSprites(); + const size_t clxBegin = clxOut.size(); + clxOut.resize(clxBegin + 4 * (2 + static_cast(numSprites))); + WriteLE32(&clxOut[clxBegin], numSprites); + + Frame frame; + int firstFrameBaseline = 0; + for (uint32_t i = 0; i < numSprites; ++i) { + const ClxSprite sprite = list[i]; + if (sprite.width() > MaxShadowMaskSpriteWidth || sprite.height() > MaxShadowMaskSpriteHeight) return false; + DecodeFrame(sprite, frame); + + // The lowest row that has an opaque pixel, -1 if there is none. + const int lowest = frame.bottom; + if (i == 0) firstFrameBaseline = lowest; + int baseline = rule.type == ShadowRuleType::EachFrame ? lowest : firstFrameBaseline; + // A sprite without pixels has no baseline. + if (baseline >= 0) { + baseline += rule.baselineOffset; + SeparateShadow(frame, baseline); + } + + WriteLE32(&clxOut[clxBegin + 4 * (1 + static_cast(i))], static_cast(clxOut.size() - clxBegin)); + AppendSpriteWithoutShadow(frame, clxOut); + } + WriteLE32(&clxOut[clxBegin + 4 * (1 + static_cast(numSprites))], static_cast(clxOut.size() - clxBegin)); + return true; +} + +std::unique_ptr ToOwnedBuffer(const std::vector &data) +{ + std::unique_ptr result { new uint8_t[data.size()] }; + memcpy(result.get(), data.data(), data.size()); + return result; +} + +} // namespace + +std::optional ExtractClxShadows(ClxSpriteListOrSheet sprites, ShadowRule rule) +{ + if (!rule.canExtractShadow()) return std::nullopt; + + std::vector clxData; + // The result is about as large as the sprites it is made from. + clxData.reserve(sprites.dataSize() + sprites.dataSize() / 16); + uint16_t numLists = 0; + if (sprites.isSheet()) { + const ClxSpriteSheet sheet = sprites.sheet(); + numLists = sheet.numLists(); + clxData.resize(static_cast(numLists) * 4); + for (uint16_t i = 0; i < numLists; ++i) { + WriteLE32(&clxData[static_cast(i) * 4], static_cast(clxData.size())); + if (!ExtractList(sheet[i], rule, clxData)) return std::nullopt; + } + } else if (!ExtractList(sprites.list(), rule, clxData)) { + return std::nullopt; + } + + return OwnedClxSpriteListOrSheet { ToOwnedBuffer(clxData), numLists }; +} + +} // namespace devilution diff --git a/Source/utils/clx_shadow.hpp b/Source/utils/clx_shadow.hpp new file mode 100644 index 000000000..99a67b53b --- /dev/null +++ b/Source/utils/clx_shadow.hpp @@ -0,0 +1,56 @@ +#pragma once +/** + * @file clx_shadow.hpp + * + * @brief Separates the shadows baked into CLX sprites from the sprites themselves. + */ + +#include + +#include "engine/clx_sprite.hpp" +#include "engine/shadow_mask.hpp" +#include "engine/shadow_rule.hpp" + +namespace devilution { + +/** + * @brief Separates the shadows baked into the given sprites from the sprites. + * + * A shadow pixel is a black (palette index 0) pixel that lies where the shadow function + * (see `shadow_rule.hpp`) casts a pixel of the sprite. Such pixels are removed from the sprite. + * The shadow becomes the shadow mask of the sprite (see `shadow_mask.hpp`), stored in its frame header. + * + * The mask of a sprite is the whole shadow that it casts, including the part underneath the + * sprite itself, limited to the pixels that are opaque in the input. Drawing a sprite over its + * mask filled with black gives back the input exactly. + * + * Black pixels that are not explained as shadow are kept in the sprite. + * + * For sprite sheets, each list is treated as a separate animation (for `ShadowRuleType::FirstFrame` + * the baseline comes from the first sprite of each list). + * + * @return The sprites without their shadows, with the same structure and dimensions as the input. + * `std::nullopt` if `rule` does not describe a shadow (`!rule.canExtractShadow()`) + * or if a sprite is larger than `MaxShadowMaskSpriteWidth` by `MaxShadowMaskSpriteHeight`. + */ +std::optional ExtractClxShadows(ClxSpriteListOrSheet sprites, ShadowRule rule); + +/** + * @brief Separates the shadows baked into the given sprites, replacing the sprites. + * + * Leaves the sprites as they are if there is nothing to extract (see `ExtractClxShadows`). + */ +inline void ExtractClxShadowsInPlace(OwnedClxSpriteList &sprites, ShadowRule rule) +{ + std::optional result = ExtractClxShadows(ClxSpriteListOrSheet { ClxSpriteList { sprites }.data(), 0 }, rule); + if (result.has_value()) sprites = std::move(*result).list(); +} + +inline void ExtractClxShadowsInPlace(OwnedClxSpriteSheet &sprites, ShadowRule rule) +{ + const ClxSpriteSheet sheet { sprites }; + std::optional result = ExtractClxShadows(ClxSpriteListOrSheet { sheet.data(), sheet.numLists() }, rule); + if (result.has_value()) sprites = std::move(*result).sheet(); +} + +} // namespace devilution diff --git a/assets/txtdata/classes/barbarian/sprites.tsv b/assets/txtdata/classes/barbarian/sprites.tsv index 573cc73df..e221a74ff 100644 --- a/assets/txtdata/classes/barbarian/sprites.tsv +++ b/assets/txtdata/classes/barbarian/sprites.tsv @@ -18,3 +18,4 @@ lightning 96 fire 96 magic 96 death 128 +shadowRule diff --git a/assets/txtdata/classes/bard/sprites.tsv b/assets/txtdata/classes/bard/sprites.tsv index 1b7dc3e81..43b8578c7 100644 --- a/assets/txtdata/classes/bard/sprites.tsv +++ b/assets/txtdata/classes/bard/sprites.tsv @@ -18,3 +18,4 @@ lightning 96 fire 96 magic 96 death 128 +shadowRule diff --git a/assets/txtdata/classes/monk/sprites.tsv b/assets/txtdata/classes/monk/sprites.tsv index 9912d7e76..9f82a8995 100644 --- a/assets/txtdata/classes/monk/sprites.tsv +++ b/assets/txtdata/classes/monk/sprites.tsv @@ -18,3 +18,4 @@ lightning 114 fire 114 magic 114 death 160 +shadowRule EachFrame diff --git a/assets/txtdata/classes/rogue/sprites.tsv b/assets/txtdata/classes/rogue/sprites.tsv index 5893ddefb..4f771e9f9 100644 --- a/assets/txtdata/classes/rogue/sprites.tsv +++ b/assets/txtdata/classes/rogue/sprites.tsv @@ -18,3 +18,4 @@ lightning 96 fire 96 magic 96 death 128 +shadowRule FirstFrame diff --git a/assets/txtdata/classes/sorcerer/sprites.tsv b/assets/txtdata/classes/sorcerer/sprites.tsv index d387a9788..73b8d2a3c 100644 --- a/assets/txtdata/classes/sorcerer/sprites.tsv +++ b/assets/txtdata/classes/sorcerer/sprites.tsv @@ -18,3 +18,4 @@ lightning 128 fire 128 magic 128 death 128 +shadowRule FirstFrame diff --git a/assets/txtdata/classes/warrior/sprites.tsv b/assets/txtdata/classes/warrior/sprites.tsv index 678812e9d..4678da1f6 100644 --- a/assets/txtdata/classes/warrior/sprites.tsv +++ b/assets/txtdata/classes/warrior/sprites.tsv @@ -18,3 +18,4 @@ lightning 96 fire 96 magic 96 death 128 +shadowRule FirstFrame|Unexplained|Stray diff --git a/assets/txtdata/monsters/monstdat.tsv b/assets/txtdata/monsters/monstdat.tsv index ff3850aa4..ab1a980da 100644 --- a/assets/txtdata/monsters/monstdat.tsv +++ b/assets/txtdata/monsters/monstdat.tsv @@ -1,113 +1,113 @@ -_monster_id name assetsSuffix soundSuffix trnFile availability width image hasSpecial hasSpecialSound frames[6] rate[6] minDunLvl maxDunLvl level hitPointsMinimum hitPointsMaximum ai abilityFlags intelligence toHit animFrameNum minDamage maxDamage toHitSpecial animFrameNumSpecial minDamageSpecial maxDamageSpecial reducePlayerStrength reducePlayerMagic reducePlayerDexterity reducePlayerVitality reducePlayerMaxHP reducePlayerMaxMana armorClass monsterClass resistance resistanceHell selectionRegion treasure exp -MT_NZOMBIE Zombie zombie\zombie Always 128 799 false false 11,24,12,6,16,0 4,1,1,1,1,1 1 2 1 4 7 Zombie 0 10 8 2 5 0 0 0 0 0 0 0 0 0 0 5 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 54 -MT_BZOMBIE Ghoul zombie\zombie zombie\bluered Always 128 799 false false 11,24,12,6,16,0 4,1,1,1,1,1 2 3 2 7 11 Zombie 1 10 8 3 10 0 0 0 0 0 0 0 0 0 0 10 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 58 -MT_GZOMBIE Rotting Carcass zombie\zombie zombie\grey Always 128 799 false false 11,24,12,6,16,0 4,1,1,1,1,1 2 4 4 15 25 Zombie 2 25 8 5 15 0 0 0 0 0 0 0 0 0 0 15 Undead IMMUNE_MAGIC IMMUNE_MAGIC,RESIST_FIRE Bottom,Middle 136 -MT_YZOMBIE Black Death zombie\zombie zombie\yellow Always 128 799 false false 11,24,12,6,16,0 4,1,1,1,1,1 3 5 6 25 40 Zombie 3 30 8 6 22 0 0 0 0 0 0 0 0 1 0 20 Undead IMMUNE_MAGIC IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 240 -MT_RFALLSP Fallen One falspear\phall falspear\fallent Always 128 543 true true 11,11,13,11,18,13 3,1,1,1,1,1 1 2 1 1 4 Fallen 0 15 7 1 3 0 5 0 0 0 0 0 0 0 0 0 Animal Bottom,Middle 46 -MT_DFALLSP Carver falspear\phall falspear\dark Always 128 543 true true 11,11,13,11,18,13 3,1,1,1,1,1 2 3 3 4 8 Fallen 2 20 7 2 5 0 5 0 0 0 0 0 0 0 0 5 Animal Bottom,Middle 80 -MT_YFALLSP Devil Kin falspear\phall Always 128 543 true true 11,11,13,11,18,13 3,1,1,1,1,1 2 4 5 12 24 Fallen 2 25 7 3 7 0 5 0 0 0 0 0 0 0 0 10 Animal RESIST_FIRE Bottom,Middle 155 -MT_BFALLSP Dark One falspear\phall falspear\blue Always 128 543 true true 11,11,13,11,18,13 3,1,1,1,1,1 3 5 7 20 36 Fallen 3 30 7 4 8 0 5 0 0 0 0 0 0 0 0 15 Animal RESIST_LIGHTNING Bottom,Middle 255 -MT_WSKELAX Skeleton skelaxe\sklax skelaxe\white Always 128 553 true false 12,8,13,6,17,16 5,1,1,1,1,1 1 2 1 2 4 SkeletonMelee 0 20 8 1 4 0 0 0 0 0 0 0 0 0 0 0 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 64 -MT_TSKELAX Corpse Axe skelaxe\sklax skelaxe\skelt Always 128 553 true false 12,8,13,6,17,16 4,1,1,1,1,1 2 3 2 4 7 SkeletonMelee 1 25 8 3 5 0 0 0 0 0 0 0 0 0 0 0 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 68 -MT_RSKELAX Burning Dead skelaxe\sklax Always 128 553 true false 12,8,13,6,17,16 2,1,1,1,1,1 2 4 4 8 12 SkeletonMelee 2 30 8 3 7 0 0 0 0 0 0 0 0 0 0 5 Undead IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 154 -MT_XSKELAX Horror skelaxe\sklax skelaxe\black Always 128 553 true false 12,8,13,6,17,16 3,1,1,1,1,1 3 5 6 12 20 SkeletonMelee 3 35 8 4 9 0 0 0 0 0 0 0 0 0 0 15 Undead IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 264 -MT_RFALLSD Fallen One falsword\fall falsword\fallent Always 128 623 true true 12,12,13,11,14,15 3,1,1,1,1,1 1 2 1 2 5 Fallen 0 15 8 1 4 0 5 0 0 0 0 0 0 0 0 10 Animal Bottom,Middle 52 -MT_DFALLSD Carver falsword\fall falsword\dark Always 128 623 true true 12,12,13,11,14,15 3,1,1,1,1,1 2 3 3 5 9 Fallen 1 20 8 2 7 0 5 0 0 0 0 0 0 0 0 15 Animal Bottom,Middle 90 -MT_YFALLSD Devil Kin falsword\fall Always 128 623 true true 12,12,13,11,14,15 3,1,1,1,1,1 2 4 5 16 24 Fallen 2 25 8 4 10 0 5 0 0 0 0 0 0 0 0 20 Animal RESIST_FIRE Bottom,Middle 180 -MT_BFALLSD Dark One falsword\fall falsword\blue Always 128 623 true true 12,12,13,11,14,15 3,1,1,1,1,1 3 5 7 24 36 Fallen 3 30 8 4 12 0 5 0 0 0 0 0 0 0 0 25 Animal RESIST_LIGHTNING Bottom,Middle 280 -MT_NSCAV Scavenger scav\scav Always 128 410 true false 12,8,12,6,20,11 2,1,1,1,1,1 1 3 2 3 6 Scavenger 0 20 7 1 5 0 0 0 0 0 0 0 0 0 0 10 Animal RESIST_FIRE Bottom,Middle 80 -MT_BSCAV Plague Eater scav\scav scav\scavbr Always 128 410 true false 12,8,12,6,20,11 2,1,1,1,1,1 2 4 4 12 24 Scavenger 1 30 7 1 8 0 0 0 0 0 0 0 0 0 0 20 Animal RESIST_LIGHTNING Bottom,Middle 188 -MT_WSCAV Shadow Beast scav\scav scav\scavbe Always 128 410 true false 12,8,12,6,20,11 2,1,1,1,1,1 3 5 6 24 36 Scavenger 2 35 7 3 12 0 0 0 0 0 0 0 0 0 0 25 Animal RESIST_FIRE Bottom,Middle 375 -MT_YSCAV Bone Gasher scav\scav scav\scavw Always 128 410 true false 12,8,12,6,20,11 2,1,1,1,1,1 4 6 8 28 40 Scavenger 3 35 7 5 15 0 0 0 0 0 0 0 0 0 0 30 Animal RESIST_MAGIC RESIST_LIGHTNING Bottom,Middle 552 -MT_WSKELBW Skeleton skelbow\sklbw skelbow\white Always 128 567 true false 9,8,16,5,16,16 4,1,1,1,1,1 2 3 3 2 4 SkeletonRanged 0 15 12 1 2 0 0 0 0 0 0 0 0 0 0 0 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 110 -MT_TSKELBW Corpse Bow skelbow\sklbw skelbow\skelt Always 128 567 true false 9,8,16,5,16,16 4,1,1,1,1,1 2 4 5 8 16 SkeletonRanged 1 25 12 1 4 0 0 0 0 0 0 0 0 0 0 0 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 210 -MT_RSKELBW Burning Dead skelbow\sklbw Always 128 567 true false 9,8,16,5,16,16 2,1,1,1,1,1 3 5 7 10 24 SkeletonRanged 2 30 12 1 6 0 0 0 0 0 0 0 0 0 0 5 Undead IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 364 -MT_XSKELBW Horror skelbow\sklbw skelbow\black Always 128 567 true false 9,8,16,5,16,16 3,1,1,1,1,1 4 6 9 15 45 SkeletonRanged 3 35 12 2 9 0 0 0 0 0 0 0 0 0 0 15 Undead IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 594 -MT_WSKELSD Skeleton Captain skelsd\sklsr skelsd\white Always 128 575 true true 13,8,12,7,15,16 4,1,1,1,1,1 1 3 2 3 6 SkeletonMelee 0 20 8 2 7 0 0 0 0 0 0 0 0 0 0 10 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 90 -MT_TSKELSD Corpse Captain skelsd\sklsr skelsd\skelt Always 128 575 true false 13,8,12,7,15,16 4,1,1,1,1,1 2 4 4 12 20 SkeletonMelee 1 30 8 3 9 0 0 0 0 0 0 0 0 0 0 5 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 200 -MT_RSKELSD Burning Dead Captain skelsd\sklsr Always 128 575 true false 13,8,12,7,15,16 4,1,1,1,1,1 3 5 6 16 30 SkeletonMelee 2 35 8 4 10 0 0 0 0 0 0 0 0 0 0 15 Undead IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 393 -MT_XSKELSD Horror Captain skelsd\sklsr skelsd\black Always 128 575 true false 13,8,12,7,15,16 4,1,1,1,1,1 4 6 8 35 50 SkeletonMelee SEARCH 3 40 8 5 14 0 0 0 0 0 0 0 0 0 0 30 Undead IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 604 -MT_INVILORD Invisible Lord tsneak\tsneak Never 128 800 false false 13,13,15,11,16,0 2,1,1,1,1,1 19 20 14 278 278 SkeletonMelee SEARCH,CAN_OPEN_DOOR 3 65 8 16 30 0 0 0 0 0 0 0 0 0 0 60 Demon RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 2000 -MT_SNEAK Hidden sneak\sneak Retail 128 992 true false 16,8,12,8,24,15 2,1,1,1,1,1 2 5 5 8 24 Sneak HIDDEN 0 35 8 3 6 0 0 0 0 0 0 0 0 0 0 25 Demon Bottom,Middle 278 -MT_STALKER Stalker sneak\sneak sneak\sneakv2 Retail 128 992 true false 16,8,12,8,24,15 2,1,1,1,1,1 5 7 9 30 45 Sneak HIDDEN,SEARCH 1 40 8 8 16 0 0 0 0 0 0 0 0 0 0 30 Demon Bottom,Middle 630 -MT_UNSEEN Unseen sneak\sneak sneak\sneakv3 Retail 128 992 true false 16,8,12,8,24,15 2,1,1,1,1,1 6 8 11 35 50 Sneak HIDDEN,SEARCH 2 45 8 12 20 0 0 0 0 0 0 0 0 0 0 30 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 935 -MT_ILLWEAV Illusion Weaver sneak\sneak sneak\sneakv1 Retail 128 992 true false 16,8,12,8,24,15 2,1,1,1,1,1 8 10 13 40 60 Sneak HIDDEN,SEARCH 3 60 8 16 24 0 0 0 0 0 0 0 0 0 0 30 Demon RESIST_MAGIC,RESIST_FIRE IMMUNE_MAGIC,RESIST_FIRE Bottom,Middle 1500 -MT_LRDSAYTR Satyr Lord goatlord\goatl newsfx\satyr Never 160 800 false false 13,13,14,9,16,0 2,1,1,1,1,1 21 22 28 160 200 SkeletonMelee SEARCH 3 90 8 20 30 0 0 0 0 0 0 0 0 0 0 70 Animal RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle 2800 -MT_NGOATMC Flesh Clan goatmace\goat Retail 128 1030 true false 12,8,12,6,20,12 2,1,1,1,1,1 4 6 8 30 45 GoatMelee SEARCH,CAN_OPEN_DOOR 0 50 8 4 10 0 0 0 0 0 0 0 0 0 0 40 Demon Bottom,Middle 460 -MT_BGOATMC Stone Clan goatmace\goat goatmace\beige Retail 128 1030 true false 12,8,12,6,20,12 2,1,1,1,1,1 5 7 10 40 55 GoatMelee SEARCH,CAN_OPEN_DOOR 1 60 8 6 12 0 0 0 0 0 0 0 0 0 0 40 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 685 -MT_RGOATMC Fire Clan goatmace\goat goatmace\red Retail 128 1030 true false 12,8,12,6,20,12 2,1,1,1,1,1 6 8 12 50 65 GoatMelee SEARCH,CAN_OPEN_DOOR 2 70 8 8 16 0 0 0 0 0 0 0 0 0 0 45 Demon RESIST_FIRE IMMUNE_FIRE Bottom,Middle 906 -MT_GGOATMC Night Clan goatmace\goat goatmace\gray Retail 128 1030 true false 12,8,12,6,20,12 2,1,1,1,1,1 7 9 14 55 70 GoatMelee SEARCH,CAN_OPEN_DOOR 3 80 8 10 20 15 0 30 30 0 0 0 0 0 0 50 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 1190 -MT_FIEND Fiend bat\bat bat\red Always 96 364 false false 9,13,10,9,13,0 1,1,1,1,1,1 2 3 3 3 6 Bat 0 35 5 1 6 0 0 0 0 0 0 0 0 0 0 0 Animal Middle,Top None 102 -MT_BLINK Blink bat\bat Always 96 364 false false 9,13,10,9,13,0 1,1,1,1,1,1 3 5 7 12 28 Bat 1 45 5 1 8 0 0 0 0 0 0 0 0 0 0 15 Animal Middle,Top None 340 -MT_GLOOM Gloom bat\bat bat\grey Always 96 364 false false 9,13,10,9,13,0 1,1,1,1,1,1 4 6 9 28 36 Bat SEARCH 2 70 5 4 12 0 0 0 0 0 0 0 0 0 0 35 Animal RESIST_MAGIC RESIST_MAGIC Middle,Top None 509 -MT_FAMILIAR Familiar bat\bat bat\orange Always 96 364 false false 9,13,10,9,13,0 1,1,1,1,1,1 6 8 13 20 35 Bat SEARCH 3 50 5 4 16 0 0 0 0 0 0 0 0 0 0 35 Demon RESIST_MAGIC,IMMUNE_LIGHTNING RESIST_MAGIC,IMMUNE_LIGHTNING Middle,Top None 448 -MT_NGOATBW Flesh Clan goatbow\goatb Retail 128 1040 false false 12,8,16,6,20,0 3,1,1,1,1,1 4 6 8 20 35 GoatRanged CAN_OPEN_DOOR 0 35 13 1 7 0 0 0 0 0 0 0 0 0 0 35 Demon Bottom,Middle 448 -MT_BGOATBW Stone Clan goatbow\goatb goatbow\beige Retail 128 1040 false false 12,8,16,6,20,0 3,1,1,1,1,1 5 7 10 30 40 GoatRanged CAN_OPEN_DOOR 1 40 13 2 9 0 0 0 0 0 0 0 0 0 0 35 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 645 -MT_RGOATBW Fire Clan goatbow\goatb goatbow\red Retail 128 1040 false false 12,8,16,6,20,0 3,1,1,1,1,1 6 8 12 40 50 GoatRanged SEARCH,CAN_OPEN_DOOR 2 45 13 3 11 0 0 0 0 0 0 0 0 0 0 35 Demon RESIST_FIRE IMMUNE_FIRE Bottom,Middle 822 -MT_GGOATBW Night Clan goatbow\goatb goatbow\gray Retail 128 1040 false false 12,8,16,6,20,0 3,1,1,1,1,1 7 9 14 50 65 GoatRanged SEARCH,CAN_OPEN_DOOR 3 50 13 4 13 15 0 0 0 0 0 0 0 0 0 40 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 1092 -MT_NACID Acid Beast acid\acid Retail 128 716 true true 13,8,12,8,16,12 1,1,1,1,1,1 6 8 11 40 66 Acid 0 40 8 4 12 25 8 0 0 0 0 0 0 0 0 30 Animal IMMUNE_ACID IMMUNE_MAGIC,IMMUNE_ACID Bottom,Middle 846 -MT_RACID Poison Spitter acid\acid acid\acidblk Retail 128 716 true true 13,8,12,8,16,12 1,1,1,1,1,1 8 10 15 60 85 Acid 1 45 8 4 16 25 8 0 0 0 0 0 0 0 0 30 Animal IMMUNE_ACID IMMUNE_MAGIC,IMMUNE_ACID Bottom,Middle 1248 -MT_BACID Pit Beast acid\acid acid\acidb Retail 128 716 true true 13,8,12,8,16,12 1,1,1,1,1,1 10 12 21 80 110 Acid 2 55 8 8 18 35 8 0 0 0 0 0 0 0 0 35 Animal RESIST_MAGIC,IMMUNE_ACID IMMUNE_MAGIC,RESIST_LIGHTNING,IMMUNE_ACID Bottom,Middle 2060 -MT_XACID Lava Maw acid\acid acid\acidr Retail 128 716 true true 13,8,12,8,16,12 1,1,1,1,1,1 12 14 25 100 150 Acid 3 65 8 10 20 40 8 0 0 0 0 0 0 0 0 35 Animal RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_ACID IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_ACID Bottom,Middle 2940 -MT_SKING Skeleton King sking\sking skelaxe\white Never 160 1010 true true 8,6,16,6,16,6 2,1,1,1,1,2 4 4 9 140 140 SkeletonKing SEARCH,CAN_OPEN_DOOR 3 60 8 6 16 0 0 0 0 0 0 0 0 0 0 70 Undead IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top Uniq(SKCROWN) 570 -MT_CLEAVER The Butcher fatc\fatc Never 128 980 false false 10,8,12,6,16,0 1,1,1,1,1,1 1 1 1 320 320 Butcher 3 50 8 6 12 0 0 0 0 0 0 0 0 0 0 50 Demon RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle Uniq(CLEAVER) 710 -MT_FAT Overlord fat\fat Retail 128 1130 true false 8,10,15,6,16,10 4,1,1,1,1,1 5 7 10 60 80 Fat 0 55 8 6 12 0 0 0 0 0 0 0 0 0 0 55 Demon RESIST_FIRE Bottom,Middle 635 -MT_MUDMAN Mud Man fat\fat fat\blue Retail 128 1130 true false 8,10,15,6,16,10 4,1,1,1,1,1 7 9 14 100 125 Fat SEARCH 1 60 8 8 16 0 0 0 0 0 0 0 0 0 0 60 Demon IMMUNE_LIGHTNING Bottom,Middle 1165 -MT_TOAD Toad Demon fat\fat fat\fatb Retail 128 1130 true false 8,10,15,6,16,10 4,1,1,1,1,1 8 10 16 135 160 Fat SEARCH 2 70 8 8 16 40 0 8 20 0 0 0 0 0 0 65 Demon IMMUNE_MAGIC IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 1380 -MT_FLAYED Flayed One fat\fat fat\fatf Retail 128 1130 true false 8,10,15,6,16,10 4,1,1,1,1,1 10 12 20 160 200 Fat SEARCH 3 85 8 10 20 0 0 0 0 0 0 0 0 0 0 70 Demon RESIST_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 2058 -MT_WYRM Wyrm worm\worm Never 160 2420 false false 13,13,13,11,19,0 1,1,1,1,1,1 5 7 11 60 90 SkeletonMelee 0 40 8 4 10 0 0 0 0 0 0 0 0 0 0 25 Animal RESIST_MAGIC RESIST_MAGIC Bottom,Middle 660 -MT_CAVSLUG Cave Slug worm\worm Never 160 2420 false false 13,13,13,11,19,0 1,1,1,1,1,1 6 8 13 75 110 SkeletonMelee 1 50 8 6 13 0 0 0 0 0 0 0 0 0 0 30 Animal RESIST_MAGIC RESIST_MAGIC Bottom,Middle 994 -MT_DVLWYRM Devil Wyrm worm\worm Never 160 2420 false false 13,13,13,11,19,0 1,1,1,1,1,1 7 9 15 100 140 SkeletonMelee 2 55 8 8 16 0 0 0 0 0 0 0 0 0 0 30 Animal RESIST_MAGIC,RESIST_FIRE RESIST_MAGIC,RESIST_FIRE Bottom,Middle 1320 -MT_DEVOUR Devourer worm\worm Never 160 2420 false false 13,13,13,11,19,0 1,1,1,1,1,1 8 10 17 125 200 SkeletonMelee 3 60 8 10 20 0 0 0 0 0 0 0 0 0 0 35 Animal RESIST_MAGIC,RESIST_FIRE RESIST_MAGIC,RESIST_FIRE Bottom,Middle 1827 -MT_NMAGMA Magma Demon magma\magma Retail 128 1680 true true 8,10,14,7,18,18 2,1,1,1,1,1 8 9 13 50 70 Magma SEARCH,CAN_OPEN_DOOR 0 45 4 2 10 50 13 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 1076 -MT_YMAGMA Blood Stone magma\magma magma\yellow Retail 128 1680 true true 8,10,14,7,18,18 2,1,1,1,1,1 8 10 14 55 75 Magma SEARCH,CAN_OPEN_DOOR 1 50 4 2 12 50 14 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 1309 -MT_BMAGMA Hell Stone magma\magma magma\blue Retail 128 1680 true true 8,10,14,7,18,18 2,1,1,1,1,1 9 11 16 60 80 Magma SEARCH,CAN_OPEN_DOOR 2 60 4 2 20 60 14 0 0 0 0 0 0 0 0 50 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 1680 -MT_WMAGMA Lava Lord magma\magma magma\wierd Retail 128 1680 true true 8,10,14,7,18,18 2,1,1,1,1,1 9 11 18 70 85 Magma SEARCH,CAN_OPEN_DOOR 3 75 4 4 24 60 14 0 0 0 0 0 0 0 0 60 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 2124 -MT_HORNED Horned Demon rhino\rhino Retail 160 1630 true true 8,8,14,6,16,6 2,1,1,1,1,1 7 9 13 40 80 Rhino SEARCH,CAN_OPEN_DOOR 0 60 7 2 16 100 0 5 32 0 0 0 0 0 0 40 Animal RESIST_FIRE Bottom,Middle,Top 1172 -MT_MUDRUN Mud Runner rhino\rhino rhino\orange Retail 160 1630 true true 8,8,14,6,16,6 2,1,1,1,1,1 8 10 15 50 90 Rhino SEARCH,CAN_OPEN_DOOR 1 70 7 6 18 100 0 12 36 0 0 0 0 0 0 45 Animal RESIST_FIRE Bottom,Middle,Top 1404 -MT_FROSTC Frost Charger rhino\rhino rhino\blue Retail 160 1630 true true 8,8,14,6,16,6 2,1,1,1,1,1 9 11 17 60 100 Rhino SEARCH,CAN_OPEN_DOOR 2 80 7 8 20 100 0 20 40 0 0 0 0 0 0 50 Animal IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle,Top 1720 -MT_OBLORD Obsidian Lord rhino\rhino rhino\rhinob Retail 160 1630 true true 8,8,14,6,16,6 2,1,1,1,1,1 10 12 19 70 110 Rhino SEARCH,CAN_OPEN_DOOR 3 90 7 10 22 100 0 20 50 0 0 0 0 0 0 55 Animal IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 1809 -MT_BONEDMN oldboned demskel\demskl Never 128 1740 true true 10,8,20,6,24,16 3,1,1,1,1,1 24 24 12 70 70 Storm 0 60 8 6 14 12 0 0 0 0 0 0 0 0 0 50 Demon IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle,Top 1344 -MT_REDDTH Red Death thin\thin thin\thinv3 Never 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 8 10 16 96 96 Storm 1 75 5 10 20 0 0 0 0 0 0 0 0 0 0 60 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 2168 -MT_LTCHDMN Litch Demon thin\thin thin\thinv3 Never 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 9 11 18 110 110 Storm 2 80 5 10 24 0 0 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2736 -MT_UDEDBLRG Undead Balrog thin\thin thin\thinv3 Never 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 11 13 22 130 130 Storm 3 85 5 12 30 0 0 0 0 0 0 0 0 0 0 65 Demon IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 3575 -MT_INCIN Incinerator fireman\firem Never 128 1460 true false 14,19,20,8,14,23 1,1,1,1,1,1 21 22 16 30 45 FireMan 0 75 8 8 16 0 0 0 0 0 0 0 0 0 0 25 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 1888 -MT_FLAMLRD Flame Lord fireman\firem Never 128 1460 true false 14,19,20,8,14,23 1,1,1,1,1,1 22 23 18 40 55 FireMan 1 75 8 10 20 0 0 0 0 0 0 0 0 0 0 25 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 2250 -MT_DOOMFIRE Doom Fire fireman\firem Never 128 1460 true false 14,19,20,8,14,23 1,1,1,1,1,1 23 24 20 50 65 FireMan 2 80 8 12 24 0 0 0 0 0 0 0 0 0 0 30 Demon IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 2740 -MT_HELLBURN Hell Burner fireman\firem Never 128 1460 true false 14,19,20,8,14,23 1,1,1,1,1,1 24 24 22 60 80 FireMan 3 85 8 15 30 0 0 0 0 0 0 0 0 0 0 30 Demon IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 3355 -MT_STORM Red Storm thin\thin thin\thinv3 Retail 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 9 11 18 55 110 Storm SEARCH,CAN_OPEN_DOOR 0 80 5 8 18 75 8 4 16 0 0 0 0 0 0 30 Demon IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2160 -MT_RSTORM Storm Rider thin\thin Retail 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 10 12 20 60 120 Storm SEARCH,CAN_OPEN_DOOR 1 80 5 8 18 80 8 4 16 0 0 0 0 0 0 30 Demon RESIST_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2391 -MT_STORML Storm Lord thin\thin thin\thinv2 Retail 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 11 13 22 75 135 Storm SEARCH,CAN_OPEN_DOOR 2 85 5 12 24 75 8 4 16 0 0 0 0 0 0 35 Demon RESIST_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2775 -MT_MAEL Maelstrom thin\thin thin\thinv1 Retail 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 12 14 24 90 150 Storm SEARCH,CAN_OPEN_DOOR 3 90 5 12 28 75 8 4 16 0 0 0 0 0 0 40 Demon RESIST_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 3177 -MT_BIGFALL Devil Kin Brute bigfall\fallg newsfx\kbrute Never 128 800 true false 10,8,11,8,17,0 1,1,1,1,2,2 21 22 27 120 160 SkeletonMelee SEARCH,CAN_OPEN_DOOR 3 100 6 18 24 0 0 0 0 0 0 0 0 0 0 70 Animal RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 2400 -MT_WINGED Winged-Demon gargoyle\gargo Retail 160 1650 true false 14,14,14,10,18,14 1,1,1,1,1,2 5 7 9 45 60 Gargoyle CAN_OPEN_DOOR 0 50 7 10 16 0 0 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Middle,Top 662 -MT_GARGOYLE Gargoyle gargoyle\gargo gargoyle\gare Retail 160 1650 true false 14,14,14,10,18,14 1,1,1,1,1,2 7 9 13 60 90 Gargoyle CAN_OPEN_DOOR 1 65 7 10 16 0 0 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Middle,Top 1205 -MT_BLOODCLW Blood Claw gargoyle\gargo gargoyle\gargbr Retail 160 1650 true false 14,14,14,10,18,14 1,1,1,1,1,1 9 11 19 75 125 Gargoyle CAN_OPEN_DOOR 2 80 7 14 22 0 0 0 0 0 0 0 0 0 0 50 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Middle,Top 1873 -MT_DEATHW Death Wing gargoyle\gargo gargoyle\gargb Retail 160 1650 true false 14,14,14,10,18,14 1,1,1,1,1,1 10 12 23 90 150 Gargoyle CAN_OPEN_DOOR 3 95 7 16 28 0 0 0 0 0 0 0 0 0 0 60 Demon IMMUNE_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Middle,Top 2278 -MT_MEGA Slayer mega\mega Retail 160 2220 true true 6,7,14,1,24,5 3,1,1,1,2,1 10 12 20 120 140 Mega SEARCH,CAN_OPEN_DOOR 0 100 8 12 20 0 3 0 0 0 0 0 0 0 0 60 Demon RESIST_MAGIC,IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 2300 -MT_GUARD Guardian mega\mega mega\guard Retail 160 2220 true true 6,7,14,1,24,5 3,1,1,1,2,1 11 13 22 140 160 Mega SEARCH,CAN_OPEN_DOOR 1 110 8 14 22 0 3 0 0 0 0 0 0 0 0 65 Demon RESIST_MAGIC,IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 2714 -MT_VTEXLRD Vortex Lord mega\mega mega\vtexl Retail 160 2220 true true 6,7,14,1,24,5 3,1,1,1,2,1 12 14 24 160 180 Mega SEARCH,CAN_OPEN_DOOR 2 120 8 18 24 0 3 0 0 0 0 0 0 0 0 70 Demon RESIST_MAGIC,IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 3252 -MT_BALROG Balrog mega\mega mega\balr Retail 160 2220 true true 6,7,14,1,24,5 3,1,1,1,2,1 13 15 26 180 200 Mega SEARCH,CAN_OPEN_DOOR 3 130 8 22 30 0 3 0 0 0 0 0 0 0 0 75 Demon RESIST_MAGIC,IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 3643 -MT_NSNAKE Cave Viper snake\snake Retail 160 1270 false false 12,11,13,5,18,0 2,1,1,1,1,1 11 13 21 100 150 Snake SEARCH 0 90 8 8 20 0 0 0 0 0 0 0 0 0 0 60 Demon IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle,Top 2725 -MT_RSNAKE Fire Drake snake\snake snake\snakr Retail 160 1270 false false 12,11,13,5,18,0 2,1,1,1,1,1 12 14 23 120 170 Snake SEARCH 1 105 8 12 24 0 0 0 0 0 0 0 0 0 0 65 Demon IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 3139 -MT_BSNAKE Gold Viper snake\snake snake\snakg Retail 160 1270 false false 12,11,13,5,18,0 2,1,1,1,1,1 13 14 25 140 180 Snake SEARCH 2 120 8 15 26 0 0 0 0 0 0 0 0 0 0 70 Demon IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle,Top 3540 -MT_GSNAKE Azure Drake snake\snake snake\snakb Retail 160 1270 false false 12,11,13,5,18,0 2,1,1,1,1,1 15 16 27 160 200 Snake SEARCH 3 130 8 18 30 0 0 0 0 0 0 0 0 0 0 75 Demon RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 3791 -MT_NBLACK Black Knight black\black Retail 160 2120 false false 8,8,16,4,24,0 2,1,1,1,1,1 12 14 24 150 150 SkeletonMelee SEARCH 0 110 8 15 20 0 0 0 0 0 0 0 0 0 0 75 Demon RESIST_MAGIC,RESIST_LIGHTNING RESIST_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 3360 -MT_RTBLACK Doom Guard black\black black\blkkntrt Retail 160 2120 false false 8,8,16,4,24,0 2,1,1,1,1,1 13 15 26 165 165 SkeletonMelee SEARCH 0 130 8 18 25 0 0 0 0 0 0 0 0 0 0 75 Demon RESIST_MAGIC,RESIST_FIRE RESIST_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 3650 -MT_BTBLACK Steel Lord black\black black\blkkntbt Retail 160 2120 false false 8,8,16,4,24,0 2,1,1,1,1,1 14 16 28 180 180 SkeletonMelee SEARCH 1 120 8 20 30 0 0 0 0 0 0 0 0 0 0 80 Demon RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 4252 -MT_RBLACK Blood Knight black\black black\blkkntbe Retail 160 2120 false false 8,8,16,4,24,0 2,1,1,1,1,1 13 14 30 200 200 SkeletonMelee SEARCH 1 130 8 25 35 0 0 0 0 0 0 0 0 0 0 85 Demon IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 5130 -MT_UNRAV The Shredded unrav\unrav newsfx\shred Never 96 484 false false 10,10,12,5,16,0 1,1,1,1,1,1 17 18 23 70 90 SkeletonMelee 0 75 7 4 12 0 0 0 0 0 0 0 0 0 0 65 Undead RESIST_FIRE,RESIST_LIGHTNING RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 900 -MT_HOLOWONE Hollow One unrav\unrav acid\acid Never 96 484 false false 10,10,12,5,16,0 1,1,1,1,1,1 18 19 27 135 240 SkeletonMelee 1 75 7 12 24 0 0 0 0 0 0 0 0 0 0 75 Undead IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 4374 -MT_PAINMSTR Pain Master unrav\unrav acid\acid Never 96 484 false false 10,10,12,5,16,0 1,1,1,1,1,1 19 20 29 110 200 SkeletonMelee 2 80 7 16 30 0 0 0 0 0 0 0 0 0 0 80 Undead IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 5147 -MT_REALWEAV Reality Weaver unrav\unrav acid\acid Never 96 484 false false 10,10,12,5,16,0 1,1,1,1,1,1 20 20 30 135 240 SkeletonMelee 3 85 7 20 35 0 0 0 0 0 0 0 0 0 0 85 Undead RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle 5925 -MT_SUCCUBUS Succubus succ\scbs Retail 128 980 false false 14,8,16,7,24,0 1,1,1,1,1,1 12 14 24 120 150 Succubus CAN_OPEN_DOOR 0 100 10 1 20 0 0 0 0 0 0 0 0 0 0 60 Demon RESIST_MAGIC IMMUNE_MAGIC,RESIST_FIRE Bottom,Middle 3696 -MT_SNOWWICH Snow Witch succ\scbs succ\succb Retail 128 980 false false 14,8,16,7,24,0 1,1,1,1,1,1 13 15 26 135 175 Succubus CAN_OPEN_DOOR 1 110 10 1 24 0 0 0 0 0 0 0 0 0 0 65 Demon RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 4084 -MT_HLSPWN Hell Spawn succ\scbs succ\succrw Retail 128 980 false false 14,8,16,7,24,0 1,1,1,1,1,1 14 16 28 150 200 Succubus SEARCH,CAN_OPEN_DOOR 2 115 10 1 30 0 0 0 0 0 0 0 0 0 0 75 Demon RESIST_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 4480 -MT_SOLBRNR Soul Burner succ\scbs succ\succbw Retail 128 980 false false 14,8,16,7,24,0 1,1,1,1,1,1 15 16 30 140 225 Succubus SEARCH,CAN_OPEN_DOOR 3 120 10 1 35 0 0 0 0 0 0 0 0 0 0 85 Demon RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle 4644 -MT_COUNSLR Counselor mage\mage Retail 128 2000 true false 12,1,20,8,28,20 1,1,1,1,1,1 13 14 25 70 70 Counselor CAN_OPEN_DOOR 0 90 8 8 20 0 0 0 0 0 0 0 0 0 0 0 Demon RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 4070 -MT_MAGISTR Magistrate mage\mage mage\cnselg Retail 128 2000 true false 12,1,20,8,28,20 1,1,1,1,1,1 14 15 27 85 85 Counselor CAN_OPEN_DOOR 1 100 8 10 24 0 0 0 0 0 0 0 0 0 0 0 Demon RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 4478 -MT_CABALIST Cabalist mage\mage mage\cnselgd Retail 128 2000 true false 12,1,20,8,28,20 1,1,1,1,1,1 15 16 29 120 120 Counselor CAN_OPEN_DOOR 2 110 8 14 30 0 0 0 0 0 0 0 0 0 0 0 Demon RESIST_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 4929 -MT_ADVOCATE Advocate mage\mage mage\cnselbk Retail 128 2000 true false 12,1,20,8,28,20 1,1,1,1,1,1 16 16 30 145 145 Counselor CAN_OPEN_DOOR 3 120 8 15 25 0 0 0 0 0 0 0 0 0 0 0 Demon IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 4968 -MT_GOLEM Golem golem\golem golem\golm Never 96 386 true false 0,16,12,0,12,20 1,1,1,1,1,1 1 1 12 1 1 Golem CAN_OPEN_DOOR 0 0 7 1 1 0 0 0 0 0 0 0 0 0 0 1 Demon 0 -MT_DIABLO The Dark Lord diablo\diablo Never 160 2000 true true 16,6,16,6,16,16 1,1,1,1,1,1 26 26 30 1666 1666 Diablo KNOCKBACK,SEARCH,CAN_OPEN_DOOR 3 220 4 30 60 0 11 0 0 0 0 0 0 0 0 90 Demon IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 31666 -MT_DARKMAGE The Arch-Litch Malignus darkmage\dmage darkmage\dmag Never 128 1060 true false 6,1,21,6,23,18 1,1,1,1,1,1 21 21 30 160 160 Counselor CAN_OPEN_DOOR 3 120 8 20 40 0 0 0 0 0 0 0 0 0 0 70 Demon RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 4968 +_monster_id name assetsSuffix soundSuffix trnFile availability width image hasSpecial hasSpecialSound frames[6] rate[6] minDunLvl maxDunLvl level hitPointsMinimum hitPointsMaximum ai abilityFlags intelligence toHit animFrameNum minDamage maxDamage toHitSpecial animFrameNumSpecial minDamageSpecial maxDamageSpecial reducePlayerStrength reducePlayerMagic reducePlayerDexterity reducePlayerVitality reducePlayerMaxHP reducePlayerMaxMana armorClass monsterClass resistance resistanceHell selectionRegion treasure exp shadowRule[6] +MT_NZOMBIE Zombie zombie\zombie Always 128 799 false false 11,24,12,6,16,0 4,1,1,1,1,1 1 2 1 4 7 Zombie 0 10 8 2 5 0 0 0 0 0 0 0 0 0 0 5 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 54 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_BZOMBIE Ghoul zombie\zombie zombie\bluered Always 128 799 false false 11,24,12,6,16,0 4,1,1,1,1,1 2 3 2 7 11 Zombie 1 10 8 3 10 0 0 0 0 0 0 0 0 0 0 10 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 58 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_GZOMBIE Rotting Carcass zombie\zombie zombie\grey Always 128 799 false false 11,24,12,6,16,0 4,1,1,1,1,1 2 4 4 15 25 Zombie 2 25 8 5 15 0 0 0 0 0 0 0 0 0 0 15 Undead IMMUNE_MAGIC IMMUNE_MAGIC,RESIST_FIRE Bottom,Middle 136 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_YZOMBIE Black Death zombie\zombie zombie\yellow Always 128 799 false false 11,24,12,6,16,0 4,1,1,1,1,1 3 5 6 25 40 Zombie 3 30 8 6 22 0 0 0 0 0 0 0 0 1 0 20 Undead IMMUNE_MAGIC IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 240 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_RFALLSP Fallen One falspear\phall falspear\fallent Always 128 543 true true 11,11,13,11,18,13 3,1,1,1,1,1 1 2 1 1 4 Fallen 0 15 7 1 3 0 5 0 0 0 0 0 0 0 0 0 Animal Bottom,Middle 46 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_DFALLSP Carver falspear\phall falspear\dark Always 128 543 true true 11,11,13,11,18,13 3,1,1,1,1,1 2 3 3 4 8 Fallen 2 20 7 2 5 0 5 0 0 0 0 0 0 0 0 5 Animal Bottom,Middle 80 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_YFALLSP Devil Kin falspear\phall Always 128 543 true true 11,11,13,11,18,13 3,1,1,1,1,1 2 4 5 12 24 Fallen 2 25 7 3 7 0 5 0 0 0 0 0 0 0 0 10 Animal RESIST_FIRE Bottom,Middle 155 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_BFALLSP Dark One falspear\phall falspear\blue Always 128 543 true true 11,11,13,11,18,13 3,1,1,1,1,1 3 5 7 20 36 Fallen 3 30 7 4 8 0 5 0 0 0 0 0 0 0 0 15 Animal RESIST_LIGHTNING Bottom,Middle 255 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_WSKELAX Skeleton skelaxe\sklax skelaxe\white Always 128 553 true false 12,8,13,6,17,16 5,1,1,1,1,1 1 2 1 2 4 SkeletonMelee 0 20 8 1 4 0 0 0 0 0 0 0 0 0 0 0 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 64 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_TSKELAX Corpse Axe skelaxe\sklax skelaxe\skelt Always 128 553 true false 12,8,13,6,17,16 4,1,1,1,1,1 2 3 2 4 7 SkeletonMelee 1 25 8 3 5 0 0 0 0 0 0 0 0 0 0 0 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 68 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_RSKELAX Burning Dead skelaxe\sklax Always 128 553 true false 12,8,13,6,17,16 2,1,1,1,1,1 2 4 4 8 12 SkeletonMelee 2 30 8 3 7 0 0 0 0 0 0 0 0 0 0 5 Undead IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 154 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_XSKELAX Horror skelaxe\sklax skelaxe\black Always 128 553 true false 12,8,13,6,17,16 3,1,1,1,1,1 3 5 6 12 20 SkeletonMelee 3 35 8 4 9 0 0 0 0 0 0 0 0 0 0 15 Undead IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 264 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_RFALLSD Fallen One falsword\fall falsword\fallent Always 128 623 true true 12,12,13,11,14,15 3,1,1,1,1,1 1 2 1 2 5 Fallen 0 15 8 1 4 0 5 0 0 0 0 0 0 0 0 10 Animal Bottom,Middle 52 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_DFALLSD Carver falsword\fall falsword\dark Always 128 623 true true 12,12,13,11,14,15 3,1,1,1,1,1 2 3 3 5 9 Fallen 1 20 8 2 7 0 5 0 0 0 0 0 0 0 0 15 Animal Bottom,Middle 90 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_YFALLSD Devil Kin falsword\fall Always 128 623 true true 12,12,13,11,14,15 3,1,1,1,1,1 2 4 5 16 24 Fallen 2 25 8 4 10 0 5 0 0 0 0 0 0 0 0 20 Animal RESIST_FIRE Bottom,Middle 180 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_BFALLSD Dark One falsword\fall falsword\blue Always 128 623 true true 12,12,13,11,14,15 3,1,1,1,1,1 3 5 7 24 36 Fallen 3 30 8 4 12 0 5 0 0 0 0 0 0 0 0 25 Animal RESIST_LIGHTNING Bottom,Middle 280 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_NSCAV Scavenger scav\scav Always 128 410 true false 12,8,12,6,20,11 2,1,1,1,1,1 1 3 2 3 6 Scavenger 0 20 7 1 5 0 0 0 0 0 0 0 0 0 0 10 Animal RESIST_FIRE Bottom,Middle 80 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_BSCAV Plague Eater scav\scav scav\scavbr Always 128 410 true false 12,8,12,6,20,11 2,1,1,1,1,1 2 4 4 12 24 Scavenger 1 30 7 1 8 0 0 0 0 0 0 0 0 0 0 20 Animal RESIST_LIGHTNING Bottom,Middle 188 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_WSCAV Shadow Beast scav\scav scav\scavbe Always 128 410 true false 12,8,12,6,20,11 2,1,1,1,1,1 3 5 6 24 36 Scavenger 2 35 7 3 12 0 0 0 0 0 0 0 0 0 0 25 Animal RESIST_FIRE Bottom,Middle 375 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_YSCAV Bone Gasher scav\scav scav\scavw Always 128 410 true false 12,8,12,6,20,11 2,1,1,1,1,1 4 6 8 28 40 Scavenger 3 35 7 5 15 0 0 0 0 0 0 0 0 0 0 30 Animal RESIST_MAGIC RESIST_LIGHTNING Bottom,Middle 552 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_WSKELBW Skeleton skelbow\sklbw skelbow\white Always 128 567 true false 9,8,16,5,16,16 4,1,1,1,1,1 2 3 3 2 4 SkeletonRanged 0 15 12 1 2 0 0 0 0 0 0 0 0 0 0 0 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 110 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_TSKELBW Corpse Bow skelbow\sklbw skelbow\skelt Always 128 567 true false 9,8,16,5,16,16 4,1,1,1,1,1 2 4 5 8 16 SkeletonRanged 1 25 12 1 4 0 0 0 0 0 0 0 0 0 0 0 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 210 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_RSKELBW Burning Dead skelbow\sklbw Always 128 567 true false 9,8,16,5,16,16 2,1,1,1,1,1 3 5 7 10 24 SkeletonRanged 2 30 12 1 6 0 0 0 0 0 0 0 0 0 0 5 Undead IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 364 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_XSKELBW Horror skelbow\sklbw skelbow\black Always 128 567 true false 9,8,16,5,16,16 3,1,1,1,1,1 4 6 9 15 45 SkeletonRanged 3 35 12 2 9 0 0 0 0 0 0 0 0 0 0 15 Undead IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 594 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_WSKELSD Skeleton Captain skelsd\sklsr skelsd\white Always 128 575 true true 13,8,12,7,15,16 4,1,1,1,1,1 1 3 2 3 6 SkeletonMelee 0 20 8 2 7 0 0 0 0 0 0 0 0 0 0 10 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 90 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_TSKELSD Corpse Captain skelsd\sklsr skelsd\skelt Always 128 575 true false 13,8,12,7,15,16 4,1,1,1,1,1 2 4 4 12 20 SkeletonMelee 1 30 8 3 9 0 0 0 0 0 0 0 0 0 0 5 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 200 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_RSKELSD Burning Dead Captain skelsd\sklsr Always 128 575 true false 13,8,12,7,15,16 4,1,1,1,1,1 3 5 6 16 30 SkeletonMelee 2 35 8 4 10 0 0 0 0 0 0 0 0 0 0 15 Undead IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 393 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_XSKELSD Horror Captain skelsd\sklsr skelsd\black Always 128 575 true false 13,8,12,7,15,16 4,1,1,1,1,1 4 6 8 35 50 SkeletonMelee SEARCH 3 40 8 5 14 0 0 0 0 0 0 0 0 0 0 30 Undead IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 604 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_INVILORD Invisible Lord tsneak\tsneak Never 128 800 false false 13,13,15,11,16,0 2,1,1,1,1,1 19 20 14 278 278 SkeletonMelee SEARCH,CAN_OPEN_DOOR 3 65 8 16 30 0 0 0 0 0 0 0 0 0 0 60 Demon RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 2000 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_SNEAK Hidden sneak\sneak Retail 128 992 true false 16,8,12,8,24,15 2,1,1,1,1,1 2 5 5 8 24 Sneak HIDDEN 0 35 8 3 6 0 0 0 0 0 0 0 0 0 0 25 Demon Bottom,Middle 278 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_STALKER Stalker sneak\sneak sneak\sneakv2 Retail 128 992 true false 16,8,12,8,24,15 2,1,1,1,1,1 5 7 9 30 45 Sneak HIDDEN,SEARCH 1 40 8 8 16 0 0 0 0 0 0 0 0 0 0 30 Demon Bottom,Middle 630 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_UNSEEN Unseen sneak\sneak sneak\sneakv3 Retail 128 992 true false 16,8,12,8,24,15 2,1,1,1,1,1 6 8 11 35 50 Sneak HIDDEN,SEARCH 2 45 8 12 20 0 0 0 0 0 0 0 0 0 0 30 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 935 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_ILLWEAV Illusion Weaver sneak\sneak sneak\sneakv1 Retail 128 992 true false 16,8,12,8,24,15 2,1,1,1,1,1 8 10 13 40 60 Sneak HIDDEN,SEARCH 3 60 8 16 24 0 0 0 0 0 0 0 0 0 0 30 Demon RESIST_MAGIC,RESIST_FIRE IMMUNE_MAGIC,RESIST_FIRE Bottom,Middle 1500 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_LRDSAYTR Satyr Lord goatlord\goatl newsfx\satyr Never 160 800 false false 13,13,14,9,16,0 2,1,1,1,1,1 21 22 28 160 200 SkeletonMelee SEARCH 3 90 8 20 30 0 0 0 0 0 0 0 0 0 0 70 Animal RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle 2800 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_NGOATMC Flesh Clan goatmace\goat Retail 128 1030 true false 12,8,12,6,20,12 2,1,1,1,1,1 4 6 8 30 45 GoatMelee SEARCH,CAN_OPEN_DOOR 0 50 8 4 10 0 0 0 0 0 0 0 0 0 0 40 Demon Bottom,Middle 460 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_BGOATMC Stone Clan goatmace\goat goatmace\beige Retail 128 1030 true false 12,8,12,6,20,12 2,1,1,1,1,1 5 7 10 40 55 GoatMelee SEARCH,CAN_OPEN_DOOR 1 60 8 6 12 0 0 0 0 0 0 0 0 0 0 40 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 685 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_RGOATMC Fire Clan goatmace\goat goatmace\red Retail 128 1030 true false 12,8,12,6,20,12 2,1,1,1,1,1 6 8 12 50 65 GoatMelee SEARCH,CAN_OPEN_DOOR 2 70 8 8 16 0 0 0 0 0 0 0 0 0 0 45 Demon RESIST_FIRE IMMUNE_FIRE Bottom,Middle 906 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_GGOATMC Night Clan goatmace\goat goatmace\gray Retail 128 1030 true false 12,8,12,6,20,12 2,1,1,1,1,1 7 9 14 55 70 GoatMelee SEARCH,CAN_OPEN_DOOR 3 80 8 10 20 15 0 30 30 0 0 0 0 0 0 50 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 1190 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_FIEND Fiend bat\bat bat\red Always 96 364 false false 9,13,10,9,13,0 1,1,1,1,1,1 2 3 3 3 6 Bat 0 35 5 1 6 0 0 0 0 0 0 0 0 0 0 0 Animal Middle,Top None 102 FirstFrame+15,FirstFrame+15,FirstFrame+15,FirstFrame+15,FirstFrame,None +MT_BLINK Blink bat\bat Always 96 364 false false 9,13,10,9,13,0 1,1,1,1,1,1 3 5 7 12 28 Bat 1 45 5 1 8 0 0 0 0 0 0 0 0 0 0 15 Animal Middle,Top None 340 FirstFrame+15,FirstFrame+15,FirstFrame+15,FirstFrame+15,FirstFrame,None +MT_GLOOM Gloom bat\bat bat\grey Always 96 364 false false 9,13,10,9,13,0 1,1,1,1,1,1 4 6 9 28 36 Bat SEARCH 2 70 5 4 12 0 0 0 0 0 0 0 0 0 0 35 Animal RESIST_MAGIC RESIST_MAGIC Middle,Top None 509 FirstFrame+15,FirstFrame+15,FirstFrame+15,FirstFrame+15,FirstFrame,None +MT_FAMILIAR Familiar bat\bat bat\orange Always 96 364 false false 9,13,10,9,13,0 1,1,1,1,1,1 6 8 13 20 35 Bat SEARCH 3 50 5 4 16 0 0 0 0 0 0 0 0 0 0 35 Demon RESIST_MAGIC,IMMUNE_LIGHTNING RESIST_MAGIC,IMMUNE_LIGHTNING Middle,Top None 448 FirstFrame+15,FirstFrame+15,FirstFrame+15,FirstFrame+15,FirstFrame,None +MT_NGOATBW Flesh Clan goatbow\goatb Retail 128 1040 false false 12,8,16,6,20,0 3,1,1,1,1,1 4 6 8 20 35 GoatRanged CAN_OPEN_DOOR 0 35 13 1 7 0 0 0 0 0 0 0 0 0 0 35 Demon Bottom,Middle 448 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_BGOATBW Stone Clan goatbow\goatb goatbow\beige Retail 128 1040 false false 12,8,16,6,20,0 3,1,1,1,1,1 5 7 10 30 40 GoatRanged CAN_OPEN_DOOR 1 40 13 2 9 0 0 0 0 0 0 0 0 0 0 35 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 645 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_RGOATBW Fire Clan goatbow\goatb goatbow\red Retail 128 1040 false false 12,8,16,6,20,0 3,1,1,1,1,1 6 8 12 40 50 GoatRanged SEARCH,CAN_OPEN_DOOR 2 45 13 3 11 0 0 0 0 0 0 0 0 0 0 35 Demon RESIST_FIRE IMMUNE_FIRE Bottom,Middle 822 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_GGOATBW Night Clan goatbow\goatb goatbow\gray Retail 128 1040 false false 12,8,16,6,20,0 3,1,1,1,1,1 7 9 14 50 65 GoatRanged SEARCH,CAN_OPEN_DOOR 3 50 13 4 13 15 0 0 0 0 0 0 0 0 0 40 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 1092 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_NACID Acid Beast acid\acid Retail 128 716 true true 13,8,12,8,16,12 1,1,1,1,1,1 6 8 11 40 66 Acid 0 40 8 4 12 25 8 0 0 0 0 0 0 0 0 30 Animal IMMUNE_ACID IMMUNE_MAGIC,IMMUNE_ACID Bottom,Middle 846 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_RACID Poison Spitter acid\acid acid\acidblk Retail 128 716 true true 13,8,12,8,16,12 1,1,1,1,1,1 8 10 15 60 85 Acid 1 45 8 4 16 25 8 0 0 0 0 0 0 0 0 30 Animal IMMUNE_ACID IMMUNE_MAGIC,IMMUNE_ACID Bottom,Middle 1248 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_BACID Pit Beast acid\acid acid\acidb Retail 128 716 true true 13,8,12,8,16,12 1,1,1,1,1,1 10 12 21 80 110 Acid 2 55 8 8 18 35 8 0 0 0 0 0 0 0 0 35 Animal RESIST_MAGIC,IMMUNE_ACID IMMUNE_MAGIC,RESIST_LIGHTNING,IMMUNE_ACID Bottom,Middle 2060 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_XACID Lava Maw acid\acid acid\acidr Retail 128 716 true true 13,8,12,8,16,12 1,1,1,1,1,1 12 14 25 100 150 Acid 3 65 8 10 20 40 8 0 0 0 0 0 0 0 0 35 Animal RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_ACID IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_ACID Bottom,Middle 2940 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_SKING Skeleton King sking\sking skelaxe\white Never 160 1010 true true 8,6,16,6,16,6 2,1,1,1,1,2 4 4 9 140 140 SkeletonKing SEARCH,CAN_OPEN_DOOR 3 60 8 6 16 0 0 0 0 0 0 0 0 0 0 70 Undead IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top Uniq(SKCROWN) 570 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_CLEAVER The Butcher fatc\fatc Never 128 980 false false 10,8,12,6,16,0 1,1,1,1,1,1 1 1 1 320 320 Butcher 3 50 8 6 12 0 0 0 0 0 0 0 0 0 0 50 Demon RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle Uniq(CLEAVER) 710 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_FAT Overlord fat\fat Retail 128 1130 true false 8,10,15,6,16,10 4,1,1,1,1,1 5 7 10 60 80 Fat 0 55 8 6 12 0 0 0 0 0 0 0 0 0 0 55 Demon RESIST_FIRE Bottom,Middle 635 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_MUDMAN Mud Man fat\fat fat\blue Retail 128 1130 true false 8,10,15,6,16,10 4,1,1,1,1,1 7 9 14 100 125 Fat SEARCH 1 60 8 8 16 0 0 0 0 0 0 0 0 0 0 60 Demon IMMUNE_LIGHTNING Bottom,Middle 1165 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_TOAD Toad Demon fat\fat fat\fatb Retail 128 1130 true false 8,10,15,6,16,10 4,1,1,1,1,1 8 10 16 135 160 Fat SEARCH 2 70 8 8 16 40 0 8 20 0 0 0 0 0 0 65 Demon IMMUNE_MAGIC IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 1380 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_FLAYED Flayed One fat\fat fat\fatf Retail 128 1130 true false 8,10,15,6,16,10 4,1,1,1,1,1 10 12 20 160 200 Fat SEARCH 3 85 8 10 20 0 0 0 0 0 0 0 0 0 0 70 Demon RESIST_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 2058 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_WYRM Wyrm worm\worm Never 160 2420 false false 13,13,13,11,19,0 1,1,1,1,1,1 5 7 11 60 90 SkeletonMelee 0 40 8 4 10 0 0 0 0 0 0 0 0 0 0 25 Animal RESIST_MAGIC RESIST_MAGIC Bottom,Middle 660 +MT_CAVSLUG Cave Slug worm\worm Never 160 2420 false false 13,13,13,11,19,0 1,1,1,1,1,1 6 8 13 75 110 SkeletonMelee 1 50 8 6 13 0 0 0 0 0 0 0 0 0 0 30 Animal RESIST_MAGIC RESIST_MAGIC Bottom,Middle 994 +MT_DVLWYRM Devil Wyrm worm\worm Never 160 2420 false false 13,13,13,11,19,0 1,1,1,1,1,1 7 9 15 100 140 SkeletonMelee 2 55 8 8 16 0 0 0 0 0 0 0 0 0 0 30 Animal RESIST_MAGIC,RESIST_FIRE RESIST_MAGIC,RESIST_FIRE Bottom,Middle 1320 +MT_DEVOUR Devourer worm\worm Never 160 2420 false false 13,13,13,11,19,0 1,1,1,1,1,1 8 10 17 125 200 SkeletonMelee 3 60 8 10 20 0 0 0 0 0 0 0 0 0 0 35 Animal RESIST_MAGIC,RESIST_FIRE RESIST_MAGIC,RESIST_FIRE Bottom,Middle 1827 +MT_NMAGMA Magma Demon magma\magma Retail 128 1680 true true 8,10,14,7,18,18 2,1,1,1,1,1 8 9 13 50 70 Magma SEARCH,CAN_OPEN_DOOR 0 45 4 2 10 50 13 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 1076 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_YMAGMA Blood Stone magma\magma magma\yellow Retail 128 1680 true true 8,10,14,7,18,18 2,1,1,1,1,1 8 10 14 55 75 Magma SEARCH,CAN_OPEN_DOOR 1 50 4 2 12 50 14 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 1309 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_BMAGMA Hell Stone magma\magma magma\blue Retail 128 1680 true true 8,10,14,7,18,18 2,1,1,1,1,1 9 11 16 60 80 Magma SEARCH,CAN_OPEN_DOOR 2 60 4 2 20 60 14 0 0 0 0 0 0 0 0 50 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 1680 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_WMAGMA Lava Lord magma\magma magma\wierd Retail 128 1680 true true 8,10,14,7,18,18 2,1,1,1,1,1 9 11 18 70 85 Magma SEARCH,CAN_OPEN_DOOR 3 75 4 4 24 60 14 0 0 0 0 0 0 0 0 60 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 2124 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_HORNED Horned Demon rhino\rhino Retail 160 1630 true true 8,8,14,6,16,6 2,1,1,1,1,1 7 9 13 40 80 Rhino SEARCH,CAN_OPEN_DOOR 0 60 7 2 16 100 0 5 32 0 0 0 0 0 0 40 Animal RESIST_FIRE Bottom,Middle,Top 1172 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_MUDRUN Mud Runner rhino\rhino rhino\orange Retail 160 1630 true true 8,8,14,6,16,6 2,1,1,1,1,1 8 10 15 50 90 Rhino SEARCH,CAN_OPEN_DOOR 1 70 7 6 18 100 0 12 36 0 0 0 0 0 0 45 Animal RESIST_FIRE Bottom,Middle,Top 1404 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_FROSTC Frost Charger rhino\rhino rhino\blue Retail 160 1630 true true 8,8,14,6,16,6 2,1,1,1,1,1 9 11 17 60 100 Rhino SEARCH,CAN_OPEN_DOOR 2 80 7 8 20 100 0 20 40 0 0 0 0 0 0 50 Animal IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle,Top 1720 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_OBLORD Obsidian Lord rhino\rhino rhino\rhinob Retail 160 1630 true true 8,8,14,6,16,6 2,1,1,1,1,1 10 12 19 70 110 Rhino SEARCH,CAN_OPEN_DOOR 3 90 7 10 22 100 0 20 50 0 0 0 0 0 0 55 Animal IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 1809 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_BONEDMN oldboned demskel\demskl Never 128 1740 true true 10,8,20,6,24,16 3,1,1,1,1,1 24 24 12 70 70 Storm 0 60 8 6 14 12 0 0 0 0 0 0 0 0 0 50 Demon IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle,Top 1344 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_REDDTH Red Death thin\thin thin\thinv3 Never 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 8 10 16 96 96 Storm 1 75 5 10 20 0 0 0 0 0 0 0 0 0 0 60 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 2168 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_LTCHDMN Litch Demon thin\thin thin\thinv3 Never 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 9 11 18 110 110 Storm 2 80 5 10 24 0 0 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2736 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_UDEDBLRG Undead Balrog thin\thin thin\thinv3 Never 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 11 13 22 130 130 Storm 3 85 5 12 30 0 0 0 0 0 0 0 0 0 0 65 Demon IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 3575 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_INCIN Incinerator fireman\firem Never 128 1460 true false 14,19,20,8,14,23 1,1,1,1,1,1 21 22 16 30 45 FireMan 0 75 8 8 16 0 0 0 0 0 0 0 0 0 0 25 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 1888 None,None,None,None,None,None +MT_FLAMLRD Flame Lord fireman\firem Never 128 1460 true false 14,19,20,8,14,23 1,1,1,1,1,1 22 23 18 40 55 FireMan 1 75 8 10 20 0 0 0 0 0 0 0 0 0 0 25 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 2250 None,None,None,None,None,None +MT_DOOMFIRE Doom Fire fireman\firem Never 128 1460 true false 14,19,20,8,14,23 1,1,1,1,1,1 23 24 20 50 65 FireMan 2 80 8 12 24 0 0 0 0 0 0 0 0 0 0 30 Demon IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 2740 None,None,None,None,None,None +MT_HELLBURN Hell Burner fireman\firem Never 128 1460 true false 14,19,20,8,14,23 1,1,1,1,1,1 24 24 22 60 80 FireMan 3 85 8 15 30 0 0 0 0 0 0 0 0 0 0 30 Demon IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 3355 None,None,None,None,None,None +MT_STORM Red Storm thin\thin thin\thinv3 Retail 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 9 11 18 55 110 Storm SEARCH,CAN_OPEN_DOOR 0 80 5 8 18 75 8 4 16 0 0 0 0 0 0 30 Demon IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2160 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_RSTORM Storm Rider thin\thin Retail 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 10 12 20 60 120 Storm SEARCH,CAN_OPEN_DOOR 1 80 5 8 18 80 8 4 16 0 0 0 0 0 0 30 Demon RESIST_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2391 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_STORML Storm Lord thin\thin thin\thinv2 Retail 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 11 13 22 75 135 Storm SEARCH,CAN_OPEN_DOOR 2 85 5 12 24 75 8 4 16 0 0 0 0 0 0 35 Demon RESIST_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2775 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_MAEL Maelstrom thin\thin thin\thinv1 Retail 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 12 14 24 90 150 Storm SEARCH,CAN_OPEN_DOOR 3 90 5 12 28 75 8 4 16 0 0 0 0 0 0 40 Demon RESIST_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 3177 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_BIGFALL Devil Kin Brute bigfall\fallg newsfx\kbrute Never 128 800 true false 10,8,11,8,17,0 1,1,1,1,2,2 21 22 27 120 160 SkeletonMelee SEARCH,CAN_OPEN_DOOR 3 100 6 18 24 0 0 0 0 0 0 0 0 0 0 70 Animal RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 2400 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_WINGED Winged-Demon gargoyle\gargo Retail 160 1650 true false 14,14,14,10,18,14 1,1,1,1,1,2 5 7 9 45 60 Gargoyle CAN_OPEN_DOOR 0 50 7 10 16 0 0 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Middle,Top 662 FirstFrame+11,FirstFrame+11,FirstFrame+11,FirstFrame+11,FirstFrame+11,EachFrame +MT_GARGOYLE Gargoyle gargoyle\gargo gargoyle\gare Retail 160 1650 true false 14,14,14,10,18,14 1,1,1,1,1,2 7 9 13 60 90 Gargoyle CAN_OPEN_DOOR 1 65 7 10 16 0 0 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Middle,Top 1205 FirstFrame+11,FirstFrame+11,FirstFrame+11,FirstFrame+11,FirstFrame+11,EachFrame +MT_BLOODCLW Blood Claw gargoyle\gargo gargoyle\gargbr Retail 160 1650 true false 14,14,14,10,18,14 1,1,1,1,1,1 9 11 19 75 125 Gargoyle CAN_OPEN_DOOR 2 80 7 14 22 0 0 0 0 0 0 0 0 0 0 50 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Middle,Top 1873 FirstFrame+11,FirstFrame+11,FirstFrame+11,FirstFrame+11,FirstFrame+11,EachFrame +MT_DEATHW Death Wing gargoyle\gargo gargoyle\gargb Retail 160 1650 true false 14,14,14,10,18,14 1,1,1,1,1,1 10 12 23 90 150 Gargoyle CAN_OPEN_DOOR 3 95 7 16 28 0 0 0 0 0 0 0 0 0 0 60 Demon IMMUNE_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Middle,Top 2278 FirstFrame+11,FirstFrame+11,FirstFrame+11,FirstFrame+11,FirstFrame+11,EachFrame +MT_MEGA Slayer mega\mega Retail 160 2220 true true 6,7,14,1,24,5 3,1,1,1,2,1 10 12 20 120 140 Mega SEARCH,CAN_OPEN_DOOR 0 100 8 12 20 0 3 0 0 0 0 0 0 0 0 60 Demon RESIST_MAGIC,IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 2300 EachFrame,EachFrame,FirstFrame,FirstFrame,FirstFrame,EachFrame +MT_GUARD Guardian mega\mega mega\guard Retail 160 2220 true true 6,7,14,1,24,5 3,1,1,1,2,1 11 13 22 140 160 Mega SEARCH,CAN_OPEN_DOOR 1 110 8 14 22 0 3 0 0 0 0 0 0 0 0 65 Demon RESIST_MAGIC,IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 2714 EachFrame,EachFrame,FirstFrame,FirstFrame,FirstFrame,EachFrame +MT_VTEXLRD Vortex Lord mega\mega mega\vtexl Retail 160 2220 true true 6,7,14,1,24,5 3,1,1,1,2,1 12 14 24 160 180 Mega SEARCH,CAN_OPEN_DOOR 2 120 8 18 24 0 3 0 0 0 0 0 0 0 0 70 Demon RESIST_MAGIC,IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 3252 EachFrame,EachFrame,FirstFrame,FirstFrame,FirstFrame,EachFrame +MT_BALROG Balrog mega\mega mega\balr Retail 160 2220 true true 6,7,14,1,24,5 3,1,1,1,2,1 13 15 26 180 200 Mega SEARCH,CAN_OPEN_DOOR 3 130 8 22 30 0 3 0 0 0 0 0 0 0 0 75 Demon RESIST_MAGIC,IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 3643 EachFrame,EachFrame,FirstFrame,FirstFrame,FirstFrame,EachFrame +MT_NSNAKE Cave Viper snake\snake Retail 160 1270 false false 12,11,13,5,18,0 2,1,1,1,1,1 11 13 21 100 150 Snake SEARCH 0 90 8 8 20 0 0 0 0 0 0 0 0 0 0 60 Demon IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle,Top 2725 FirstFrame,FirstFrame,EachFrame,FirstFrame,FirstFrame,None +MT_RSNAKE Fire Drake snake\snake snake\snakr Retail 160 1270 false false 12,11,13,5,18,0 2,1,1,1,1,1 12 14 23 120 170 Snake SEARCH 1 105 8 12 24 0 0 0 0 0 0 0 0 0 0 65 Demon IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 3139 FirstFrame,FirstFrame,EachFrame,FirstFrame,FirstFrame,None +MT_BSNAKE Gold Viper snake\snake snake\snakg Retail 160 1270 false false 12,11,13,5,18,0 2,1,1,1,1,1 13 14 25 140 180 Snake SEARCH 2 120 8 15 26 0 0 0 0 0 0 0 0 0 0 70 Demon IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle,Top 3540 FirstFrame,FirstFrame,EachFrame,FirstFrame,FirstFrame,None +MT_GSNAKE Azure Drake snake\snake snake\snakb Retail 160 1270 false false 12,11,13,5,18,0 2,1,1,1,1,1 15 16 27 160 200 Snake SEARCH 3 130 8 18 30 0 0 0 0 0 0 0 0 0 0 75 Demon RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 3791 FirstFrame,FirstFrame,EachFrame,FirstFrame,FirstFrame,None +MT_NBLACK Black Knight black\black Retail 160 2120 false false 8,8,16,4,24,0 2,1,1,1,1,1 12 14 24 150 150 SkeletonMelee SEARCH 0 110 8 15 20 0 0 0 0 0 0 0 0 0 0 75 Demon RESIST_MAGIC,RESIST_LIGHTNING RESIST_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 3360 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_RTBLACK Doom Guard black\black black\blkkntrt Retail 160 2120 false false 8,8,16,4,24,0 2,1,1,1,1,1 13 15 26 165 165 SkeletonMelee SEARCH 0 130 8 18 25 0 0 0 0 0 0 0 0 0 0 75 Demon RESIST_MAGIC,RESIST_FIRE RESIST_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 3650 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_BTBLACK Steel Lord black\black black\blkkntbt Retail 160 2120 false false 8,8,16,4,24,0 2,1,1,1,1,1 14 16 28 180 180 SkeletonMelee SEARCH 1 120 8 20 30 0 0 0 0 0 0 0 0 0 0 80 Demon RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 4252 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_RBLACK Blood Knight black\black black\blkkntbe Retail 160 2120 false false 8,8,16,4,24,0 2,1,1,1,1,1 13 14 30 200 200 SkeletonMelee SEARCH 1 130 8 25 35 0 0 0 0 0 0 0 0 0 0 85 Demon IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 5130 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_UNRAV The Shredded unrav\unrav newsfx\shred Never 96 484 false false 10,10,12,5,16,0 1,1,1,1,1,1 17 18 23 70 90 SkeletonMelee 0 75 7 4 12 0 0 0 0 0 0 0 0 0 0 65 Undead RESIST_FIRE,RESIST_LIGHTNING RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 900 None,None,None,None,None,None +MT_HOLOWONE Hollow One unrav\unrav acid\acid Never 96 484 false false 10,10,12,5,16,0 1,1,1,1,1,1 18 19 27 135 240 SkeletonMelee 1 75 7 12 24 0 0 0 0 0 0 0 0 0 0 75 Undead IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 4374 None,None,None,None,None,None +MT_PAINMSTR Pain Master unrav\unrav acid\acid Never 96 484 false false 10,10,12,5,16,0 1,1,1,1,1,1 19 20 29 110 200 SkeletonMelee 2 80 7 16 30 0 0 0 0 0 0 0 0 0 0 80 Undead IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 5147 None,None,None,None,None,None +MT_REALWEAV Reality Weaver unrav\unrav acid\acid Never 96 484 false false 10,10,12,5,16,0 1,1,1,1,1,1 20 20 30 135 240 SkeletonMelee 3 85 7 20 35 0 0 0 0 0 0 0 0 0 0 85 Undead RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle 5925 None,None,None,None,None,None +MT_SUCCUBUS Succubus succ\scbs Retail 128 980 false false 14,8,16,7,24,0 1,1,1,1,1,1 12 14 24 120 150 Succubus CAN_OPEN_DOOR 0 100 10 1 20 0 0 0 0 0 0 0 0 0 0 60 Demon RESIST_MAGIC IMMUNE_MAGIC,RESIST_FIRE Bottom,Middle 3696 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_SNOWWICH Snow Witch succ\scbs succ\succb Retail 128 980 false false 14,8,16,7,24,0 1,1,1,1,1,1 13 15 26 135 175 Succubus CAN_OPEN_DOOR 1 110 10 1 24 0 0 0 0 0 0 0 0 0 0 65 Demon RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 4084 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_HLSPWN Hell Spawn succ\scbs succ\succrw Retail 128 980 false false 14,8,16,7,24,0 1,1,1,1,1,1 14 16 28 150 200 Succubus SEARCH,CAN_OPEN_DOOR 2 115 10 1 30 0 0 0 0 0 0 0 0 0 0 75 Demon RESIST_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 4480 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_SOLBRNR Soul Burner succ\scbs succ\succbw Retail 128 980 false false 14,8,16,7,24,0 1,1,1,1,1,1 15 16 30 140 225 Succubus SEARCH,CAN_OPEN_DOOR 3 120 10 1 35 0 0 0 0 0 0 0 0 0 0 85 Demon RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle 4644 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_COUNSLR Counselor mage\mage Retail 128 2000 true false 12,1,20,8,28,20 1,1,1,1,1,1 13 14 25 70 70 Counselor CAN_OPEN_DOOR 0 90 8 8 20 0 0 0 0 0 0 0 0 0 0 0 Demon RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 4070 FirstFrame,FirstFrame,EachFrame,FirstFrame,FirstFrame,FirstFrame +MT_MAGISTR Magistrate mage\mage mage\cnselg Retail 128 2000 true false 12,1,20,8,28,20 1,1,1,1,1,1 14 15 27 85 85 Counselor CAN_OPEN_DOOR 1 100 8 10 24 0 0 0 0 0 0 0 0 0 0 0 Demon RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 4478 FirstFrame,FirstFrame,EachFrame,FirstFrame,FirstFrame,FirstFrame +MT_CABALIST Cabalist mage\mage mage\cnselgd Retail 128 2000 true false 12,1,20,8,28,20 1,1,1,1,1,1 15 16 29 120 120 Counselor CAN_OPEN_DOOR 2 110 8 14 30 0 0 0 0 0 0 0 0 0 0 0 Demon RESIST_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 4929 FirstFrame,FirstFrame,EachFrame,FirstFrame,FirstFrame,FirstFrame +MT_ADVOCATE Advocate mage\mage mage\cnselbk Retail 128 2000 true false 12,1,20,8,28,20 1,1,1,1,1,1 16 16 30 145 145 Counselor CAN_OPEN_DOOR 3 120 8 15 25 0 0 0 0 0 0 0 0 0 0 0 Demon IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 4968 FirstFrame,FirstFrame,EachFrame,FirstFrame,FirstFrame,FirstFrame +MT_GOLEM Golem golem\golem golem\golm Never 96 386 true false 0,16,12,0,12,20 1,1,1,1,1,1 1 1 12 1 1 Golem CAN_OPEN_DOOR 0 0 7 1 1 0 0 0 0 0 0 0 0 0 0 1 Demon 0 None,EachFrame,EachFrame,None,EachFrame,EachFrame +MT_DIABLO The Dark Lord diablo\diablo Never 160 2000 true true 16,6,16,6,16,16 1,1,1,1,1,1 26 26 30 1666 1666 Diablo KNOCKBACK,SEARCH,CAN_OPEN_DOOR 3 220 4 30 60 0 11 0 0 0 0 0 0 0 0 90 Demon IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 31666 FirstFrame,FirstFrame,EachFrame,FirstFrame,EachFrame,EachFrame +MT_DARKMAGE The Arch-Litch Malignus darkmage\dmage darkmage\dmag Never 128 1060 true false 6,1,21,6,23,18 1,1,1,1,1,1 21 21 30 160 160 Counselor CAN_OPEN_DOOR 3 120 8 20 40 0 0 0 0 0 0 0 0 0 0 70 Demon RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 4968 FirstFrame,None,FirstFrame,FirstFrame,FirstFrame,FirstFrame diff --git a/assets/txtdata/objects/objdat.tsv b/assets/txtdata/objects/objdat.tsv index 1d5bd4ee2..c24a23a4a 100644 --- a/assets/txtdata/objects/objdat.tsv +++ b/assets/txtdata/objects/objdat.tsv @@ -1,110 +1,110 @@ -id file minLevel maxLevel levelType theme quest flags animDelay animLen animWidth selectionRegion -OBJ_L1LIGHT l1braz 0 0 DTYPE_CATHEDRAL Animated,Solid,MissilesPassThrough 1 26 64 -OBJ_L1LDOOR l1doors 0 0 DTYPE_CATHEDRAL Light,Trap 1 0 64 Bottom,Middle -OBJ_L1RDOOR l1doors 0 0 DTYPE_CATHEDRAL Light,Trap 2 0 64 Bottom,Middle -OBJ_SKFIRE skulfire 0 0 THEME_SKELROOM Animated,Solid,MissilesPassThrough 2 11 96 -OBJ_LEVER lever 0 0 Solid,MissilesPassThrough,Light,Trap 1 1 96 Bottom -OBJ_CHEST1 chest1 1 24 Solid,MissilesPassThrough,Light,Trap 1 0 96 Bottom -OBJ_CHEST2 chest2 1 24 Solid,MissilesPassThrough,Light,Trap 1 0 96 Bottom -OBJ_CHEST3 chest3 1 24 Solid,MissilesPassThrough,Light,Trap 1 0 96 Bottom -OBJ_CANDLE1 l1braz 0 0 0 0 0 -OBJ_CANDLE2 candle2 0 0 THEME_SHRINE Q_PWATER Animated,Solid,MissilesPassThrough,Light 2 4 96 -OBJ_CANDLEO l1braz 0 0 0 0 0 -OBJ_BANNERL banner 0 0 THEME_SKELROOM Solid,MissilesPassThrough,Light 2 0 96 -OBJ_BANNERM banner 0 0 THEME_SKELROOM Solid,MissilesPassThrough,Light 1 0 96 -OBJ_BANNERR banner 0 0 THEME_SKELROOM Solid,MissilesPassThrough,Light 3 0 96 -OBJ_SKPILE skulpile 0 0 Solid,MissilesPassThrough,Light 1 1 96 -OBJ_SKSTICK1 l1braz 0 0 0 0 0 -OBJ_SKSTICK2 l1braz 0 0 0 0 0 -OBJ_SKSTICK3 l1braz 0 0 0 0 0 -OBJ_SKSTICK4 l1braz 0 0 0 0 0 -OBJ_SKSTICK5 l1braz 0 0 0 0 0 -OBJ_CRUX1 cruxsk1 0 0 Solid,Light,Breakable 1 15 96 Bottom,Middle -OBJ_CRUX2 cruxsk2 0 0 Solid,Light,Breakable 1 15 96 Bottom,Middle -OBJ_CRUX3 cruxsk3 0 0 Solid,Light,Breakable 1 15 96 Bottom,Middle -OBJ_STAND rockstan 5 5 Solid,MissilesPassThrough,Light 1 0 96 -OBJ_ANGEL angel 0 0 Solid,Light 1 0 96 -OBJ_BOOK2L book2 0 0 Solid,MissilesPassThrough,Light 1 0 96 Bottom,Middle -OBJ_BCROSS burncros 0 0 Animated,Solid 0 10 160 -OBJ_NUDEW2R nude2 0 0 Animated,Solid,Light 3 6 128 -OBJ_SWITCHSKL switch4 16 16 Solid,MissilesPassThrough,Light,Trap 1 0 96 Bottom -OBJ_TNUDEM1 tnudem 13 15 Q_BUTCHER Solid,Light 1 0 128 -OBJ_TNUDEM2 tnudem 13 15 THEME_TORTURE Q_BUTCHER Solid,Light 2 0 128 -OBJ_TNUDEM3 tnudem 13 15 THEME_TORTURE Q_BUTCHER Solid,Light 3 0 128 -OBJ_TNUDEM4 tnudem 13 15 THEME_TORTURE Q_BUTCHER Solid,Light 4 0 128 -OBJ_TNUDEW1 tnudew 13 15 THEME_TORTURE Q_BUTCHER Solid,Light 1 0 128 -OBJ_TNUDEW2 tnudew 13 15 THEME_TORTURE Q_BUTCHER Solid,Light 2 0 128 -OBJ_TNUDEW3 tnudew 13 15 THEME_TORTURE Q_BUTCHER Solid,Light 3 0 128 -OBJ_TORTURE1 tsoul 13 15 Q_BUTCHER MissilesPassThrough,Light 1 0 128 -OBJ_TORTURE2 tsoul 13 15 Q_BUTCHER MissilesPassThrough,Light 2 0 128 -OBJ_TORTURE3 tsoul 13 15 Q_BUTCHER MissilesPassThrough,Light 3 0 128 -OBJ_TORTURE4 tsoul 13 15 Q_BUTCHER MissilesPassThrough,Light 4 0 128 -OBJ_TORTURE5 tsoul 13 15 Q_BUTCHER MissilesPassThrough,Light 5 0 128 -OBJ_BOOK2R book2 6 6 Solid,MissilesPassThrough,Light 4 0 96 Bottom,Middle -OBJ_L2LDOOR l2doors 0 0 DTYPE_CATACOMBS Light,Trap 1 0 64 Bottom,Middle -OBJ_L2RDOOR l2doors 0 0 DTYPE_CATACOMBS Light,Trap 2 0 64 Bottom,Middle -OBJ_TORCHL wtorch4 5 8 Animated,MissilesPassThrough 1 9 96 -OBJ_TORCHR wtorch3 5 8 Animated,MissilesPassThrough 1 9 96 -OBJ_TORCHL2 wtorch1 5 8 Animated,MissilesPassThrough 1 9 96 -OBJ_TORCHR2 wtorch2 5 8 Animated,MissilesPassThrough 1 9 96 -OBJ_SARC sarc 1 4 Solid,MissilesPassThrough,Light,Trap 1 5 128 Bottom,Middle -OBJ_FLAMEHOLE flame1 0 0 MissilesPassThrough,Light 1 20 96 -OBJ_FLAMELVR lever 0 0 Solid,MissilesPassThrough,Light,Trap 1 2 96 Bottom -OBJ_WATER miniwatr 0 0 Animated,Solid,Light 1 10 64 -OBJ_BOOKLVR book1 0 0 Solid,MissilesPassThrough,Light 1 0 96 Bottom,Middle -OBJ_TRAPL traphole 1 24 MissilesPassThrough,Light 1 0 64 -OBJ_TRAPR traphole 1 24 MissilesPassThrough,Light 2 0 64 -OBJ_BOOKSHELF bcase 0 0 Solid,Light 1 0 96 -OBJ_WEAPRACK weapstnd 0 0 Solid,Light 1 0 96 -OBJ_BARREL barrel 1 15 Solid,MissilesPassThrough,Light,Breakable 1 9 96 Bottom,Middle -OBJ_BARRELEX barrelex 1 15 Solid,MissilesPassThrough,Light,Breakable 1 10 96 Bottom,Middle -OBJ_SHRINEL lshrineg 0 0 THEME_SHRINE Light 1 11 128 Bottom,Middle -OBJ_SHRINER rshrineg 0 0 THEME_SHRINE Light 1 11 128 Bottom,Middle -OBJ_SKELBOOK book2 0 0 THEME_SKELROOM Solid,MissilesPassThrough,Light 4 0 96 Bottom,Middle -OBJ_BOOKCASEL bcase 0 0 THEME_LIBRARY Solid,Light 3 0 96 Bottom,Middle -OBJ_BOOKCASER bcase 0 0 THEME_LIBRARY Solid,Light 4 0 96 Bottom,Middle -OBJ_BOOKSTAND book2 0 0 THEME_LIBRARY Solid,MissilesPassThrough,Light 1 0 96 Bottom,Middle -OBJ_BOOKCANDLE candle2 0 0 THEME_LIBRARY Animated,Solid,MissilesPassThrough,Light 2 4 96 -OBJ_BLOODFTN bloodfnt 0 0 THEME_BLOODFOUNTAIN Animated,Solid,MissilesPassThrough,Light 2 10 96 Bottom,Middle -OBJ_DECAP decap 13 15 THEME_DECAPITATED Solid,MissilesPassThrough,Light 1 0 96 Bottom -OBJ_TCHEST1 chest1 1 24 Solid,MissilesPassThrough,Light,Trap 1 0 96 Bottom -OBJ_TCHEST2 chest2 1 24 Solid,MissilesPassThrough,Light,Trap 1 0 96 Bottom -OBJ_TCHEST3 chest3 1 24 Solid,MissilesPassThrough,Light,Trap 1 0 96 Bottom -OBJ_BLINDBOOK book1 0 0 Q_BLIND Solid,MissilesPassThrough,Light 1 0 96 Bottom,Middle -OBJ_BLOODBOOK book1 0 0 Q_BLOOD Solid,MissilesPassThrough,Light 4 0 96 Bottom,Middle -OBJ_PEDESTAL pedistl 0 0 Q_BLOOD Solid,MissilesPassThrough,Light 1 0 96 Bottom,Middle -OBJ_L3LDOOR l3doors 0 0 DTYPE_CAVES Light,Trap 1 0 64 Bottom,Middle -OBJ_L3RDOOR l3doors 0 0 DTYPE_CAVES Light,Trap 2 0 64 Bottom,Middle -OBJ_PURIFYINGFTN pfountn 0 0 THEME_PURIFYINGFOUNTAIN Animated,Solid,MissilesPassThrough,Light 2 10 128 Bottom,Middle -OBJ_ARMORSTAND armstand 0 0 THEME_ARMORSTAND Solid,Light 1 0 96 Bottom,Middle -OBJ_ARMORSTANDN armstand 0 0 THEME_ARMORSTAND Solid,Light 2 0 96 -OBJ_GOATSHRINE goatshrn 0 0 THEME_GOATSHRINE Animated,Solid,MissilesPassThrough,Light 2 10 96 Bottom,Middle -OBJ_CAULDRON cauldren 13 15 Solid,Light 1 0 96 Bottom,Middle -OBJ_MURKYFTN mfountn 0 0 THEME_MURKYFOUNTAIN Animated,Solid,MissilesPassThrough,Light 2 10 128 Bottom,Middle -OBJ_TEARFTN tfountn 0 0 THEME_TEARFOUNTAIN Animated,Solid,MissilesPassThrough,Light 2 4 128 Bottom,Middle -OBJ_ALTBOY altboy 0 0 Q_BETRAYER Solid,MissilesPassThrough,Light 1 0 128 -OBJ_MCIRCLE1 mcirl 0 0 Q_BETRAYER MissilesPassThrough,Light 1 0 96 -OBJ_MCIRCLE2 mcirl 0 0 Q_BETRAYER MissilesPassThrough,Light 1 0 96 -OBJ_STORYBOOK bkslbrnt 0 0 Solid,MissilesPassThrough,Light 1 0 96 Bottom,Middle -OBJ_STORYCANDLE candle2 0 0 Q_BETRAYER Animated,Solid,MissilesPassThrough,Light 2 4 96 -OBJ_STEELTOME book1 0 0 Q_WARLORD Solid,MissilesPassThrough,Light 4 0 96 Bottom,Middle -OBJ_WARARMOR armstand 0 0 Q_WARLORD Solid,Light 1 0 96 Bottom,Middle -OBJ_WARWEAP weapstnd 0 0 Q_WARLORD Solid,Light 1 0 96 Bottom,Middle -OBJ_TBCROSS burncros 0 0 THEME_BRNCROSS Animated,Solid 0 10 160 -OBJ_WEAPONRACK weapstnd 0 0 THEME_WEAPONRACK Solid,Light 1 0 96 Bottom,Middle -OBJ_WEAPONRACKN weapstnd 0 0 THEME_WEAPONRACK Solid,Light 2 0 96 -OBJ_MUSHPATCH mushptch 0 0 Q_MUSHROOM Solid,MissilesPassThrough,Light,Trap 1 0 96 Bottom,Middle -OBJ_LAZSTAND lzstand 0 0 Q_BETRAYER Solid,Light 1 0 128 Bottom,Middle -OBJ_SLAINHERO decap 9 9 Solid,MissilesPassThrough,Light 2 0 96 Bottom -OBJ_SIGNCHEST chest3 0 0 Solid,MissilesPassThrough,Light 1 0 96 Bottom -OBJ_BOOKSHELFR bcase 0 0 Solid,Light 2 0 96 -OBJ_POD l6pod1 17 20 Solid,MissilesPassThrough,Light,Breakable 1 9 96 Bottom,Middle -OBJ_PODEX l6pod2 17 20 Solid,MissilesPassThrough,Light,Breakable 1 10 96 Bottom,Middle -OBJ_URN urn 21 24 Solid,MissilesPassThrough,Light,Breakable 1 9 96 Bottom,Middle -OBJ_URNEX urnexpld 21 24 Solid,MissilesPassThrough,Light,Breakable 1 10 96 Bottom,Middle -OBJ_L5BOOKS l5books 21 24 Solid,MissilesPassThrough,Light 1 0 96 Bottom,Middle -OBJ_L5CANDLE l5light 21 23 Animated,Solid,MissilesPassThrough,Light 2 4 96 -OBJ_L5LDOOR l5door 0 0 DTYPE_CRYPT Light,Trap 1 0 64 Bottom,Middle -OBJ_L5RDOOR l5door 0 0 DTYPE_CRYPT Light,Trap 2 0 64 Bottom,Middle -OBJ_L5LEVER l5lever 24 24 Solid,MissilesPassThrough,Light,Trap 1 1 96 Bottom -OBJ_L5SARC l5sarco 21 24 Solid,MissilesPassThrough,Light,Trap 1 5 128 Bottom,Middle +id file minLevel maxLevel levelType theme quest flags animDelay animLen animWidth selectionRegion shadowRule +OBJ_L1LIGHT l1braz 0 0 DTYPE_CATHEDRAL Animated,Solid,MissilesPassThrough 1 26 64 None +OBJ_L1LDOOR l1doors 0 0 DTYPE_CATHEDRAL Light,Trap 1 0 64 Bottom,Middle None +OBJ_L1RDOOR l1doors 0 0 DTYPE_CATHEDRAL Light,Trap 2 0 64 Bottom,Middle None +OBJ_SKFIRE skulfire 0 0 THEME_SKELROOM Animated,Solid,MissilesPassThrough 2 11 96 FirstFrame +OBJ_LEVER lever 0 0 Solid,MissilesPassThrough,Light,Trap 1 1 96 Bottom FirstFrame +OBJ_CHEST1 chest1 1 24 Solid,MissilesPassThrough,Light,Trap 1 0 96 Bottom EachFrame +OBJ_CHEST2 chest2 1 24 Solid,MissilesPassThrough,Light,Trap 1 0 96 Bottom EachFrame +OBJ_CHEST3 chest3 1 24 Solid,MissilesPassThrough,Light,Trap 1 0 96 Bottom EachFrame +OBJ_CANDLE1 l1braz 0 0 0 0 0 None +OBJ_CANDLE2 candle2 0 0 THEME_SHRINE Q_PWATER Animated,Solid,MissilesPassThrough,Light 2 4 96 FirstFrame +OBJ_CANDLEO l1braz 0 0 0 0 0 None +OBJ_BANNERL banner 0 0 THEME_SKELROOM Solid,MissilesPassThrough,Light 2 0 96 EachFrame +OBJ_BANNERM banner 0 0 THEME_SKELROOM Solid,MissilesPassThrough,Light 1 0 96 EachFrame +OBJ_BANNERR banner 0 0 THEME_SKELROOM Solid,MissilesPassThrough,Light 3 0 96 EachFrame +OBJ_SKPILE skulpile 0 0 Solid,MissilesPassThrough,Light 1 1 96 FirstFrame +OBJ_SKSTICK1 l1braz 0 0 0 0 0 None +OBJ_SKSTICK2 l1braz 0 0 0 0 0 None +OBJ_SKSTICK3 l1braz 0 0 0 0 0 None +OBJ_SKSTICK4 l1braz 0 0 0 0 0 None +OBJ_SKSTICK5 l1braz 0 0 0 0 0 None +OBJ_CRUX1 cruxsk1 0 0 Solid,Light,Breakable 1 15 96 Bottom,Middle EachFrame +OBJ_CRUX2 cruxsk2 0 0 Solid,Light,Breakable 1 15 96 Bottom,Middle EachFrame +OBJ_CRUX3 cruxsk3 0 0 Solid,Light,Breakable 1 15 96 Bottom,Middle EachFrame +OBJ_STAND rockstan 5 5 Solid,MissilesPassThrough,Light 1 0 96 None +OBJ_ANGEL angel 0 0 Solid,Light 1 0 96 EachFrame +OBJ_BOOK2L book2 0 0 Solid,MissilesPassThrough,Light 1 0 96 Bottom,Middle EachFrame +OBJ_BCROSS burncros 0 0 Animated,Solid 0 10 160 None +OBJ_NUDEW2R nude2 0 0 Animated,Solid,Light 3 6 128 None +OBJ_SWITCHSKL switch4 16 16 Solid,MissilesPassThrough,Light,Trap 1 0 96 Bottom FirstFrame +OBJ_TNUDEM1 tnudem 13 15 Q_BUTCHER Solid,Light 1 0 128 EachFrame +OBJ_TNUDEM2 tnudem 13 15 THEME_TORTURE Q_BUTCHER Solid,Light 2 0 128 EachFrame +OBJ_TNUDEM3 tnudem 13 15 THEME_TORTURE Q_BUTCHER Solid,Light 3 0 128 EachFrame +OBJ_TNUDEM4 tnudem 13 15 THEME_TORTURE Q_BUTCHER Solid,Light 4 0 128 EachFrame +OBJ_TNUDEW1 tnudew 13 15 THEME_TORTURE Q_BUTCHER Solid,Light 1 0 128 EachFrame +OBJ_TNUDEW2 tnudew 13 15 THEME_TORTURE Q_BUTCHER Solid,Light 2 0 128 EachFrame +OBJ_TNUDEW3 tnudew 13 15 THEME_TORTURE Q_BUTCHER Solid,Light 3 0 128 EachFrame +OBJ_TORTURE1 tsoul 13 15 Q_BUTCHER MissilesPassThrough,Light 1 0 128 None +OBJ_TORTURE2 tsoul 13 15 Q_BUTCHER MissilesPassThrough,Light 2 0 128 None +OBJ_TORTURE3 tsoul 13 15 Q_BUTCHER MissilesPassThrough,Light 3 0 128 None +OBJ_TORTURE4 tsoul 13 15 Q_BUTCHER MissilesPassThrough,Light 4 0 128 None +OBJ_TORTURE5 tsoul 13 15 Q_BUTCHER MissilesPassThrough,Light 5 0 128 None +OBJ_BOOK2R book2 6 6 Solid,MissilesPassThrough,Light 4 0 96 Bottom,Middle EachFrame +OBJ_L2LDOOR l2doors 0 0 DTYPE_CATACOMBS Light,Trap 1 0 64 Bottom,Middle None +OBJ_L2RDOOR l2doors 0 0 DTYPE_CATACOMBS Light,Trap 2 0 64 Bottom,Middle None +OBJ_TORCHL wtorch4 5 8 Animated,MissilesPassThrough 1 9 96 None +OBJ_TORCHR wtorch3 5 8 Animated,MissilesPassThrough 1 9 96 None +OBJ_TORCHL2 wtorch1 5 8 Animated,MissilesPassThrough 1 9 96 None +OBJ_TORCHR2 wtorch2 5 8 Animated,MissilesPassThrough 1 9 96 None +OBJ_SARC sarc 1 4 Solid,MissilesPassThrough,Light,Trap 1 5 128 Bottom,Middle FirstFrame +OBJ_FLAMEHOLE flame1 0 0 MissilesPassThrough,Light 1 20 96 None +OBJ_FLAMELVR lever 0 0 Solid,MissilesPassThrough,Light,Trap 1 2 96 Bottom FirstFrame +OBJ_WATER miniwatr 0 0 Animated,Solid,Light 1 10 64 None +OBJ_BOOKLVR book1 0 0 Solid,MissilesPassThrough,Light 1 0 96 Bottom,Middle EachFrame +OBJ_TRAPL traphole 1 24 MissilesPassThrough,Light 1 0 64 None +OBJ_TRAPR traphole 1 24 MissilesPassThrough,Light 2 0 64 None +OBJ_BOOKSHELF bcase 0 0 Solid,Light 1 0 96 None +OBJ_WEAPRACK weapstnd 0 0 Solid,Light 1 0 96 EachFrame +OBJ_BARREL barrel 1 15 Solid,MissilesPassThrough,Light,Breakable 1 9 96 Bottom,Middle FirstFrame +OBJ_BARRELEX barrelex 1 15 Solid,MissilesPassThrough,Light,Breakable 1 10 96 Bottom,Middle FirstFrame +OBJ_SHRINEL lshrineg 0 0 THEME_SHRINE Light 1 11 128 Bottom,Middle None +OBJ_SHRINER rshrineg 0 0 THEME_SHRINE Light 1 11 128 Bottom,Middle None +OBJ_SKELBOOK book2 0 0 THEME_SKELROOM Solid,MissilesPassThrough,Light 4 0 96 Bottom,Middle EachFrame +OBJ_BOOKCASEL bcase 0 0 THEME_LIBRARY Solid,Light 3 0 96 Bottom,Middle None +OBJ_BOOKCASER bcase 0 0 THEME_LIBRARY Solid,Light 4 0 96 Bottom,Middle None +OBJ_BOOKSTAND book2 0 0 THEME_LIBRARY Solid,MissilesPassThrough,Light 1 0 96 Bottom,Middle EachFrame +OBJ_BOOKCANDLE candle2 0 0 THEME_LIBRARY Animated,Solid,MissilesPassThrough,Light 2 4 96 FirstFrame +OBJ_BLOODFTN bloodfnt 0 0 THEME_BLOODFOUNTAIN Animated,Solid,MissilesPassThrough,Light 2 10 96 Bottom,Middle None +OBJ_DECAP decap 13 15 THEME_DECAPITATED Solid,MissilesPassThrough,Light 1 0 96 Bottom FirstFrame +OBJ_TCHEST1 chest1 1 24 Solid,MissilesPassThrough,Light,Trap 1 0 96 Bottom EachFrame +OBJ_TCHEST2 chest2 1 24 Solid,MissilesPassThrough,Light,Trap 1 0 96 Bottom EachFrame +OBJ_TCHEST3 chest3 1 24 Solid,MissilesPassThrough,Light,Trap 1 0 96 Bottom EachFrame +OBJ_BLINDBOOK book1 0 0 Q_BLIND Solid,MissilesPassThrough,Light 1 0 96 Bottom,Middle EachFrame +OBJ_BLOODBOOK book1 0 0 Q_BLOOD Solid,MissilesPassThrough,Light 4 0 96 Bottom,Middle EachFrame +OBJ_PEDESTAL pedistl 0 0 Q_BLOOD Solid,MissilesPassThrough,Light 1 0 96 Bottom,Middle FirstFrame +OBJ_L3LDOOR l3doors 0 0 DTYPE_CAVES Light,Trap 1 0 64 Bottom,Middle None +OBJ_L3RDOOR l3doors 0 0 DTYPE_CAVES Light,Trap 2 0 64 Bottom,Middle None +OBJ_PURIFYINGFTN pfountn 0 0 THEME_PURIFYINGFOUNTAIN Animated,Solid,MissilesPassThrough,Light 2 10 128 Bottom,Middle None +OBJ_ARMORSTAND armstand 0 0 THEME_ARMORSTAND Solid,Light 1 0 96 Bottom,Middle FirstFrame +OBJ_ARMORSTANDN armstand 0 0 THEME_ARMORSTAND Solid,Light 2 0 96 FirstFrame +OBJ_GOATSHRINE goatshrn 0 0 THEME_GOATSHRINE Animated,Solid,MissilesPassThrough,Light 2 10 96 Bottom,Middle None +OBJ_CAULDRON cauldren 13 15 Solid,Light 1 0 96 Bottom,Middle Baked +OBJ_MURKYFTN mfountn 0 0 THEME_MURKYFOUNTAIN Animated,Solid,MissilesPassThrough,Light 2 10 128 Bottom,Middle None +OBJ_TEARFTN tfountn 0 0 THEME_TEARFOUNTAIN Animated,Solid,MissilesPassThrough,Light 2 4 128 Bottom,Middle None +OBJ_ALTBOY altboy 0 0 Q_BETRAYER Solid,MissilesPassThrough,Light 1 0 128 FirstFrame +OBJ_MCIRCLE1 mcirl 0 0 Q_BETRAYER MissilesPassThrough,Light 1 0 96 None +OBJ_MCIRCLE2 mcirl 0 0 Q_BETRAYER MissilesPassThrough,Light 1 0 96 None +OBJ_STORYBOOK bkslbrnt 0 0 Solid,MissilesPassThrough,Light 1 0 96 Bottom,Middle FirstFrame +OBJ_STORYCANDLE candle2 0 0 Q_BETRAYER Animated,Solid,MissilesPassThrough,Light 2 4 96 FirstFrame +OBJ_STEELTOME book1 0 0 Q_WARLORD Solid,MissilesPassThrough,Light 4 0 96 Bottom,Middle EachFrame +OBJ_WARARMOR armstand 0 0 Q_WARLORD Solid,Light 1 0 96 Bottom,Middle FirstFrame +OBJ_WARWEAP weapstnd 0 0 Q_WARLORD Solid,Light 1 0 96 Bottom,Middle EachFrame +OBJ_TBCROSS burncros 0 0 THEME_BRNCROSS Animated,Solid 0 10 160 None +OBJ_WEAPONRACK weapstnd 0 0 THEME_WEAPONRACK Solid,Light 1 0 96 Bottom,Middle EachFrame +OBJ_WEAPONRACKN weapstnd 0 0 THEME_WEAPONRACK Solid,Light 2 0 96 EachFrame +OBJ_MUSHPATCH mushptch 0 0 Q_MUSHROOM Solid,MissilesPassThrough,Light,Trap 1 0 96 Bottom,Middle None +OBJ_LAZSTAND lzstand 0 0 Q_BETRAYER Solid,Light 1 0 128 Bottom,Middle FirstFrame +OBJ_SLAINHERO decap 9 9 Solid,MissilesPassThrough,Light 2 0 96 Bottom FirstFrame +OBJ_SIGNCHEST chest3 0 0 Solid,MissilesPassThrough,Light 1 0 96 Bottom EachFrame +OBJ_BOOKSHELFR bcase 0 0 Solid,Light 2 0 96 None +OBJ_POD l6pod1 17 20 Solid,MissilesPassThrough,Light,Breakable 1 9 96 Bottom,Middle None +OBJ_PODEX l6pod2 17 20 Solid,MissilesPassThrough,Light,Breakable 1 10 96 Bottom,Middle None +OBJ_URN urn 21 24 Solid,MissilesPassThrough,Light,Breakable 1 9 96 Bottom,Middle EachFrame +OBJ_URNEX urnexpld 21 24 Solid,MissilesPassThrough,Light,Breakable 1 10 96 Bottom,Middle EachFrame +OBJ_L5BOOKS l5books 21 24 Solid,MissilesPassThrough,Light 1 0 96 Bottom,Middle None +OBJ_L5CANDLE l5light 21 23 Animated,Solid,MissilesPassThrough,Light 2 4 96 FirstFrame +OBJ_L5LDOOR l5door 0 0 DTYPE_CRYPT Light,Trap 1 0 64 Bottom,Middle None +OBJ_L5RDOOR l5door 0 0 DTYPE_CRYPT Light,Trap 2 0 64 Bottom,Middle None +OBJ_L5LEVER l5lever 24 24 Solid,MissilesPassThrough,Light,Trap 1 1 96 Bottom None +OBJ_L5SARC l5sarco 21 24 Solid,MissilesPassThrough,Light,Trap 1 5 128 Bottom,Middle None diff --git a/assets/txtdata/towners/towners.tsv b/assets/txtdata/towners/towners.tsv index 14f8a0ceb..c13faaa0e 100644 --- a/assets/txtdata/towners/towners.tsv +++ b/assets/txtdata/towners/towners.tsv @@ -1,13 +1,13 @@ -type name position_x position_y direction animWidth animPath animFrames animDelay gossipTexts animOrder -TOWN_SMITH Griswold the Blacksmith 62 63 SouthWest 96 towners\smith\smithn 16 3 TEXT_GRISWOLD2,TEXT_GRISWOLD3,TEXT_GRISWOLD4,TEXT_GRISWOLD5,TEXT_GRISWOLD6,TEXT_GRISWOLD7,TEXT_GRISWOLD8,TEXT_GRISWOLD9,TEXT_GRISWOLD10,TEXT_GRISWOLD12,TEXT_GRISWOLD13 4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,14,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,3 -TOWN_HEALER Pepin the Healer 55 79 SouthEast 96 towners\healer\healer 20 6 TEXT_PEPIN2,TEXT_PEPIN3,TEXT_PEPIN4,TEXT_PEPIN5,TEXT_PEPIN6,TEXT_PEPIN7,TEXT_PEPIN9,TEXT_PEPIN10,TEXT_PEPIN11 0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19 -TOWN_DEADGUY Wounded Townsman 24 32 North 96 towners\butch\deadguy 8 6 -TOWN_TAVERN Ogden the Tavern owner 55 62 SouthWest 96 towners\twnf\twnfn 16 3 TEXT_OGDEN2,TEXT_OGDEN3,TEXT_OGDEN4,TEXT_OGDEN5,TEXT_OGDEN6,TEXT_OGDEN8,TEXT_OGDEN9,TEXT_OGDEN10 0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,1,0,15,14,13,13,14,15,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15 -TOWN_STORY Cain the Elder 62 71 South 96 towners\strytell\strytell 25 3 TEXT_STORY2,TEXT_STORY3,TEXT_STORY4,TEXT_STORY5,TEXT_STORY6,TEXT_STORY7,TEXT_STORY9,TEXT_STORY10,TEXT_STORY11 0,0,24,24,23,22,21,20,19,18,17,16,15,14,15,16,17,18,19,20,21,22,23,24,24,24,0,0,0,24,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0 -TOWN_DRUNK Farnham the Drunk 71 84 South 96 towners\drunk\twndrunk 18 3 TEXT_FARNHAM2,TEXT_FARNHAM3,TEXT_FARNHAM4,TEXT_FARNHAM5,TEXT_FARNHAM6,TEXT_FARNHAM8,TEXT_FARNHAM9,TEXT_FARNHAM10,TEXT_FARNHAM11,TEXT_FARNHAM12,TEXT_FARNHAM13 0,0,0,1,2,3,4,5,6,7,8,9,10,10,10,10,11,12,13,14,15,16,17,17,0,0,0,17,16,15,14,13,12,11,10,9,10,11,12,13,14,15,16,17,0,1,2,3,4,4,4,3,2,1 -TOWN_WITCH Adria the Witch 80 20 South 96 towners\townwmn1\witch 19 6 TEXT_ADRIA2,TEXT_ADRIA3,TEXT_ADRIA4,TEXT_ADRIA5,TEXT_ADRIA6,TEXT_ADRIA7,TEXT_ADRIA8,TEXT_ADRIA9,TEXT_ADRIA10,TEXT_ADRIA12,TEXT_ADRIA13 3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,2,1,0,18,17,18,0,1,0,18,17,18,0,1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,14,14,13,12,12,12,12,13,14,14,14,13,12,11,11,11,10,9,9,9,8,7,8,9,9,10,11,12,13,14,15,16,17,18,0,1,0,18,17,18,0,1,0,1,2 -TOWN_BMAID Gillian the Barmaid 43 66 South 96 towners\townwmn1\wmnn 18 6 TEXT_GILLIAN2,TEXT_GILLIAN3,TEXT_GILLIAN4,TEXT_GILLIAN5,TEXT_GILLIAN6,TEXT_GILLIAN7,TEXT_GILLIAN9,TEXT_GILLIAN10 -TOWN_PEGBOY Wirt the Peg-legged boy 11 53 South 96 towners\townboy\pegkid1 20 6 TEXT_WIRT2,TEXT_WIRT3,TEXT_WIRT4,TEXT_WIRT5,TEXT_WIRT6,TEXT_WIRT7,TEXT_WIRT8,TEXT_WIRT9,TEXT_WIRT11,TEXT_WIRT12 -TOWN_COW Cow 58 16 SouthWest 128 12 3 -TOWN_COW Cow 56 14 NorthWest 128 12 3 -TOWN_COW Cow 59 20 North 128 12 3 +type name position_x position_y direction animWidth animPath animFrames animDelay gossipTexts animOrder shadowRule +TOWN_SMITH Griswold the Blacksmith 62 63 SouthWest 96 towners\smith\smithn 16 3 TEXT_GRISWOLD2,TEXT_GRISWOLD3,TEXT_GRISWOLD4,TEXT_GRISWOLD5,TEXT_GRISWOLD6,TEXT_GRISWOLD7,TEXT_GRISWOLD8,TEXT_GRISWOLD9,TEXT_GRISWOLD10,TEXT_GRISWOLD12,TEXT_GRISWOLD13 4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,14,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,3 FirstFrame +TOWN_HEALER Pepin the Healer 55 79 SouthEast 96 towners\healer\healer 20 6 TEXT_PEPIN2,TEXT_PEPIN3,TEXT_PEPIN4,TEXT_PEPIN5,TEXT_PEPIN6,TEXT_PEPIN7,TEXT_PEPIN9,TEXT_PEPIN10,TEXT_PEPIN11 0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19 FirstFrame +TOWN_DEADGUY Wounded Townsman 24 32 North 96 towners\butch\deadguy 8 6 None +TOWN_TAVERN Ogden the Tavern owner 55 62 SouthWest 96 towners\twnf\twnfn 16 3 TEXT_OGDEN2,TEXT_OGDEN3,TEXT_OGDEN4,TEXT_OGDEN5,TEXT_OGDEN6,TEXT_OGDEN8,TEXT_OGDEN9,TEXT_OGDEN10 0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,1,0,15,14,13,13,14,15,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15 FirstFrame +TOWN_STORY Cain the Elder 62 71 South 96 towners\strytell\strytell 25 3 TEXT_STORY2,TEXT_STORY3,TEXT_STORY4,TEXT_STORY5,TEXT_STORY6,TEXT_STORY7,TEXT_STORY9,TEXT_STORY10,TEXT_STORY11 0,0,24,24,23,22,21,20,19,18,17,16,15,14,15,16,17,18,19,20,21,22,23,24,24,24,0,0,0,24,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0 FirstFrame +TOWN_DRUNK Farnham the Drunk 71 84 South 96 towners\drunk\twndrunk 18 3 TEXT_FARNHAM2,TEXT_FARNHAM3,TEXT_FARNHAM4,TEXT_FARNHAM5,TEXT_FARNHAM6,TEXT_FARNHAM8,TEXT_FARNHAM9,TEXT_FARNHAM10,TEXT_FARNHAM11,TEXT_FARNHAM12,TEXT_FARNHAM13 0,0,0,1,2,3,4,5,6,7,8,9,10,10,10,10,11,12,13,14,15,16,17,17,0,0,0,17,16,15,14,13,12,11,10,9,10,11,12,13,14,15,16,17,0,1,2,3,4,4,4,3,2,1 FirstFrame +TOWN_WITCH Adria the Witch 80 20 South 96 towners\townwmn1\witch 19 6 TEXT_ADRIA2,TEXT_ADRIA3,TEXT_ADRIA4,TEXT_ADRIA5,TEXT_ADRIA6,TEXT_ADRIA7,TEXT_ADRIA8,TEXT_ADRIA9,TEXT_ADRIA10,TEXT_ADRIA12,TEXT_ADRIA13 3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,2,1,0,18,17,18,0,1,0,18,17,18,0,1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,14,14,13,12,12,12,12,13,14,14,14,13,12,11,11,11,10,9,9,9,8,7,8,9,9,10,11,12,13,14,15,16,17,18,0,1,0,18,17,18,0,1,0,1,2 FirstFrame +TOWN_BMAID Gillian the Barmaid 43 66 South 96 towners\townwmn1\wmnn 18 6 TEXT_GILLIAN2,TEXT_GILLIAN3,TEXT_GILLIAN4,TEXT_GILLIAN5,TEXT_GILLIAN6,TEXT_GILLIAN7,TEXT_GILLIAN9,TEXT_GILLIAN10 FirstFrame +TOWN_PEGBOY Wirt the Peg-legged boy 11 53 South 96 towners\townboy\pegkid1 20 6 TEXT_WIRT2,TEXT_WIRT3,TEXT_WIRT4,TEXT_WIRT5,TEXT_WIRT6,TEXT_WIRT7,TEXT_WIRT8,TEXT_WIRT9,TEXT_WIRT11,TEXT_WIRT12 FirstFrame|Stray +TOWN_COW Cow 58 16 SouthWest 128 12 3 +TOWN_COW Cow 56 14 NorthWest 128 12 3 +TOWN_COW Cow 59 20 North 128 12 3 diff --git a/mods/hf/txtdata/monsters/monstdat.tsv b/mods/hf/txtdata/monsters/monstdat.tsv index ae909a267..3d73fc4c2 100644 --- a/mods/hf/txtdata/monsters/monstdat.tsv +++ b/mods/hf/txtdata/monsters/monstdat.tsv @@ -1,139 +1,139 @@ -_monster_id name assetsSuffix soundSuffix trnFile availability width image hasSpecial hasSpecialSound frames[6] rate[6] minDunLvl maxDunLvl level hitPointsMinimum hitPointsMaximum ai abilityFlags intelligence toHit animFrameNum minDamage maxDamage toHitSpecial animFrameNumSpecial minDamageSpecial maxDamageSpecial reducePlayerStrength reducePlayerMagic reducePlayerDexterity reducePlayerVitality reducePlayerMaxHP reducePlayerMaxMana armorClass monsterClass resistance resistanceHell selectionRegion treasure exp -MT_NZOMBIE Zombie zombie\zombie Always 128 799 false false 11,24,12,6,16,0 4,1,1,1,1,1 1 2 1 4 7 Zombie 0 10 8 2 5 0 0 0 0 0 0 0 0 0 0 5 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 54 -MT_BZOMBIE Ghoul zombie\zombie zombie\bluered Always 128 799 false false 11,24,12,6,16,0 4,1,1,1,1,1 2 3 2 7 11 Zombie 1 10 8 3 10 0 0 0 0 0 0 0 0 0 0 10 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 58 -MT_GZOMBIE Rotting Carcass zombie\zombie zombie\grey Always 128 799 false false 11,24,12,6,16,0 4,1,1,1,1,1 2 4 4 15 25 Zombie 2 25 8 5 15 0 0 0 0 0 0 0 0 0 0 15 Undead IMMUNE_MAGIC IMMUNE_MAGIC,RESIST_FIRE Bottom,Middle 136 -MT_YZOMBIE Black Death zombie\zombie zombie\yellow Always 128 799 false false 11,24,12,6,16,0 4,1,1,1,1,1 3 5 6 25 40 Zombie 3 30 8 6 22 0 0 0 0 0 0 0 0 1 0 20 Undead IMMUNE_MAGIC IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 240 -MT_RFALLSP Fallen One falspear\phall falspear\fallent Always 128 543 true true 11,11,13,11,18,13 3,1,1,1,1,1 1 2 1 1 4 Fallen 0 15 7 1 3 0 5 0 0 0 0 0 0 0 0 0 Animal Bottom,Middle 46 -MT_DFALLSP Carver falspear\phall falspear\dark Always 128 543 true true 11,11,13,11,18,13 3,1,1,1,1,1 2 3 3 4 8 Fallen 2 20 7 2 5 0 5 0 0 0 0 0 0 0 0 5 Animal Bottom,Middle 80 -MT_YFALLSP Devil Kin falspear\phall Always 128 543 true true 11,11,13,11,18,13 3,1,1,1,1,1 2 4 5 12 24 Fallen 2 25 7 3 7 0 5 0 0 0 0 0 0 0 0 10 Animal RESIST_FIRE Bottom,Middle 155 -MT_BFALLSP Dark One falspear\phall falspear\blue Always 128 543 true true 11,11,13,11,18,13 3,1,1,1,1,1 3 5 7 20 36 Fallen 3 30 7 4 8 0 5 0 0 0 0 0 0 0 0 15 Animal RESIST_LIGHTNING Bottom,Middle 255 -MT_WSKELAX Skeleton skelaxe\sklax skelaxe\white Always 128 553 true false 12,8,13,6,17,16 5,1,1,1,1,1 1 2 1 2 4 SkeletonMelee 0 20 8 1 4 0 0 0 0 0 0 0 0 0 0 0 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 64 -MT_TSKELAX Corpse Axe skelaxe\sklax skelaxe\skelt Always 128 553 true false 12,8,13,6,17,16 4,1,1,1,1,1 2 3 2 4 7 SkeletonMelee 1 25 8 3 5 0 0 0 0 0 0 0 0 0 0 0 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 68 -MT_RSKELAX Burning Dead skelaxe\sklax Always 128 553 true false 12,8,13,6,17,16 2,1,1,1,1,1 2 4 4 8 12 SkeletonMelee 2 30 8 3 7 0 0 0 0 0 0 0 0 0 0 5 Undead IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 154 -MT_XSKELAX Horror skelaxe\sklax skelaxe\black Always 128 553 true false 12,8,13,6,17,16 3,1,1,1,1,1 3 5 6 12 20 SkeletonMelee 3 35 8 4 9 0 0 0 0 0 0 0 0 0 0 15 Undead IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 264 -MT_RFALLSD Fallen One falsword\fall falsword\fallent Always 128 623 true true 12,12,13,11,14,15 3,1,1,1,1,1 1 2 1 2 5 Fallen 0 15 8 1 4 0 5 0 0 0 0 0 0 0 0 10 Animal Bottom,Middle 52 -MT_DFALLSD Carver falsword\fall falsword\dark Always 128 623 true true 12,12,13,11,14,15 3,1,1,1,1,1 2 3 3 5 9 Fallen 1 20 8 2 7 0 5 0 0 0 0 0 0 0 0 15 Animal Bottom,Middle 90 -MT_YFALLSD Devil Kin falsword\fall Always 128 623 true true 12,12,13,11,14,15 3,1,1,1,1,1 2 4 5 16 24 Fallen 2 25 8 4 10 0 5 0 0 0 0 0 0 0 0 20 Animal RESIST_FIRE Bottom,Middle 180 -MT_BFALLSD Dark One falsword\fall falsword\blue Always 128 623 true true 12,12,13,11,14,15 3,1,1,1,1,1 3 5 7 24 36 Fallen 3 30 8 4 12 0 5 0 0 0 0 0 0 0 0 25 Animal RESIST_LIGHTNING Bottom,Middle 280 -MT_NSCAV Scavenger scav\scav Always 128 410 true false 12,8,12,6,20,11 2,1,1,1,1,1 1 3 2 3 6 Scavenger 0 20 7 1 5 0 0 0 0 0 0 0 0 0 0 10 Animal RESIST_FIRE Bottom,Middle 80 -MT_BSCAV Plague Eater scav\scav scav\scavbr Always 128 410 true false 12,8,12,6,20,11 2,1,1,1,1,1 2 4 4 12 24 Scavenger 1 30 7 1 8 0 0 0 0 0 0 0 0 0 0 20 Animal RESIST_LIGHTNING Bottom,Middle 188 -MT_WSCAV Shadow Beast scav\scav scav\scavbe Always 128 410 true false 12,8,12,6,20,11 2,1,1,1,1,1 3 5 6 24 36 Scavenger 2 35 7 3 12 0 0 0 0 0 0 0 0 0 0 25 Animal RESIST_FIRE Bottom,Middle 375 -MT_YSCAV Bone Gasher scav\scav scav\scavw Always 128 410 true false 12,8,12,6,20,11 2,1,1,1,1,1 4 6 8 28 40 Scavenger 3 35 7 5 15 0 0 0 0 0 0 0 0 0 0 30 Animal RESIST_MAGIC RESIST_LIGHTNING Bottom,Middle 552 -MT_WSKELBW Skeleton skelbow\sklbw skelbow\white Always 128 567 true false 9,8,16,5,16,16 4,1,1,1,1,1 2 3 3 2 4 SkeletonRanged 0 15 12 1 2 0 0 0 0 0 0 0 0 0 0 0 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 110 -MT_TSKELBW Corpse Bow skelbow\sklbw skelbow\skelt Always 128 567 true false 9,8,16,5,16,16 4,1,1,1,1,1 2 4 5 8 16 SkeletonRanged 1 25 12 1 4 0 0 0 0 0 0 0 0 0 0 0 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 210 -MT_RSKELBW Burning Dead skelbow\sklbw Always 128 567 true false 9,8,16,5,16,16 2,1,1,1,1,1 3 5 7 10 24 SkeletonRanged 2 30 12 1 6 0 0 0 0 0 0 0 0 0 0 5 Undead IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 364 -MT_XSKELBW Horror skelbow\sklbw skelbow\black Always 128 567 true false 9,8,16,5,16,16 3,1,1,1,1,1 4 6 9 15 45 SkeletonRanged 3 35 12 2 9 0 0 0 0 0 0 0 0 0 0 15 Undead IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 594 -MT_WSKELSD Skeleton Captain skelsd\sklsr skelsd\white Always 128 575 true true 13,8,12,7,15,16 4,1,1,1,1,1 1 3 2 3 6 SkeletonMelee 0 20 8 2 7 0 0 0 0 0 0 0 0 0 0 10 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 90 -MT_TSKELSD Corpse Captain skelsd\sklsr skelsd\skelt Always 128 575 true false 13,8,12,7,15,16 4,1,1,1,1,1 2 4 4 12 20 SkeletonMelee 1 30 8 3 9 0 0 0 0 0 0 0 0 0 0 5 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 200 -MT_RSKELSD Burning Dead Captain skelsd\sklsr Always 128 575 true false 13,8,12,7,15,16 4,1,1,1,1,1 3 5 6 16 30 SkeletonMelee 2 35 8 4 10 0 0 0 0 0 0 0 0 0 0 15 Undead IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 393 -MT_XSKELSD Horror Captain skelsd\sklsr skelsd\black Always 128 575 true false 13,8,12,7,15,16 4,1,1,1,1,1 4 6 8 35 50 SkeletonMelee SEARCH 3 40 8 5 14 0 0 0 0 0 0 0 0 0 0 30 Undead IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 604 -MT_INVILORD Invisible Lord tsneak\tsneak Never 128 800 false false 13,13,15,11,16,0 2,1,1,1,1,1 19 20 14 278 278 SkeletonMelee SEARCH,CAN_OPEN_DOOR 3 65 8 16 30 0 0 0 0 0 0 0 0 0 0 60 Demon RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 2000 -MT_SNEAK Hidden sneak\sneak Retail 128 992 true false 16,8,12,8,24,15 2,1,1,1,1,1 2 5 5 8 24 Sneak HIDDEN 0 35 8 3 6 0 0 0 0 0 0 0 0 0 0 25 Demon Bottom,Middle 278 -MT_STALKER Stalker sneak\sneak sneak\sneakv2 Retail 128 992 true false 16,8,12,8,24,15 2,1,1,1,1,1 5 7 9 30 45 Sneak HIDDEN,SEARCH 1 40 8 8 16 0 0 0 0 0 0 0 0 0 0 30 Demon Bottom,Middle 630 -MT_UNSEEN Unseen sneak\sneak sneak\sneakv3 Retail 128 992 true false 16,8,12,8,24,15 2,1,1,1,1,1 6 8 11 35 50 Sneak HIDDEN,SEARCH 2 45 8 12 20 0 0 0 0 0 0 0 0 0 0 30 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 935 -MT_ILLWEAV Illusion Weaver sneak\sneak sneak\sneakv1 Retail 128 992 true false 16,8,12,8,24,15 2,1,1,1,1,1 8 10 13 40 60 Sneak HIDDEN,SEARCH 3 60 8 16 24 0 0 0 0 0 0 0 0 0 0 30 Demon RESIST_MAGIC,RESIST_FIRE IMMUNE_MAGIC,RESIST_FIRE Bottom,Middle 1500 -MT_LRDSAYTR Satyr Lord goatlord\goatl newsfx\satyr Never 160 800 false false 13,13,14,9,16,0 2,1,1,1,1,1 21 22 28 160 200 SkeletonMelee SEARCH 3 90 8 20 30 0 0 0 0 0 0 0 0 0 0 70 Animal RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle 2800 -MT_NGOATMC Flesh Clan goatmace\goat Retail 128 1030 true false 12,8,12,6,20,12 2,1,1,1,1,1 4 6 8 30 45 GoatMelee SEARCH,CAN_OPEN_DOOR 0 50 8 4 10 0 0 0 0 0 0 0 0 0 0 40 Demon Bottom,Middle 460 -MT_BGOATMC Stone Clan goatmace\goat goatmace\beige Retail 128 1030 true false 12,8,12,6,20,12 2,1,1,1,1,1 5 7 10 40 55 GoatMelee SEARCH,CAN_OPEN_DOOR 1 60 8 6 12 0 0 0 0 0 0 0 0 0 0 40 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 685 -MT_RGOATMC Fire Clan goatmace\goat goatmace\red Retail 128 1030 true false 12,8,12,6,20,12 2,1,1,1,1,1 6 8 12 50 65 GoatMelee SEARCH,CAN_OPEN_DOOR 2 70 8 8 16 0 0 0 0 0 0 0 0 0 0 45 Demon RESIST_FIRE IMMUNE_FIRE Bottom,Middle 906 -MT_GGOATMC Night Clan goatmace\goat goatmace\gray Retail 128 1030 true false 12,8,12,6,20,12 2,1,1,1,1,1 7 9 14 55 70 GoatMelee SEARCH,CAN_OPEN_DOOR 3 80 8 10 20 15 0 30 30 0 0 0 0 0 0 50 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 1190 -MT_FIEND Fiend bat\bat bat\red Always 96 364 false false 9,13,10,9,13,0 1,1,1,1,1,1 2 3 3 3 6 Bat 0 35 5 1 6 0 0 0 0 0 0 0 0 0 0 0 Animal Middle,Top None 102 -MT_BLINK Blink bat\bat Always 96 364 false false 9,13,10,9,13,0 1,1,1,1,1,1 3 5 7 12 28 Bat 1 45 5 1 8 0 0 0 0 0 0 0 0 0 0 15 Animal Middle,Top None 340 -MT_GLOOM Gloom bat\bat bat\grey Always 96 364 false false 9,13,10,9,13,0 1,1,1,1,1,1 4 6 9 28 36 Bat SEARCH 2 70 5 4 12 0 0 0 0 0 0 0 0 0 0 35 Animal RESIST_MAGIC RESIST_MAGIC Middle,Top None 509 -MT_FAMILIAR Familiar bat\bat bat\orange Always 96 364 false false 9,13,10,9,13,0 1,1,1,1,1,1 6 8 13 20 35 Bat SEARCH 3 50 5 4 16 0 0 0 0 0 0 0 0 0 0 35 Demon RESIST_MAGIC,IMMUNE_LIGHTNING RESIST_MAGIC,IMMUNE_LIGHTNING Middle,Top None 448 -MT_NGOATBW Flesh Clan goatbow\goatb Retail 128 1040 false false 12,8,16,6,20,0 3,1,1,1,1,1 4 6 8 20 35 GoatRanged CAN_OPEN_DOOR 0 35 13 1 7 0 0 0 0 0 0 0 0 0 0 35 Demon Bottom,Middle 448 -MT_BGOATBW Stone Clan goatbow\goatb goatbow\beige Retail 128 1040 false false 12,8,16,6,20,0 3,1,1,1,1,1 5 7 10 30 40 GoatRanged CAN_OPEN_DOOR 1 40 13 2 9 0 0 0 0 0 0 0 0 0 0 35 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 645 -MT_RGOATBW Fire Clan goatbow\goatb goatbow\red Retail 128 1040 false false 12,8,16,6,20,0 3,1,1,1,1,1 6 8 12 40 50 GoatRanged SEARCH,CAN_OPEN_DOOR 2 45 13 3 11 0 0 0 0 0 0 0 0 0 0 35 Demon RESIST_FIRE IMMUNE_FIRE Bottom,Middle 822 -MT_GGOATBW Night Clan goatbow\goatb goatbow\gray Retail 128 1040 false false 12,8,16,6,20,0 3,1,1,1,1,1 7 9 14 50 65 GoatRanged SEARCH,CAN_OPEN_DOOR 3 50 13 4 13 15 0 0 0 0 0 0 0 0 0 40 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 1092 -MT_NACID Acid Beast acid\acid Retail 128 716 true true 13,8,12,8,16,12 1,1,1,1,1,1 6 8 11 40 66 Acid 0 40 8 4 12 25 8 0 0 0 0 0 0 0 0 30 Animal IMMUNE_ACID IMMUNE_MAGIC,IMMUNE_ACID Bottom,Middle 846 -MT_RACID Poison Spitter acid\acid acid\acidblk Retail 128 716 true true 13,8,12,8,16,12 1,1,1,1,1,1 8 10 15 60 85 Acid 1 45 8 4 16 25 8 0 0 0 0 0 0 0 0 30 Animal IMMUNE_ACID IMMUNE_MAGIC,IMMUNE_ACID Bottom,Middle 1248 -MT_BACID Pit Beast acid\acid acid\acidb Retail 128 716 true true 13,8,12,8,16,12 1,1,1,1,1,1 10 12 21 80 110 Acid 2 55 8 8 18 35 8 0 0 0 0 0 0 0 0 35 Animal RESIST_MAGIC,IMMUNE_ACID IMMUNE_MAGIC,RESIST_LIGHTNING,IMMUNE_ACID Bottom,Middle 2060 -MT_XACID Lava Maw acid\acid acid\acidr Retail 128 716 true true 13,8,12,8,16,12 1,1,1,1,1,1 12 14 25 100 150 Acid 3 65 8 10 20 40 8 0 0 0 0 0 0 0 0 35 Animal RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_ACID IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_ACID Bottom,Middle 2940 -MT_SKING Skeleton King sking\sking skelaxe\white Never 160 1010 true true 8,6,16,6,16,6 2,1,1,1,1,2 4 4 9 140 140 SkeletonKing SEARCH,CAN_OPEN_DOOR 3 60 8 6 16 0 0 0 0 0 0 0 0 0 0 70 Undead IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top Uniq(SKCROWN) 570 -MT_CLEAVER The Butcher fatc\fatc Never 128 980 false false 10,8,12,6,16,0 1,1,1,1,1,1 1 1 1 320 320 Butcher 3 50 8 6 12 0 0 0 0 0 0 0 0 0 0 50 Demon RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle Uniq(CLEAVER) 710 -MT_FAT Overlord fat\fat Retail 128 1130 true false 8,10,15,6,16,10 4,1,1,1,1,1 5 7 10 60 80 Fat 0 55 8 6 12 0 0 0 0 0 0 0 0 0 0 55 Demon RESIST_FIRE Bottom,Middle 635 -MT_MUDMAN Mud Man fat\fat fat\blue Retail 128 1130 true false 8,10,15,6,16,10 4,1,1,1,1,1 7 9 14 100 125 Fat SEARCH 1 60 8 8 16 0 0 0 0 0 0 0 0 0 0 60 Demon IMMUNE_LIGHTNING Bottom,Middle 1165 -MT_TOAD Toad Demon fat\fat fat\fatb Retail 128 1130 true false 8,10,15,6,16,10 4,1,1,1,1,1 8 10 16 135 160 Fat SEARCH 2 70 8 8 16 40 0 8 20 0 0 0 0 0 0 65 Demon IMMUNE_MAGIC IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 1380 -MT_FLAYED Flayed One fat\fat fat\fatf Retail 128 1130 true false 8,10,15,6,16,10 4,1,1,1,1,1 10 12 20 160 200 Fat SEARCH 3 85 8 10 20 0 0 0 0 0 0 0 0 0 0 70 Demon RESIST_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 2058 -MT_WYRM Wyrm worm\worm Never 160 2420 false false 13,13,13,11,19,0 1,1,1,1,1,1 5 7 11 60 90 SkeletonMelee 0 40 8 4 10 0 0 0 0 0 0 0 0 0 0 25 Animal RESIST_MAGIC RESIST_MAGIC Bottom,Middle 660 -MT_CAVSLUG Cave Slug worm\worm Never 160 2420 false false 13,13,13,11,19,0 1,1,1,1,1,1 6 8 13 75 110 SkeletonMelee 1 50 8 6 13 0 0 0 0 0 0 0 0 0 0 30 Animal RESIST_MAGIC RESIST_MAGIC Bottom,Middle 994 -MT_DVLWYRM Devil Wyrm worm\worm Never 160 2420 false false 13,13,13,11,19,0 1,1,1,1,1,1 7 9 15 100 140 SkeletonMelee 2 55 8 8 16 0 0 0 0 0 0 0 0 0 0 30 Animal RESIST_MAGIC,RESIST_FIRE RESIST_MAGIC,RESIST_FIRE Bottom,Middle 1320 -MT_DEVOUR Devourer worm\worm Never 160 2420 false false 13,13,13,11,19,0 1,1,1,1,1,1 8 10 17 125 200 SkeletonMelee 3 60 8 10 20 0 0 0 0 0 0 0 0 0 0 35 Animal RESIST_MAGIC,RESIST_FIRE RESIST_MAGIC,RESIST_FIRE Bottom,Middle 1827 -MT_NMAGMA Magma Demon magma\magma Retail 128 1680 true true 8,10,14,7,18,18 2,1,1,1,1,1 8 9 13 50 70 Magma SEARCH,CAN_OPEN_DOOR 0 45 4 2 10 50 13 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 1076 -MT_YMAGMA Blood Stone magma\magma magma\yellow Retail 128 1680 true true 8,10,14,7,18,18 2,1,1,1,1,1 8 10 14 55 75 Magma SEARCH,CAN_OPEN_DOOR 1 50 4 2 12 50 14 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 1309 -MT_BMAGMA Hell Stone magma\magma magma\blue Retail 128 1680 true true 8,10,14,7,18,18 2,1,1,1,1,1 9 11 16 60 80 Magma SEARCH,CAN_OPEN_DOOR 2 60 4 2 20 60 14 0 0 0 0 0 0 0 0 50 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 1680 -MT_WMAGMA Lava Lord magma\magma magma\wierd Retail 128 1680 true true 8,10,14,7,18,18 2,1,1,1,1,1 9 11 18 70 85 Magma SEARCH,CAN_OPEN_DOOR 3 75 4 4 24 60 14 0 0 0 0 0 0 0 0 60 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 2124 -MT_HORNED Horned Demon rhino\rhino Retail 160 1630 true true 8,8,14,6,16,6 2,1,1,1,1,1 7 9 13 40 80 Rhino SEARCH,CAN_OPEN_DOOR 0 60 7 2 16 100 0 5 32 0 0 0 0 0 0 40 Animal RESIST_FIRE Bottom,Middle,Top 1172 -MT_MUDRUN Mud Runner rhino\rhino rhino\orange Retail 160 1630 true true 8,8,14,6,16,6 2,1,1,1,1,1 8 10 15 50 90 Rhino SEARCH,CAN_OPEN_DOOR 1 70 7 6 18 100 0 12 36 0 0 0 0 0 0 45 Animal RESIST_FIRE Bottom,Middle,Top 1404 -MT_FROSTC Frost Charger rhino\rhino rhino\blue Retail 160 1630 true true 8,8,14,6,16,6 2,1,1,1,1,1 9 11 17 60 100 Rhino SEARCH,CAN_OPEN_DOOR 2 80 7 8 20 100 0 20 40 0 0 0 0 0 0 50 Animal IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle,Top 1720 -MT_OBLORD Obsidian Lord rhino\rhino rhino\rhinob Retail 160 1630 true true 8,8,14,6,16,6 2,1,1,1,1,1 10 12 19 70 110 Rhino SEARCH,CAN_OPEN_DOOR 3 90 7 10 22 100 0 20 50 0 0 0 0 0 0 55 Animal IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 1809 -MT_BONEDMN oldboned demskel\demskl Never 128 1740 true true 10,8,20,6,24,16 3,1,1,1,1,1 24 24 12 70 70 Storm 0 60 8 6 14 12 0 0 0 0 0 0 0 0 0 50 Demon IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle,Top 1344 -MT_REDDTH Red Death thin\thin thin\thinv3 Never 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 8 10 16 96 96 Storm 1 75 5 10 20 0 0 0 0 0 0 0 0 0 0 60 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 2168 -MT_LTCHDMN Litch Demon thin\thin thin\thinv3 Never 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 9 11 18 110 110 Storm 2 80 5 10 24 0 0 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2736 -MT_UDEDBLRG Undead Balrog thin\thin thin\thinv3 Never 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 11 13 22 130 130 Storm 3 85 5 12 30 0 0 0 0 0 0 0 0 0 0 65 Demon IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 3575 -MT_INCIN Incinerator fireman\firem Never 128 1460 true false 14,19,20,8,14,23 1,1,1,1,1,1 21 22 16 30 45 FireMan 0 75 8 8 16 0 0 0 0 0 0 0 0 0 0 25 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 1888 -MT_FLAMLRD Flame Lord fireman\firem Never 128 1460 true false 14,19,20,8,14,23 1,1,1,1,1,1 22 23 18 40 55 FireMan 1 75 8 10 20 0 0 0 0 0 0 0 0 0 0 25 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 2250 -MT_DOOMFIRE Doom Fire fireman\firem Never 128 1460 true false 14,19,20,8,14,23 1,1,1,1,1,1 23 24 20 50 65 FireMan 2 80 8 12 24 0 0 0 0 0 0 0 0 0 0 30 Demon IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 2740 -MT_HELLBURN Hell Burner fireman\firem Never 128 1460 true false 14,19,20,8,14,23 1,1,1,1,1,1 24 24 22 60 80 FireMan 3 85 8 15 30 0 0 0 0 0 0 0 0 0 0 30 Demon IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 3355 -MT_STORM Red Storm thin\thin thin\thinv3 Retail 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 9 11 18 55 110 Storm SEARCH,CAN_OPEN_DOOR 0 80 5 8 18 75 8 4 16 0 0 0 0 0 0 30 Demon IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2160 -MT_RSTORM Storm Rider thin\thin Retail 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 10 12 20 60 120 Storm SEARCH,CAN_OPEN_DOOR 1 80 5 8 18 80 8 4 16 0 0 0 0 0 0 30 Demon RESIST_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2391 -MT_STORML Storm Lord thin\thin thin\thinv2 Retail 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 11 13 22 75 135 Storm SEARCH,CAN_OPEN_DOOR 2 85 5 12 24 75 8 4 16 0 0 0 0 0 0 35 Demon RESIST_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2775 -MT_MAEL Maelstrom thin\thin thin\thinv1 Retail 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 12 14 24 90 150 Storm SEARCH,CAN_OPEN_DOOR 3 90 5 12 28 75 8 4 16 0 0 0 0 0 0 40 Demon RESIST_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 3177 -MT_BIGFALL Devil Kin Brute bigfall\fallg newsfx\kbrute Never 128 800 true false 10,8,11,8,17,0 1,1,1,1,2,2 21 22 27 120 160 SkeletonMelee SEARCH,CAN_OPEN_DOOR 3 100 6 18 24 0 0 0 0 0 0 0 0 0 0 70 Animal RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 2400 -MT_WINGED Winged-Demon gargoyle\gargo Retail 160 1650 true false 14,14,14,10,18,14 1,1,1,1,1,2 5 7 9 45 60 Gargoyle CAN_OPEN_DOOR 0 50 7 10 16 0 0 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Middle,Top 662 -MT_GARGOYLE Gargoyle gargoyle\gargo gargoyle\gare Retail 160 1650 true false 14,14,14,10,18,14 1,1,1,1,1,2 7 9 13 60 90 Gargoyle CAN_OPEN_DOOR 1 65 7 10 16 0 0 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Middle,Top 1205 -MT_BLOODCLW Blood Claw gargoyle\gargo gargoyle\gargbr Retail 160 1650 true false 14,14,14,10,18,14 1,1,1,1,1,1 9 11 19 75 125 Gargoyle CAN_OPEN_DOOR 2 80 7 14 22 0 0 0 0 0 0 0 0 0 0 50 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Middle,Top 1873 -MT_DEATHW Death Wing gargoyle\gargo gargoyle\gargb Retail 160 1650 true false 14,14,14,10,18,14 1,1,1,1,1,1 10 12 23 90 150 Gargoyle CAN_OPEN_DOOR 3 95 7 16 28 0 0 0 0 0 0 0 0 0 0 60 Demon IMMUNE_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Middle,Top 2278 -MT_MEGA Slayer mega\mega Retail 160 2220 true true 6,7,14,1,24,5 3,1,1,1,2,1 10 12 20 120 140 Mega SEARCH,CAN_OPEN_DOOR 0 100 8 12 20 0 3 0 0 0 0 0 0 0 0 60 Demon RESIST_MAGIC,IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 2300 -MT_GUARD Guardian mega\mega mega\guard Retail 160 2220 true true 6,7,14,1,24,5 3,1,1,1,2,1 11 13 22 140 160 Mega SEARCH,CAN_OPEN_DOOR 1 110 8 14 22 0 3 0 0 0 0 0 0 0 0 65 Demon RESIST_MAGIC,IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 2714 -MT_VTEXLRD Vortex Lord mega\mega mega\vtexl Retail 160 2220 true true 6,7,14,1,24,5 3,1,1,1,2,1 12 14 24 160 180 Mega SEARCH,CAN_OPEN_DOOR 2 120 8 18 24 0 3 0 0 0 0 0 0 0 0 70 Demon RESIST_MAGIC,IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 3252 -MT_BALROG Balrog mega\mega mega\balr Retail 160 2220 true true 6,7,14,1,24,5 3,1,1,1,2,1 13 15 26 180 200 Mega SEARCH,CAN_OPEN_DOOR 3 130 8 22 30 0 3 0 0 0 0 0 0 0 0 75 Demon RESIST_MAGIC,IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 3643 -MT_NSNAKE Cave Viper snake\snake Retail 160 1270 false false 12,11,13,5,18,0 2,1,1,1,1,1 11 13 21 100 150 Snake SEARCH 0 90 8 8 20 0 0 0 0 0 0 0 0 0 0 60 Demon IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle,Top 2725 -MT_RSNAKE Fire Drake snake\snake snake\snakr Retail 160 1270 false false 12,11,13,5,18,0 2,1,1,1,1,1 12 14 23 120 170 Snake SEARCH 1 105 8 12 24 0 0 0 0 0 0 0 0 0 0 65 Demon IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 3139 -MT_BSNAKE Gold Viper snake\snake snake\snakg Retail 160 1270 false false 12,11,13,5,18,0 2,1,1,1,1,1 13 14 25 140 180 Snake SEARCH 2 120 8 15 26 0 0 0 0 0 0 0 0 0 0 70 Demon IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle,Top 3540 -MT_GSNAKE Azure Drake snake\snake snake\snakb Retail 160 1270 false false 12,11,13,5,18,0 2,1,1,1,1,1 15 16 27 160 200 Snake SEARCH 3 130 8 18 30 0 0 0 0 0 0 0 0 0 0 75 Demon RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 3791 -MT_NBLACK Black Knight black\black Retail 160 2120 false false 8,8,16,4,24,0 2,1,1,1,1,1 12 14 24 150 150 SkeletonMelee SEARCH 0 110 8 15 20 0 0 0 0 0 0 0 0 0 0 75 Demon RESIST_MAGIC,RESIST_LIGHTNING RESIST_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 3360 -MT_RTBLACK Doom Guard black\black black\blkkntrt Retail 160 2120 false false 8,8,16,4,24,0 2,1,1,1,1,1 13 15 26 165 165 SkeletonMelee SEARCH 0 130 8 18 25 0 0 0 0 0 0 0 0 0 0 75 Demon RESIST_MAGIC,RESIST_FIRE RESIST_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 3650 -MT_BTBLACK Steel Lord black\black black\blkkntbt Retail 160 2120 false false 8,8,16,4,24,0 2,1,1,1,1,1 14 16 28 180 180 SkeletonMelee SEARCH 1 120 8 20 30 0 0 0 0 0 0 0 0 0 0 80 Demon RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 4252 -MT_RBLACK Blood Knight black\black black\blkkntbe Retail 160 2120 false false 8,8,16,4,24,0 2,1,1,1,1,1 13 14 30 200 200 SkeletonMelee SEARCH 1 130 8 25 35 0 0 0 0 0 0 0 0 0 0 85 Demon IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 5130 -MT_UNRAV The Shredded unrav\unrav newsfx\shred Never 96 484 false false 10,10,12,5,16,0 1,1,1,1,1,1 17 18 23 70 90 SkeletonMelee 0 75 7 4 12 0 0 0 0 0 0 0 0 0 0 65 Undead RESIST_FIRE,RESIST_LIGHTNING RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 900 -MT_HOLOWONE Hollow One unrav\unrav acid\acid Never 96 484 false false 10,10,12,5,16,0 1,1,1,1,1,1 18 19 27 135 240 SkeletonMelee 1 75 7 12 24 0 0 0 0 0 0 0 0 0 0 75 Undead IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 4374 -MT_PAINMSTR Pain Master unrav\unrav acid\acid Never 96 484 false false 10,10,12,5,16,0 1,1,1,1,1,1 19 20 29 110 200 SkeletonMelee 2 80 7 16 30 0 0 0 0 0 0 0 0 0 0 80 Undead IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 5147 -MT_REALWEAV Reality Weaver unrav\unrav acid\acid Never 96 484 false false 10,10,12,5,16,0 1,1,1,1,1,1 20 20 30 135 240 SkeletonMelee 3 85 7 20 35 0 0 0 0 0 0 0 0 0 0 85 Undead RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle 5925 -MT_SUCCUBUS Succubus succ\scbs Retail 128 980 false false 14,8,16,7,24,0 1,1,1,1,1,1 12 14 24 120 150 Succubus CAN_OPEN_DOOR 0 100 10 1 20 0 0 0 0 0 0 0 0 0 0 60 Demon RESIST_MAGIC IMMUNE_MAGIC,RESIST_FIRE Bottom,Middle 3696 -MT_SNOWWICH Snow Witch succ\scbs succ\succb Retail 128 980 false false 14,8,16,7,24,0 1,1,1,1,1,1 13 15 26 135 175 Succubus CAN_OPEN_DOOR 1 110 10 1 24 0 0 0 0 0 0 0 0 0 0 65 Demon RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 4084 -MT_HLSPWN Hell Spawn succ\scbs succ\succrw Retail 128 980 false false 14,8,16,7,24,0 1,1,1,1,1,1 14 16 28 150 200 Succubus SEARCH,CAN_OPEN_DOOR 2 115 10 1 30 0 0 0 0 0 0 0 0 0 0 75 Demon RESIST_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 4480 -MT_SOLBRNR Soul Burner succ\scbs succ\succbw Retail 128 980 false false 14,8,16,7,24,0 1,1,1,1,1,1 15 16 30 140 225 Succubus SEARCH,CAN_OPEN_DOOR 3 120 10 1 35 0 0 0 0 0 0 0 0 0 0 85 Demon RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle 4644 -MT_COUNSLR Counselor mage\mage Retail 128 2000 true false 12,1,20,8,28,20 1,1,1,1,1,1 13 14 25 70 70 Counselor CAN_OPEN_DOOR 0 90 8 8 20 0 0 0 0 0 0 0 0 0 0 0 Demon RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 4070 -MT_MAGISTR Magistrate mage\mage mage\cnselg Retail 128 2000 true false 12,1,20,8,28,20 1,1,1,1,1,1 14 15 27 85 85 Counselor CAN_OPEN_DOOR 1 100 8 10 24 0 0 0 0 0 0 0 0 0 0 0 Demon RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 4478 -MT_CABALIST Cabalist mage\mage mage\cnselgd Retail 128 2000 true false 12,1,20,8,28,20 1,1,1,1,1,1 15 16 29 120 120 Counselor CAN_OPEN_DOOR 2 110 8 14 30 0 0 0 0 0 0 0 0 0 0 0 Demon RESIST_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 4929 -MT_ADVOCATE Advocate mage\mage mage\cnselbk Retail 128 2000 true false 12,1,20,8,28,20 1,1,1,1,1,1 16 16 30 145 145 Counselor CAN_OPEN_DOOR 3 120 8 15 25 0 0 0 0 0 0 0 0 0 0 0 Demon IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 4968 -MT_GOLEM Golem golem\golem golem\golm Never 96 386 true false 0,16,12,0,12,20 1,1,1,1,1,1 1 1 12 1 1 Golem CAN_OPEN_DOOR 0 0 7 1 1 0 0 0 0 0 0 0 0 0 0 1 Demon 0 -MT_DIABLO The Dark Lord diablo\diablo Never 160 2000 true true 16,6,16,6,16,16 1,1,1,1,1,1 26 26 30 1666 1666 Diablo KNOCKBACK,SEARCH,CAN_OPEN_DOOR 3 220 4 30 60 0 11 0 0 0 0 0 0 0 0 90 Demon IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 31666 -MT_DARKMAGE The Arch-Litch Malignus darkmage\dmage darkmage\dmag Never 128 1060 true false 6,1,21,6,23,18 1,1,1,1,1,1 21 21 30 160 160 Counselor CAN_OPEN_DOOR 3 120 8 20 40 0 0 0 0 0 0 0 0 0 0 70 Demon RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 4968 -MT_HELLBOAR Hellboar fork\fork newsfx\hboar Retail 188 800 false false 10,10,15,6,16,0 2,1,1,1,1,1 17 18 23 80 100 SkeletonMelee KNOCKBACK,SEARCH 2 70 7 16 24 0 0 0 0 0 0 0 0 0 0 60 Demon RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 750 -MT_STINGER Stinger scorp\scorp newsfx\stingr Retail 64 305 false false 10,10,12,6,15,0 2,1,1,1,1,1 17 18 22 30 40 SkeletonMelee 3 85 8 1 20 0 0 0 0 0 0 0 0 0 0 50 Animal RESIST_LIGHTNING Bottom 500 -MT_PSYCHORB Psychorb eye\eye newsfx\psyco Retail 156 800 false false 12,13,13,7,21,0 2,1,1,1,1,1 17 18 22 20 30 Psychorb 3 80 8 10 10 0 0 0 0 0 0 0 0 0 0 40 Animal RESIST_FIRE Middle,Top 450 -MT_ARACHNON Arachnon spider\spider newsfx\slord Retail 148 800 false false 12,10,15,6,20,0 2,1,1,1,1,1 17 18 22 60 80 SkeletonMelee SEARCH 3 50 8 5 15 0 0 0 0 0 0 0 0 0 0 50 Animal RESIST_LIGHTNING Bottom,Middle,Top 500 -MT_FELLTWIN Felltwin tsneak\tsneak newsfx\ftwin Retail 128 800 false false 13,13,15,11,16,0 2,1,1,1,1,1 17 18 22 50 70 SkeletonMelee SEARCH,CAN_OPEN_DOOR 3 70 8 10 18 0 0 0 0 0 0 0 0 0 0 50 Demon RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 600 -MT_HORKSPWN Hork Spawn spawn\spawn newsfx\hspawn Retail 164 520 false true 15,12,14,11,14,0 1,1,1,1,1,1 18 19 22 30 30 SkeletonMelee 3 60 8 10 25 0 0 0 0 0 0 0 0 0 0 25 Demon RESIST_MAGIC RESIST_MAGIC Bottom,Middle 250 -MT_VENMTAIL Venomtail wscorp\wscorp newsfx\stingr Retail 86 305 false false 10,10,12,6,15,0 2,1,1,1,1,1 19 20 24 40 50 SkeletonMelee 3 85 8 1 30 0 0 0 0 0 0 0 0 0 0 60 Animal RESIST_LIGHTNING IMMUNE_LIGHTNING Bottom 1000 -MT_NECRMORB Necromorb eye2\eye2 newsfx\psyco Retail 140 800 false false 12,13,13,7,21,0 2,1,1,1,1,1 19 20 24 30 40 Necromorb 3 80 8 20 20 0 0 0 0 0 0 0 0 0 0 50 Animal RESIST_FIRE IMMUNE_FIRE,RESIST_LIGHTNING Middle,Top 1100 -MT_SPIDLORD Spider Lord bspidr\bspidr newsfx\slord Retail 148 800 true true 12,10,15,6,20,10 2,1,1,1,1,1 19 20 24 80 100 Acid SEARCH 3 60 8 8 20 75 8 10 10 0 0 0 0 0 0 60 Animal RESIST_LIGHTNING RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 1250 -MT_LASHWORM Lashworm clasp\clasp newsfx\lworm Retail 176 800 false false 10,12,15,6,16,0 1,1,1,1,1,1 19 20 20 30 30 SkeletonMelee 3 90 8 12 20 0 0 0 0 0 0 0 0 0 0 50 Animal RESIST_FIRE Bottom,Middle 600 -MT_TORCHANT Torchant antworm\worm newsfx\tchant Retail 192 800 false false 14,12,12,6,20,0 2,1,1,1,1,1 19 20 22 60 80 Torchant 3 75 8 20 30 0 0 0 0 0 0 0 0 0 0 70 Animal IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 1250 -MT_HORKDMN Hork Demon horkd\horkd newsfx\hdemon Never 138 800 true true 15,8,16,6,16,9 2,1,1,1,1,2 19 19 27 120 160 SkeletonMelee 3 60 8 20 35 80 8 0 0 0 0 0 0 0 0 80 Demon RESIST_LIGHTNING RESIST_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2000 -MT_DEFILER Hell Bug hellbug\hellbg newsfx\defile Never 198 800 true true 8,8,14,6,14,12 1,1,1,1,1,1 20 20 30 240 240 SkeletonMelee SEARCH 3 110 8 20 30 90 8 50 60 0 0 0 0 0 0 80 Demon RESIST_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 5000 -MT_GRAVEDIG Gravedigger gravdg\gravdg newsfx\gdiggr Retail 124 800 true true 24,24,12,6,16,16 2,1,1,1,1,1 21 21 26 120 240 Scavenger CAN_OPEN_DOOR 3 80 6 2 12 0 0 0 0 0 0 0 0 0 0 20 Undead IMMUNE_LIGHTNING RESIST_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle 2000 -MT_TOMBRAT Tomb Rat rat\rat newsfx\tmbrat Retail 104 550 false false 11,8,12,6,20,0 2,1,1,1,1,1 21 22 24 80 120 SkeletonMelee 3 120 8 12 25 0 0 0 0 0 0 0 0 0 0 30 Animal RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 1800 -MT_FIREBAT Firebat hellbat\helbat newsfx\helbat Retail 96 550 false false 18,16,14,6,18,11 2,1,1,1,1,1 21 22 24 60 80 FireBat 3 100 8 15 20 0 0 0 0 0 0 0 0 0 0 70 Animal IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 2400 -MT_SKLWING Skullwing demskel\demskl newsfx\swing skelaxe\skelt Retail 128 1740 true false 10,8,20,6,24,16 3,1,1,1,1,1 21 22 27 70 70 SkeletonMelee 0 75 7 15 20 75 9 15 20 0 0 0 0 0 0 80 Undead RESIST_FIRE,RESIST_LIGHTNING RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 3000 -MT_LICH Lich lich\lich newsfx\lich Retail 96 800 false true 12,10,10,7,21,0 2,1,1,1,2,1 21 22 25 80 100 Lich 3 100 8 15 20 0 0 0 0 0 0 0 0 0 0 60 Undead RESIST_LIGHTNING RESIST_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle 3000 -MT_CRYPTDMN Crypt Demon bubba\bubba newsfx\crypt Retail 154 800 false true 8,18,12,8,21,0 3,1,1,1,1,1 22 23 28 200 240 SkeletonMelee 3 100 8 20 40 0 0 0 0 0 0 0 0 0 0 85 Demon IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 3200 -MT_HELLBAT Hellbat hellbat2\bhelbt newsfx\helbat Retail 96 550 true false 18,16,14,6,18,11 2,1,1,1,1,1 23 24 29 100 140 Torchant 3 110 8 30 30 0 0 0 0 0 0 0 0 0 0 80 Demon RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 3600 -MT_BONEDEMN Bone Demon demskel\demskl newsfx\swing Retail 128 1740 true true 10,8,20,6,24,16 3,1,1,1,1,1 23 24 30 240 280 BoneDemon 0 100 8 40 50 160 12 50 50 0 0 0 0 0 0 50 Undead IMMUNE_FIRE,IMMUNE_LIGHTNING IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 5000 -MT_ARCHLICH Arch Lich lich2\lich2 newsfx\lich Retail 136 800 false true 12,10,10,7,21,0 2,1,1,1,2,1 23 24 30 180 200 ArchLich 3 120 8 30 30 0 0 0 0 0 0 0 0 0 0 75 Undead RESIST_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle 4000 -MT_BICLOPS Biclops byclps\byclps newsfx\biclop Retail 180 800 false false 10,11,16,6,16,0 2,1,1,1,2,1 23 24 30 200 240 SkeletonMelee KNOCKBACK,CAN_OPEN_DOOR 3 90 8 40 50 0 0 0 0 0 0 0 0 0 0 80 Demon RESIST_LIGHTNING RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 4000 -MT_FLESTHNG Flesh Thing flesh\flesh newsfx\flesht Retail 164 800 false true 15,24,15,6,16,0 1,1,1,1,1,1 23 24 28 300 400 SkeletonMelee 3 150 8 12 18 0 0 0 0 0 0 0 0 0 0 70 Demon RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 4000 -MT_REAPER Reaper reaper\reap newsfx\reaper Retail 180 800 false false 12,10,14,6,16,0 2,1,1,1,1,1 23 24 30 260 300 SkeletonMelee 3 120 8 30 35 0 0 0 0 0 0 0 0 0 0 90 Demon IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle 6000 -MT_NAKRUL Na-Krul nkr\nkr newsfx\nakrul Never 226 1200 true true 2,6,16,3,16,16 1,1,1,1,1,1 31 31 40 1332 1332 SkeletonMelee KNOCKBACK,SEARCH,CAN_OPEN_DOOR 3 150 7 40 50 150 10 40 50 0 0 0 0 0 0 125 Demon IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 13333 +_monster_id name assetsSuffix soundSuffix trnFile availability width image hasSpecial hasSpecialSound frames[6] rate[6] minDunLvl maxDunLvl level hitPointsMinimum hitPointsMaximum ai abilityFlags intelligence toHit animFrameNum minDamage maxDamage toHitSpecial animFrameNumSpecial minDamageSpecial maxDamageSpecial reducePlayerStrength reducePlayerMagic reducePlayerDexterity reducePlayerVitality reducePlayerMaxHP reducePlayerMaxMana armorClass monsterClass resistance resistanceHell selectionRegion treasure exp shadowRule[6] +MT_NZOMBIE Zombie zombie\zombie Always 128 799 false false 11,24,12,6,16,0 4,1,1,1,1,1 1 2 1 4 7 Zombie 0 10 8 2 5 0 0 0 0 0 0 0 0 0 0 5 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 54 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_BZOMBIE Ghoul zombie\zombie zombie\bluered Always 128 799 false false 11,24,12,6,16,0 4,1,1,1,1,1 2 3 2 7 11 Zombie 1 10 8 3 10 0 0 0 0 0 0 0 0 0 0 10 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 58 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_GZOMBIE Rotting Carcass zombie\zombie zombie\grey Always 128 799 false false 11,24,12,6,16,0 4,1,1,1,1,1 2 4 4 15 25 Zombie 2 25 8 5 15 0 0 0 0 0 0 0 0 0 0 15 Undead IMMUNE_MAGIC IMMUNE_MAGIC,RESIST_FIRE Bottom,Middle 136 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_YZOMBIE Black Death zombie\zombie zombie\yellow Always 128 799 false false 11,24,12,6,16,0 4,1,1,1,1,1 3 5 6 25 40 Zombie 3 30 8 6 22 0 0 0 0 0 0 0 0 1 0 20 Undead IMMUNE_MAGIC IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 240 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_RFALLSP Fallen One falspear\phall falspear\fallent Always 128 543 true true 11,11,13,11,18,13 3,1,1,1,1,1 1 2 1 1 4 Fallen 0 15 7 1 3 0 5 0 0 0 0 0 0 0 0 0 Animal Bottom,Middle 46 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_DFALLSP Carver falspear\phall falspear\dark Always 128 543 true true 11,11,13,11,18,13 3,1,1,1,1,1 2 3 3 4 8 Fallen 2 20 7 2 5 0 5 0 0 0 0 0 0 0 0 5 Animal Bottom,Middle 80 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_YFALLSP Devil Kin falspear\phall Always 128 543 true true 11,11,13,11,18,13 3,1,1,1,1,1 2 4 5 12 24 Fallen 2 25 7 3 7 0 5 0 0 0 0 0 0 0 0 10 Animal RESIST_FIRE Bottom,Middle 155 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_BFALLSP Dark One falspear\phall falspear\blue Always 128 543 true true 11,11,13,11,18,13 3,1,1,1,1,1 3 5 7 20 36 Fallen 3 30 7 4 8 0 5 0 0 0 0 0 0 0 0 15 Animal RESIST_LIGHTNING Bottom,Middle 255 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_WSKELAX Skeleton skelaxe\sklax skelaxe\white Always 128 553 true false 12,8,13,6,17,16 5,1,1,1,1,1 1 2 1 2 4 SkeletonMelee 0 20 8 1 4 0 0 0 0 0 0 0 0 0 0 0 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 64 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_TSKELAX Corpse Axe skelaxe\sklax skelaxe\skelt Always 128 553 true false 12,8,13,6,17,16 4,1,1,1,1,1 2 3 2 4 7 SkeletonMelee 1 25 8 3 5 0 0 0 0 0 0 0 0 0 0 0 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 68 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_RSKELAX Burning Dead skelaxe\sklax Always 128 553 true false 12,8,13,6,17,16 2,1,1,1,1,1 2 4 4 8 12 SkeletonMelee 2 30 8 3 7 0 0 0 0 0 0 0 0 0 0 5 Undead IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 154 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_XSKELAX Horror skelaxe\sklax skelaxe\black Always 128 553 true false 12,8,13,6,17,16 3,1,1,1,1,1 3 5 6 12 20 SkeletonMelee 3 35 8 4 9 0 0 0 0 0 0 0 0 0 0 15 Undead IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 264 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_RFALLSD Fallen One falsword\fall falsword\fallent Always 128 623 true true 12,12,13,11,14,15 3,1,1,1,1,1 1 2 1 2 5 Fallen 0 15 8 1 4 0 5 0 0 0 0 0 0 0 0 10 Animal Bottom,Middle 52 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_DFALLSD Carver falsword\fall falsword\dark Always 128 623 true true 12,12,13,11,14,15 3,1,1,1,1,1 2 3 3 5 9 Fallen 1 20 8 2 7 0 5 0 0 0 0 0 0 0 0 15 Animal Bottom,Middle 90 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_YFALLSD Devil Kin falsword\fall Always 128 623 true true 12,12,13,11,14,15 3,1,1,1,1,1 2 4 5 16 24 Fallen 2 25 8 4 10 0 5 0 0 0 0 0 0 0 0 20 Animal RESIST_FIRE Bottom,Middle 180 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_BFALLSD Dark One falsword\fall falsword\blue Always 128 623 true true 12,12,13,11,14,15 3,1,1,1,1,1 3 5 7 24 36 Fallen 3 30 8 4 12 0 5 0 0 0 0 0 0 0 0 25 Animal RESIST_LIGHTNING Bottom,Middle 280 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_NSCAV Scavenger scav\scav Always 128 410 true false 12,8,12,6,20,11 2,1,1,1,1,1 1 3 2 3 6 Scavenger 0 20 7 1 5 0 0 0 0 0 0 0 0 0 0 10 Animal RESIST_FIRE Bottom,Middle 80 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_BSCAV Plague Eater scav\scav scav\scavbr Always 128 410 true false 12,8,12,6,20,11 2,1,1,1,1,1 2 4 4 12 24 Scavenger 1 30 7 1 8 0 0 0 0 0 0 0 0 0 0 20 Animal RESIST_LIGHTNING Bottom,Middle 188 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_WSCAV Shadow Beast scav\scav scav\scavbe Always 128 410 true false 12,8,12,6,20,11 2,1,1,1,1,1 3 5 6 24 36 Scavenger 2 35 7 3 12 0 0 0 0 0 0 0 0 0 0 25 Animal RESIST_FIRE Bottom,Middle 375 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_YSCAV Bone Gasher scav\scav scav\scavw Always 128 410 true false 12,8,12,6,20,11 2,1,1,1,1,1 4 6 8 28 40 Scavenger 3 35 7 5 15 0 0 0 0 0 0 0 0 0 0 30 Animal RESIST_MAGIC RESIST_LIGHTNING Bottom,Middle 552 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_WSKELBW Skeleton skelbow\sklbw skelbow\white Always 128 567 true false 9,8,16,5,16,16 4,1,1,1,1,1 2 3 3 2 4 SkeletonRanged 0 15 12 1 2 0 0 0 0 0 0 0 0 0 0 0 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 110 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_TSKELBW Corpse Bow skelbow\sklbw skelbow\skelt Always 128 567 true false 9,8,16,5,16,16 4,1,1,1,1,1 2 4 5 8 16 SkeletonRanged 1 25 12 1 4 0 0 0 0 0 0 0 0 0 0 0 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 210 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_RSKELBW Burning Dead skelbow\sklbw Always 128 567 true false 9,8,16,5,16,16 2,1,1,1,1,1 3 5 7 10 24 SkeletonRanged 2 30 12 1 6 0 0 0 0 0 0 0 0 0 0 5 Undead IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 364 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_XSKELBW Horror skelbow\sklbw skelbow\black Always 128 567 true false 9,8,16,5,16,16 3,1,1,1,1,1 4 6 9 15 45 SkeletonRanged 3 35 12 2 9 0 0 0 0 0 0 0 0 0 0 15 Undead IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 594 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_WSKELSD Skeleton Captain skelsd\sklsr skelsd\white Always 128 575 true true 13,8,12,7,15,16 4,1,1,1,1,1 1 3 2 3 6 SkeletonMelee 0 20 8 2 7 0 0 0 0 0 0 0 0 0 0 10 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 90 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_TSKELSD Corpse Captain skelsd\sklsr skelsd\skelt Always 128 575 true false 13,8,12,7,15,16 4,1,1,1,1,1 2 4 4 12 20 SkeletonMelee 1 30 8 3 9 0 0 0 0 0 0 0 0 0 0 5 Undead IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle 200 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_RSKELSD Burning Dead Captain skelsd\sklsr Always 128 575 true false 13,8,12,7,15,16 4,1,1,1,1,1 3 5 6 16 30 SkeletonMelee 2 35 8 4 10 0 0 0 0 0 0 0 0 0 0 15 Undead IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 393 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_XSKELSD Horror Captain skelsd\sklsr skelsd\black Always 128 575 true false 13,8,12,7,15,16 4,1,1,1,1,1 4 6 8 35 50 SkeletonMelee SEARCH 3 40 8 5 14 0 0 0 0 0 0 0 0 0 0 30 Undead IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 604 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_INVILORD Invisible Lord tsneak\tsneak Never 128 800 false false 13,13,15,11,16,0 2,1,1,1,1,1 19 20 14 278 278 SkeletonMelee SEARCH,CAN_OPEN_DOOR 3 65 8 16 30 0 0 0 0 0 0 0 0 0 0 60 Demon RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 2000 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_SNEAK Hidden sneak\sneak Retail 128 992 true false 16,8,12,8,24,15 2,1,1,1,1,1 2 5 5 8 24 Sneak HIDDEN 0 35 8 3 6 0 0 0 0 0 0 0 0 0 0 25 Demon Bottom,Middle 278 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_STALKER Stalker sneak\sneak sneak\sneakv2 Retail 128 992 true false 16,8,12,8,24,15 2,1,1,1,1,1 5 7 9 30 45 Sneak HIDDEN,SEARCH 1 40 8 8 16 0 0 0 0 0 0 0 0 0 0 30 Demon Bottom,Middle 630 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_UNSEEN Unseen sneak\sneak sneak\sneakv3 Retail 128 992 true false 16,8,12,8,24,15 2,1,1,1,1,1 6 8 11 35 50 Sneak HIDDEN,SEARCH 2 45 8 12 20 0 0 0 0 0 0 0 0 0 0 30 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 935 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_ILLWEAV Illusion Weaver sneak\sneak sneak\sneakv1 Retail 128 992 true false 16,8,12,8,24,15 2,1,1,1,1,1 8 10 13 40 60 Sneak HIDDEN,SEARCH 3 60 8 16 24 0 0 0 0 0 0 0 0 0 0 30 Demon RESIST_MAGIC,RESIST_FIRE IMMUNE_MAGIC,RESIST_FIRE Bottom,Middle 1500 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_LRDSAYTR Satyr Lord goatlord\goatl newsfx\satyr Never 160 800 false false 13,13,14,9,16,0 2,1,1,1,1,1 21 22 28 160 200 SkeletonMelee SEARCH 3 90 8 20 30 0 0 0 0 0 0 0 0 0 0 70 Animal RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle 2800 FirstFrame,FirstFrame,FirstFrame,FirstFrame,None,None +MT_NGOATMC Flesh Clan goatmace\goat Retail 128 1030 true false 12,8,12,6,20,12 2,1,1,1,1,1 4 6 8 30 45 GoatMelee SEARCH,CAN_OPEN_DOOR 0 50 8 4 10 0 0 0 0 0 0 0 0 0 0 40 Demon Bottom,Middle 460 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_BGOATMC Stone Clan goatmace\goat goatmace\beige Retail 128 1030 true false 12,8,12,6,20,12 2,1,1,1,1,1 5 7 10 40 55 GoatMelee SEARCH,CAN_OPEN_DOOR 1 60 8 6 12 0 0 0 0 0 0 0 0 0 0 40 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 685 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_RGOATMC Fire Clan goatmace\goat goatmace\red Retail 128 1030 true false 12,8,12,6,20,12 2,1,1,1,1,1 6 8 12 50 65 GoatMelee SEARCH,CAN_OPEN_DOOR 2 70 8 8 16 0 0 0 0 0 0 0 0 0 0 45 Demon RESIST_FIRE IMMUNE_FIRE Bottom,Middle 906 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_GGOATMC Night Clan goatmace\goat goatmace\gray Retail 128 1030 true false 12,8,12,6,20,12 2,1,1,1,1,1 7 9 14 55 70 GoatMelee SEARCH,CAN_OPEN_DOOR 3 80 8 10 20 15 0 30 30 0 0 0 0 0 0 50 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 1190 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_FIEND Fiend bat\bat bat\red Always 96 364 false false 9,13,10,9,13,0 1,1,1,1,1,1 2 3 3 3 6 Bat 0 35 5 1 6 0 0 0 0 0 0 0 0 0 0 0 Animal Middle,Top None 102 FirstFrame+15,FirstFrame+15,FirstFrame+15,FirstFrame+15,FirstFrame,None +MT_BLINK Blink bat\bat Always 96 364 false false 9,13,10,9,13,0 1,1,1,1,1,1 3 5 7 12 28 Bat 1 45 5 1 8 0 0 0 0 0 0 0 0 0 0 15 Animal Middle,Top None 340 FirstFrame+15,FirstFrame+15,FirstFrame+15,FirstFrame+15,FirstFrame,None +MT_GLOOM Gloom bat\bat bat\grey Always 96 364 false false 9,13,10,9,13,0 1,1,1,1,1,1 4 6 9 28 36 Bat SEARCH 2 70 5 4 12 0 0 0 0 0 0 0 0 0 0 35 Animal RESIST_MAGIC RESIST_MAGIC Middle,Top None 509 FirstFrame+15,FirstFrame+15,FirstFrame+15,FirstFrame+15,FirstFrame,None +MT_FAMILIAR Familiar bat\bat bat\orange Always 96 364 false false 9,13,10,9,13,0 1,1,1,1,1,1 6 8 13 20 35 Bat SEARCH 3 50 5 4 16 0 0 0 0 0 0 0 0 0 0 35 Demon RESIST_MAGIC,IMMUNE_LIGHTNING RESIST_MAGIC,IMMUNE_LIGHTNING Middle,Top None 448 FirstFrame+15,FirstFrame+15,FirstFrame+15,FirstFrame+15,FirstFrame,None +MT_NGOATBW Flesh Clan goatbow\goatb Retail 128 1040 false false 12,8,16,6,20,0 3,1,1,1,1,1 4 6 8 20 35 GoatRanged CAN_OPEN_DOOR 0 35 13 1 7 0 0 0 0 0 0 0 0 0 0 35 Demon Bottom,Middle 448 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_BGOATBW Stone Clan goatbow\goatb goatbow\beige Retail 128 1040 false false 12,8,16,6,20,0 3,1,1,1,1,1 5 7 10 30 40 GoatRanged CAN_OPEN_DOOR 1 40 13 2 9 0 0 0 0 0 0 0 0 0 0 35 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 645 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_RGOATBW Fire Clan goatbow\goatb goatbow\red Retail 128 1040 false false 12,8,16,6,20,0 3,1,1,1,1,1 6 8 12 40 50 GoatRanged SEARCH,CAN_OPEN_DOOR 2 45 13 3 11 0 0 0 0 0 0 0 0 0 0 35 Demon RESIST_FIRE IMMUNE_FIRE Bottom,Middle 822 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_GGOATBW Night Clan goatbow\goatb goatbow\gray Retail 128 1040 false false 12,8,16,6,20,0 3,1,1,1,1,1 7 9 14 50 65 GoatRanged SEARCH,CAN_OPEN_DOOR 3 50 13 4 13 15 0 0 0 0 0 0 0 0 0 40 Demon RESIST_MAGIC IMMUNE_MAGIC Bottom,Middle 1092 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_NACID Acid Beast acid\acid Retail 128 716 true true 13,8,12,8,16,12 1,1,1,1,1,1 6 8 11 40 66 Acid 0 40 8 4 12 25 8 0 0 0 0 0 0 0 0 30 Animal IMMUNE_ACID IMMUNE_MAGIC,IMMUNE_ACID Bottom,Middle 846 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_RACID Poison Spitter acid\acid acid\acidblk Retail 128 716 true true 13,8,12,8,16,12 1,1,1,1,1,1 8 10 15 60 85 Acid 1 45 8 4 16 25 8 0 0 0 0 0 0 0 0 30 Animal IMMUNE_ACID IMMUNE_MAGIC,IMMUNE_ACID Bottom,Middle 1248 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_BACID Pit Beast acid\acid acid\acidb Retail 128 716 true true 13,8,12,8,16,12 1,1,1,1,1,1 10 12 21 80 110 Acid 2 55 8 8 18 35 8 0 0 0 0 0 0 0 0 35 Animal RESIST_MAGIC,IMMUNE_ACID IMMUNE_MAGIC,RESIST_LIGHTNING,IMMUNE_ACID Bottom,Middle 2060 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_XACID Lava Maw acid\acid acid\acidr Retail 128 716 true true 13,8,12,8,16,12 1,1,1,1,1,1 12 14 25 100 150 Acid 3 65 8 10 20 40 8 0 0 0 0 0 0 0 0 35 Animal RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_ACID IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_ACID Bottom,Middle 2940 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_SKING Skeleton King sking\sking skelaxe\white Never 160 1010 true true 8,6,16,6,16,6 2,1,1,1,1,2 4 4 9 140 140 SkeletonKing SEARCH,CAN_OPEN_DOOR 3 60 8 6 16 0 0 0 0 0 0 0 0 0 0 70 Undead IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top Uniq(SKCROWN) 570 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_CLEAVER The Butcher fatc\fatc Never 128 980 false false 10,8,12,6,16,0 1,1,1,1,1,1 1 1 1 320 320 Butcher 3 50 8 6 12 0 0 0 0 0 0 0 0 0 0 50 Demon RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle Uniq(CLEAVER) 710 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_FAT Overlord fat\fat Retail 128 1130 true false 8,10,15,6,16,10 4,1,1,1,1,1 5 7 10 60 80 Fat 0 55 8 6 12 0 0 0 0 0 0 0 0 0 0 55 Demon RESIST_FIRE Bottom,Middle 635 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_MUDMAN Mud Man fat\fat fat\blue Retail 128 1130 true false 8,10,15,6,16,10 4,1,1,1,1,1 7 9 14 100 125 Fat SEARCH 1 60 8 8 16 0 0 0 0 0 0 0 0 0 0 60 Demon IMMUNE_LIGHTNING Bottom,Middle 1165 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_TOAD Toad Demon fat\fat fat\fatb Retail 128 1130 true false 8,10,15,6,16,10 4,1,1,1,1,1 8 10 16 135 160 Fat SEARCH 2 70 8 8 16 40 0 8 20 0 0 0 0 0 0 65 Demon IMMUNE_MAGIC IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 1380 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_FLAYED Flayed One fat\fat fat\fatf Retail 128 1130 true false 8,10,15,6,16,10 4,1,1,1,1,1 10 12 20 160 200 Fat SEARCH 3 85 8 10 20 0 0 0 0 0 0 0 0 0 0 70 Demon RESIST_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 2058 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_WYRM Wyrm worm\worm Never 160 2420 false false 13,13,13,11,19,0 1,1,1,1,1,1 5 7 11 60 90 SkeletonMelee 0 40 8 4 10 0 0 0 0 0 0 0 0 0 0 25 Animal RESIST_MAGIC RESIST_MAGIC Bottom,Middle 660 +MT_CAVSLUG Cave Slug worm\worm Never 160 2420 false false 13,13,13,11,19,0 1,1,1,1,1,1 6 8 13 75 110 SkeletonMelee 1 50 8 6 13 0 0 0 0 0 0 0 0 0 0 30 Animal RESIST_MAGIC RESIST_MAGIC Bottom,Middle 994 +MT_DVLWYRM Devil Wyrm worm\worm Never 160 2420 false false 13,13,13,11,19,0 1,1,1,1,1,1 7 9 15 100 140 SkeletonMelee 2 55 8 8 16 0 0 0 0 0 0 0 0 0 0 30 Animal RESIST_MAGIC,RESIST_FIRE RESIST_MAGIC,RESIST_FIRE Bottom,Middle 1320 +MT_DEVOUR Devourer worm\worm Never 160 2420 false false 13,13,13,11,19,0 1,1,1,1,1,1 8 10 17 125 200 SkeletonMelee 3 60 8 10 20 0 0 0 0 0 0 0 0 0 0 35 Animal RESIST_MAGIC,RESIST_FIRE RESIST_MAGIC,RESIST_FIRE Bottom,Middle 1827 +MT_NMAGMA Magma Demon magma\magma Retail 128 1680 true true 8,10,14,7,18,18 2,1,1,1,1,1 8 9 13 50 70 Magma SEARCH,CAN_OPEN_DOOR 0 45 4 2 10 50 13 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 1076 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_YMAGMA Blood Stone magma\magma magma\yellow Retail 128 1680 true true 8,10,14,7,18,18 2,1,1,1,1,1 8 10 14 55 75 Magma SEARCH,CAN_OPEN_DOOR 1 50 4 2 12 50 14 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 1309 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_BMAGMA Hell Stone magma\magma magma\blue Retail 128 1680 true true 8,10,14,7,18,18 2,1,1,1,1,1 9 11 16 60 80 Magma SEARCH,CAN_OPEN_DOOR 2 60 4 2 20 60 14 0 0 0 0 0 0 0 0 50 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 1680 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_WMAGMA Lava Lord magma\magma magma\wierd Retail 128 1680 true true 8,10,14,7,18,18 2,1,1,1,1,1 9 11 18 70 85 Magma SEARCH,CAN_OPEN_DOOR 3 75 4 4 24 60 14 0 0 0 0 0 0 0 0 60 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 2124 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_HORNED Horned Demon rhino\rhino Retail 160 1630 true true 8,8,14,6,16,6 2,1,1,1,1,1 7 9 13 40 80 Rhino SEARCH,CAN_OPEN_DOOR 0 60 7 2 16 100 0 5 32 0 0 0 0 0 0 40 Animal RESIST_FIRE Bottom,Middle,Top 1172 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_MUDRUN Mud Runner rhino\rhino rhino\orange Retail 160 1630 true true 8,8,14,6,16,6 2,1,1,1,1,1 8 10 15 50 90 Rhino SEARCH,CAN_OPEN_DOOR 1 70 7 6 18 100 0 12 36 0 0 0 0 0 0 45 Animal RESIST_FIRE Bottom,Middle,Top 1404 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_FROSTC Frost Charger rhino\rhino rhino\blue Retail 160 1630 true true 8,8,14,6,16,6 2,1,1,1,1,1 9 11 17 60 100 Rhino SEARCH,CAN_OPEN_DOOR 2 80 7 8 20 100 0 20 40 0 0 0 0 0 0 50 Animal IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle,Top 1720 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_OBLORD Obsidian Lord rhino\rhino rhino\rhinob Retail 160 1630 true true 8,8,14,6,16,6 2,1,1,1,1,1 10 12 19 70 110 Rhino SEARCH,CAN_OPEN_DOOR 3 90 7 10 22 100 0 20 50 0 0 0 0 0 0 55 Animal IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 1809 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_BONEDMN oldboned demskel\demskl Never 128 1740 true true 10,8,20,6,24,16 3,1,1,1,1,1 24 24 12 70 70 Storm 0 60 8 6 14 12 0 0 0 0 0 0 0 0 0 50 Demon IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle,Top 1344 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_REDDTH Red Death thin\thin thin\thinv3 Never 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 8 10 16 96 96 Storm 1 75 5 10 20 0 0 0 0 0 0 0 0 0 0 60 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 2168 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_LTCHDMN Litch Demon thin\thin thin\thinv3 Never 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 9 11 18 110 110 Storm 2 80 5 10 24 0 0 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2736 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_UDEDBLRG Undead Balrog thin\thin thin\thinv3 Never 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 11 13 22 130 130 Storm 3 85 5 12 30 0 0 0 0 0 0 0 0 0 0 65 Demon IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 3575 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_INCIN Incinerator fireman\firem Never 128 1460 true false 14,19,20,8,14,23 1,1,1,1,1,1 21 22 16 30 45 FireMan 0 75 8 8 16 0 0 0 0 0 0 0 0 0 0 25 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 1888 None,None,None,None,None,None +MT_FLAMLRD Flame Lord fireman\firem Never 128 1460 true false 14,19,20,8,14,23 1,1,1,1,1,1 22 23 18 40 55 FireMan 1 75 8 10 20 0 0 0 0 0 0 0 0 0 0 25 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle 2250 None,None,None,None,None,None +MT_DOOMFIRE Doom Fire fireman\firem Never 128 1460 true false 14,19,20,8,14,23 1,1,1,1,1,1 23 24 20 50 65 FireMan 2 80 8 12 24 0 0 0 0 0 0 0 0 0 0 30 Demon IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 2740 None,None,None,None,None,None +MT_HELLBURN Hell Burner fireman\firem Never 128 1460 true false 14,19,20,8,14,23 1,1,1,1,1,1 24 24 22 60 80 FireMan 3 85 8 15 30 0 0 0 0 0 0 0 0 0 0 30 Demon IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 3355 None,None,None,None,None,None +MT_STORM Red Storm thin\thin thin\thinv3 Retail 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 9 11 18 55 110 Storm SEARCH,CAN_OPEN_DOOR 0 80 5 8 18 75 8 4 16 0 0 0 0 0 0 30 Demon IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2160 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_RSTORM Storm Rider thin\thin Retail 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 10 12 20 60 120 Storm SEARCH,CAN_OPEN_DOOR 1 80 5 8 18 80 8 4 16 0 0 0 0 0 0 30 Demon RESIST_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2391 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_STORML Storm Lord thin\thin thin\thinv2 Retail 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 11 13 22 75 135 Storm SEARCH,CAN_OPEN_DOOR 2 85 5 12 24 75 8 4 16 0 0 0 0 0 0 35 Demon RESIST_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2775 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_MAEL Maelstrom thin\thin thin\thinv1 Retail 160 1740 true true 8,8,18,4,17,14 3,1,1,1,1,1 12 14 24 90 150 Storm SEARCH,CAN_OPEN_DOOR 3 90 5 12 28 75 8 4 16 0 0 0 0 0 0 40 Demon RESIST_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 3177 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_BIGFALL Devil Kin Brute bigfall\fallg newsfx\kbrute Never 128 800 true false 10,8,11,8,17,0 1,1,1,1,2,2 21 22 27 120 160 SkeletonMelee SEARCH,CAN_OPEN_DOOR 3 100 6 18 24 0 0 0 0 0 0 0 0 0 0 70 Animal RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 2400 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_WINGED Winged-Demon gargoyle\gargo Retail 160 1650 true false 14,14,14,10,18,14 1,1,1,1,1,2 5 7 9 45 60 Gargoyle CAN_OPEN_DOOR 0 50 7 10 16 0 0 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Middle,Top 662 FirstFrame+11,FirstFrame+11,FirstFrame+11,FirstFrame+11,FirstFrame+11,EachFrame +MT_GARGOYLE Gargoyle gargoyle\gargo gargoyle\gare Retail 160 1650 true false 14,14,14,10,18,14 1,1,1,1,1,2 7 9 13 60 90 Gargoyle CAN_OPEN_DOOR 1 65 7 10 16 0 0 0 0 0 0 0 0 0 0 45 Demon IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_LIGHTNING Middle,Top 1205 FirstFrame+11,FirstFrame+11,FirstFrame+11,FirstFrame+11,FirstFrame+11,EachFrame +MT_BLOODCLW Blood Claw gargoyle\gargo gargoyle\gargbr Retail 160 1650 true false 14,14,14,10,18,14 1,1,1,1,1,1 9 11 19 75 125 Gargoyle CAN_OPEN_DOOR 2 80 7 14 22 0 0 0 0 0 0 0 0 0 0 50 Demon IMMUNE_MAGIC,IMMUNE_FIRE IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Middle,Top 1873 FirstFrame+11,FirstFrame+11,FirstFrame+11,FirstFrame+11,FirstFrame+11,EachFrame +MT_DEATHW Death Wing gargoyle\gargo gargoyle\gargb Retail 160 1650 true false 14,14,14,10,18,14 1,1,1,1,1,1 10 12 23 90 150 Gargoyle CAN_OPEN_DOOR 3 95 7 16 28 0 0 0 0 0 0 0 0 0 0 60 Demon IMMUNE_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Middle,Top 2278 FirstFrame+11,FirstFrame+11,FirstFrame+11,FirstFrame+11,FirstFrame+11,EachFrame +MT_MEGA Slayer mega\mega Retail 160 2220 true true 6,7,14,1,24,5 3,1,1,1,2,1 10 12 20 120 140 Mega SEARCH,CAN_OPEN_DOOR 0 100 8 12 20 0 3 0 0 0 0 0 0 0 0 60 Demon RESIST_MAGIC,IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 2300 EachFrame,EachFrame,FirstFrame,FirstFrame,FirstFrame,EachFrame +MT_GUARD Guardian mega\mega mega\guard Retail 160 2220 true true 6,7,14,1,24,5 3,1,1,1,2,1 11 13 22 140 160 Mega SEARCH,CAN_OPEN_DOOR 1 110 8 14 22 0 3 0 0 0 0 0 0 0 0 65 Demon RESIST_MAGIC,IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 2714 EachFrame,EachFrame,FirstFrame,FirstFrame,FirstFrame,EachFrame +MT_VTEXLRD Vortex Lord mega\mega mega\vtexl Retail 160 2220 true true 6,7,14,1,24,5 3,1,1,1,2,1 12 14 24 160 180 Mega SEARCH,CAN_OPEN_DOOR 2 120 8 18 24 0 3 0 0 0 0 0 0 0 0 70 Demon RESIST_MAGIC,IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 3252 EachFrame,EachFrame,FirstFrame,FirstFrame,FirstFrame,EachFrame +MT_BALROG Balrog mega\mega mega\balr Retail 160 2220 true true 6,7,14,1,24,5 3,1,1,1,2,1 13 15 26 180 200 Mega SEARCH,CAN_OPEN_DOOR 3 130 8 22 30 0 3 0 0 0 0 0 0 0 0 75 Demon RESIST_MAGIC,IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 3643 EachFrame,EachFrame,FirstFrame,FirstFrame,FirstFrame,EachFrame +MT_NSNAKE Cave Viper snake\snake Retail 160 1270 false false 12,11,13,5,18,0 2,1,1,1,1,1 11 13 21 100 150 Snake SEARCH 0 90 8 8 20 0 0 0 0 0 0 0 0 0 0 60 Demon IMMUNE_MAGIC IMMUNE_MAGIC Bottom,Middle,Top 2725 FirstFrame,FirstFrame,EachFrame,FirstFrame,FirstFrame,None +MT_RSNAKE Fire Drake snake\snake snake\snakr Retail 160 1270 false false 12,11,13,5,18,0 2,1,1,1,1,1 12 14 23 120 170 Snake SEARCH 1 105 8 12 24 0 0 0 0 0 0 0 0 0 0 65 Demon IMMUNE_MAGIC,RESIST_FIRE IMMUNE_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 3139 FirstFrame,FirstFrame,EachFrame,FirstFrame,FirstFrame,None +MT_BSNAKE Gold Viper snake\snake snake\snakg Retail 160 1270 false false 12,11,13,5,18,0 2,1,1,1,1,1 13 14 25 140 180 Snake SEARCH 2 120 8 15 26 0 0 0 0 0 0 0 0 0 0 70 Demon IMMUNE_MAGIC,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle,Top 3540 FirstFrame,FirstFrame,EachFrame,FirstFrame,FirstFrame,None +MT_GSNAKE Azure Drake snake\snake snake\snakb Retail 160 1270 false false 12,11,13,5,18,0 2,1,1,1,1,1 15 16 27 160 200 Snake SEARCH 3 130 8 18 30 0 0 0 0 0 0 0 0 0 0 75 Demon RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 3791 FirstFrame,FirstFrame,EachFrame,FirstFrame,FirstFrame,None +MT_NBLACK Black Knight black\black Retail 160 2120 false false 8,8,16,4,24,0 2,1,1,1,1,1 12 14 24 150 150 SkeletonMelee SEARCH 0 110 8 15 20 0 0 0 0 0 0 0 0 0 0 75 Demon RESIST_MAGIC,RESIST_LIGHTNING RESIST_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 3360 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_RTBLACK Doom Guard black\black black\blkkntrt Retail 160 2120 false false 8,8,16,4,24,0 2,1,1,1,1,1 13 15 26 165 165 SkeletonMelee SEARCH 0 130 8 18 25 0 0 0 0 0 0 0 0 0 0 75 Demon RESIST_MAGIC,RESIST_FIRE RESIST_MAGIC,IMMUNE_FIRE Bottom,Middle,Top 3650 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_BTBLACK Steel Lord black\black black\blkkntbt Retail 160 2120 false false 8,8,16,4,24,0 2,1,1,1,1,1 14 16 28 180 180 SkeletonMelee SEARCH 1 120 8 20 30 0 0 0 0 0 0 0 0 0 0 80 Demon RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 4252 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_RBLACK Blood Knight black\black black\blkkntbe Retail 160 2120 false false 8,8,16,4,24,0 2,1,1,1,1,1 13 14 30 200 200 SkeletonMelee SEARCH 1 130 8 25 35 0 0 0 0 0 0 0 0 0 0 85 Demon IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 5130 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_UNRAV The Shredded unrav\unrav newsfx\shred Never 96 484 false false 10,10,12,5,16,0 1,1,1,1,1,1 17 18 23 70 90 SkeletonMelee 0 75 7 4 12 0 0 0 0 0 0 0 0 0 0 65 Undead RESIST_FIRE,RESIST_LIGHTNING RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 900 None,EachFrame,EachFrame,None,None,None +MT_HOLOWONE Hollow One unrav\unrav acid\acid Never 96 484 false false 10,10,12,5,16,0 1,1,1,1,1,1 18 19 27 135 240 SkeletonMelee 1 75 7 12 24 0 0 0 0 0 0 0 0 0 0 75 Undead IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 4374 None,EachFrame,EachFrame,None,None,None +MT_PAINMSTR Pain Master unrav\unrav acid\acid Never 96 484 false false 10,10,12,5,16,0 1,1,1,1,1,1 19 20 29 110 200 SkeletonMelee 2 80 7 16 30 0 0 0 0 0 0 0 0 0 0 80 Undead IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 5147 None,EachFrame,EachFrame,None,None,None +MT_REALWEAV Reality Weaver unrav\unrav acid\acid Never 96 484 false false 10,10,12,5,16,0 1,1,1,1,1,1 20 20 30 135 240 SkeletonMelee 3 85 7 20 35 0 0 0 0 0 0 0 0 0 0 85 Undead RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle 5925 None,EachFrame,EachFrame,None,None,None +MT_SUCCUBUS Succubus succ\scbs Retail 128 980 false false 14,8,16,7,24,0 1,1,1,1,1,1 12 14 24 120 150 Succubus CAN_OPEN_DOOR 0 100 10 1 20 0 0 0 0 0 0 0 0 0 0 60 Demon RESIST_MAGIC IMMUNE_MAGIC,RESIST_FIRE Bottom,Middle 3696 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_SNOWWICH Snow Witch succ\scbs succ\succb Retail 128 980 false false 14,8,16,7,24,0 1,1,1,1,1,1 13 15 26 135 175 Succubus CAN_OPEN_DOOR 1 110 10 1 24 0 0 0 0 0 0 0 0 0 0 65 Demon RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_LIGHTNING Bottom,Middle 4084 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_HLSPWN Hell Spawn succ\scbs succ\succrw Retail 128 980 false false 14,8,16,7,24,0 1,1,1,1,1,1 14 16 28 150 200 Succubus SEARCH,CAN_OPEN_DOOR 2 115 10 1 30 0 0 0 0 0 0 0 0 0 0 75 Demon RESIST_MAGIC,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 4480 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_SOLBRNR Soul Burner succ\scbs succ\succbw Retail 128 980 false false 14,8,16,7,24,0 1,1,1,1,1,1 15 16 30 140 225 Succubus SEARCH,CAN_OPEN_DOOR 3 120 10 1 35 0 0 0 0 0 0 0 0 0 0 85 Demon RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle 4644 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_COUNSLR Counselor mage\mage Retail 128 2000 true false 12,1,20,8,28,20 1,1,1,1,1,1 13 14 25 70 70 Counselor CAN_OPEN_DOOR 0 90 8 8 20 0 0 0 0 0 0 0 0 0 0 0 Demon RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 4070 FirstFrame,FirstFrame,EachFrame,FirstFrame,FirstFrame,FirstFrame +MT_MAGISTR Magistrate mage\mage mage\cnselg Retail 128 2000 true false 12,1,20,8,28,20 1,1,1,1,1,1 14 15 27 85 85 Counselor CAN_OPEN_DOOR 1 100 8 10 24 0 0 0 0 0 0 0 0 0 0 0 Demon RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 4478 FirstFrame,FirstFrame,EachFrame,FirstFrame,FirstFrame,FirstFrame +MT_CABALIST Cabalist mage\mage mage\cnselgd Retail 128 2000 true false 12,1,20,8,28,20 1,1,1,1,1,1 15 16 29 120 120 Counselor CAN_OPEN_DOOR 2 110 8 14 30 0 0 0 0 0 0 0 0 0 0 0 Demon RESIST_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 4929 FirstFrame,FirstFrame,EachFrame,FirstFrame,FirstFrame,FirstFrame +MT_ADVOCATE Advocate mage\mage mage\cnselbk Retail 128 2000 true false 12,1,20,8,28,20 1,1,1,1,1,1 16 16 30 145 145 Counselor CAN_OPEN_DOOR 3 120 8 15 25 0 0 0 0 0 0 0 0 0 0 0 Demon IMMUNE_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 4968 FirstFrame,FirstFrame,EachFrame,FirstFrame,FirstFrame,FirstFrame +MT_GOLEM Golem golem\golem golem\golm Never 96 386 true false 0,16,12,0,12,20 1,1,1,1,1,1 1 1 12 1 1 Golem CAN_OPEN_DOOR 0 0 7 1 1 0 0 0 0 0 0 0 0 0 0 1 Demon 0 None,EachFrame,EachFrame,None,EachFrame,EachFrame +MT_DIABLO The Dark Lord diablo\diablo Never 160 2000 true true 16,6,16,6,16,16 1,1,1,1,1,1 26 26 30 1666 1666 Diablo KNOCKBACK,SEARCH,CAN_OPEN_DOOR 3 220 4 30 60 0 11 0 0 0 0 0 0 0 0 90 Demon IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 31666 FirstFrame,FirstFrame,EachFrame,FirstFrame,EachFrame,EachFrame +MT_DARKMAGE The Arch-Litch Malignus darkmage\dmage darkmage\dmag Never 128 1060 true false 6,1,21,6,23,18 1,1,1,1,1,1 21 21 30 160 160 Counselor CAN_OPEN_DOOR 3 120 8 20 40 0 0 0 0 0 0 0 0 0 0 70 Demon RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 4968 FirstFrame,None,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_HELLBOAR Hellboar fork\fork newsfx\hboar Retail 188 800 false false 10,10,15,6,16,0 2,1,1,1,1,1 17 18 23 80 100 SkeletonMelee KNOCKBACK,SEARCH 2 70 7 16 24 0 0 0 0 0 0 0 0 0 0 60 Demon RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 750 EachFrame,EachFrame,EachFrame,EachFrame,None,None +MT_STINGER Stinger scorp\scorp newsfx\stingr Retail 64 305 false false 10,10,12,6,15,0 2,1,1,1,1,1 17 18 22 30 40 SkeletonMelee 3 85 8 1 20 0 0 0 0 0 0 0 0 0 0 50 Animal RESIST_LIGHTNING Bottom 500 EachFrame,EachFrame,EachFrame,EachFrame,EachFrame,None +MT_PSYCHORB Psychorb eye\eye newsfx\psyco Retail 156 800 false false 12,13,13,7,21,0 2,1,1,1,1,1 17 18 22 20 30 Psychorb 3 80 8 10 10 0 0 0 0 0 0 0 0 0 0 40 Animal RESIST_FIRE Middle,Top 450 EachFrame,EachFrame,EachFrame,EachFrame,EachFrame,None +MT_ARACHNON Arachnon spider\spider newsfx\slord Retail 148 800 false false 12,10,15,6,20,0 2,1,1,1,1,1 17 18 22 60 80 SkeletonMelee SEARCH 3 50 8 5 15 0 0 0 0 0 0 0 0 0 0 50 Animal RESIST_LIGHTNING Bottom,Middle,Top 500 EachFrame,EachFrame,EachFrame,EachFrame,EachFrame,None +MT_FELLTWIN Felltwin tsneak\tsneak newsfx\ftwin Retail 128 800 false false 13,13,15,11,16,0 2,1,1,1,1,1 17 18 22 50 70 SkeletonMelee SEARCH,CAN_OPEN_DOOR 3 70 8 10 18 0 0 0 0 0 0 0 0 0 0 50 Demon RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 600 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,None +MT_HORKSPWN Hork Spawn spawn\spawn newsfx\hspawn Retail 164 520 false true 15,12,14,11,14,0 1,1,1,1,1,1 18 19 22 30 30 SkeletonMelee 3 60 8 10 25 0 0 0 0 0 0 0 0 0 0 25 Demon RESIST_MAGIC RESIST_MAGIC Bottom,Middle 250 EachFrame,EachFrame,EachFrame,EachFrame,None,None +MT_VENMTAIL Venomtail wscorp\wscorp newsfx\stingr Retail 86 305 false false 10,10,12,6,15,0 2,1,1,1,1,1 19 20 24 40 50 SkeletonMelee 3 85 8 1 30 0 0 0 0 0 0 0 0 0 0 60 Animal RESIST_LIGHTNING IMMUNE_LIGHTNING Bottom 1000 EachFrame,EachFrame,EachFrame,EachFrame,EachFrame,None +MT_NECRMORB Necromorb eye2\eye2 newsfx\psyco Retail 140 800 false false 12,13,13,7,21,0 2,1,1,1,1,1 19 20 24 30 40 Necromorb 3 80 8 20 20 0 0 0 0 0 0 0 0 0 0 50 Animal RESIST_FIRE IMMUNE_FIRE,RESIST_LIGHTNING Middle,Top 1100 EachFrame,EachFrame,EachFrame,EachFrame,EachFrame,None +MT_SPIDLORD Spider Lord bspidr\bspidr newsfx\slord Retail 148 800 true true 12,10,15,6,20,10 2,1,1,1,1,1 19 20 24 80 100 Acid SEARCH 3 60 8 8 20 75 8 10 10 0 0 0 0 0 0 60 Animal RESIST_LIGHTNING RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 1250 EachFrame,EachFrame,EachFrame,EachFrame,EachFrame,EachFrame +MT_LASHWORM Lashworm clasp\clasp newsfx\lworm Retail 176 800 false false 10,12,15,6,16,0 1,1,1,1,1,1 19 20 20 30 30 SkeletonMelee 3 90 8 12 20 0 0 0 0 0 0 0 0 0 0 50 Animal RESIST_FIRE Bottom,Middle 600 EachFrame,EachFrame,EachFrame,EachFrame,EachFrame,None +MT_TORCHANT Torchant antworm\worm newsfx\tchant Retail 192 800 false false 14,12,12,6,20,0 2,1,1,1,1,1 19 20 22 60 80 Torchant 3 75 8 20 30 0 0 0 0 0 0 0 0 0 0 70 Animal IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 1250 EachFrame,EachFrame,EachFrame,EachFrame,None,None +MT_HORKDMN Hork Demon horkd\horkd newsfx\hdemon Never 138 800 true true 15,8,16,6,16,9 2,1,1,1,1,2 19 19 27 120 160 SkeletonMelee 3 60 8 20 35 80 8 0 0 0 0 0 0 0 0 80 Demon RESIST_LIGHTNING RESIST_MAGIC,IMMUNE_LIGHTNING Bottom,Middle,Top 2000 EachFrame,EachFrame,EachFrame,EachFrame,None,EachFrame +MT_DEFILER Hell Bug hellbug\hellbg newsfx\defile Never 198 800 true true 8,8,14,6,14,12 1,1,1,1,1,1 20 20 30 240 240 SkeletonMelee SEARCH 3 110 8 20 30 90 8 50 60 0 0 0 0 0 0 80 Demon RESIST_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 5000 EachFrame,EachFrame,EachFrame,EachFrame,None,EachFrame +MT_GRAVEDIG Gravedigger gravdg\gravdg newsfx\gdiggr Retail 124 800 true true 24,24,12,6,16,16 2,1,1,1,1,1 21 21 26 120 240 Scavenger CAN_OPEN_DOOR 3 80 6 2 12 0 0 0 0 0 0 0 0 0 0 20 Undead IMMUNE_LIGHTNING RESIST_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle 2000 EachFrame,EachFrame,EachFrame,EachFrame,EachFrame,EachFrame +MT_TOMBRAT Tomb Rat rat\rat newsfx\tmbrat Retail 104 550 false false 11,8,12,6,20,0 2,1,1,1,1,1 21 22 24 80 120 SkeletonMelee 3 120 8 12 25 0 0 0 0 0 0 0 0 0 0 30 Animal RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 1800 EachFrame,EachFrame,EachFrame,EachFrame,None,None +MT_FIREBAT Firebat hellbat\helbat newsfx\helbat Retail 96 550 false false 18,16,14,6,18,11 2,1,1,1,1,1 21 22 24 60 80 FireBat 3 100 8 15 20 0 0 0 0 0 0 0 0 0 0 70 Animal IMMUNE_FIRE RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 2400 EachFrame,EachFrame,EachFrame,EachFrame,None,EachFrame +MT_SKLWING Skullwing demskel\demskl newsfx\swing skelaxe\skelt Retail 128 1740 true false 10,8,20,6,24,16 3,1,1,1,1,1 21 22 27 70 70 SkeletonMelee 0 75 7 15 20 75 9 15 20 0 0 0 0 0 0 80 Undead RESIST_FIRE,RESIST_LIGHTNING RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle,Top 3000 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_LICH Lich lich\lich newsfx\lich Retail 96 800 false true 12,10,10,7,21,0 2,1,1,1,2,1 21 22 25 80 100 Lich 3 100 8 15 20 0 0 0 0 0 0 0 0 0 0 60 Undead RESIST_LIGHTNING RESIST_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING Bottom,Middle 3000 EachFrame,EachFrame,EachFrame,EachFrame,EachFrame,None +MT_CRYPTDMN Crypt Demon bubba\bubba newsfx\crypt Retail 154 800 false true 8,18,12,8,21,0 3,1,1,1,1,1 22 23 28 200 240 SkeletonMelee 3 100 8 20 40 0 0 0 0 0 0 0 0 0 0 85 Demon IMMUNE_MAGIC,RESIST_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING Bottom,Middle 3200 EachFrame,EachFrame,EachFrame,EachFrame,None,None +MT_HELLBAT Hellbat hellbat2\bhelbt newsfx\helbat Retail 96 550 true false 18,16,14,6,18,11 2,1,1,1,1,1 23 24 29 100 140 Torchant 3 110 8 30 30 0 0 0 0 0 0 0 0 0 0 80 Demon RESIST_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING RESIST_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 3600 EachFrame,EachFrame,EachFrame,EachFrame,None,EachFrame +MT_BONEDEMN Bone Demon demskel\demskl newsfx\swing Retail 128 1740 true true 10,8,20,6,24,16 3,1,1,1,1,1 23 24 30 240 280 BoneDemon 0 100 8 40 50 160 12 50 50 0 0 0 0 0 0 50 Undead IMMUNE_FIRE,IMMUNE_LIGHTNING IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 5000 FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame,FirstFrame +MT_ARCHLICH Arch Lich lich2\lich2 newsfx\lich Retail 136 800 false true 12,10,10,7,21,0 2,1,1,1,2,1 23 24 30 180 200 ArchLich 3 120 8 30 30 0 0 0 0 0 0 0 0 0 0 75 Undead RESIST_MAGIC,RESIST_FIRE,IMMUNE_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle 4000 EachFrame,EachFrame,EachFrame,EachFrame,None,None +MT_BICLOPS Biclops byclps\byclps newsfx\biclop Retail 180 800 false false 10,11,16,6,16,0 2,1,1,1,2,1 23 24 30 200 240 SkeletonMelee KNOCKBACK,CAN_OPEN_DOOR 3 90 8 40 50 0 0 0 0 0 0 0 0 0 0 80 Demon RESIST_LIGHTNING RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 4000 EachFrame,EachFrame,EachFrame,EachFrame,EachFrame,None +MT_FLESTHNG Flesh Thing flesh\flesh newsfx\flesht Retail 164 800 false true 15,24,15,6,16,0 1,1,1,1,1,1 23 24 28 300 400 SkeletonMelee 3 150 8 12 18 0 0 0 0 0 0 0 0 0 0 70 Demon RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING RESIST_MAGIC,RESIST_FIRE,RESIST_LIGHTNING Bottom,Middle 4000 EachFrame,EachFrame,EachFrame,EachFrame,EachFrame,None +MT_REAPER Reaper reaper\reap newsfx\reaper Retail 180 800 false false 12,10,14,6,16,0 2,1,1,1,1,1 23 24 30 260 300 SkeletonMelee 3 120 8 30 35 0 0 0 0 0 0 0 0 0 0 90 Demon IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle 6000 EachFrame,EachFrame,EachFrame,EachFrame,EachFrame|Unexplained,None +MT_NAKRUL Na-Krul nkr\nkr newsfx\nakrul Never 226 1200 true true 2,6,16,3,16,16 1,1,1,1,1,1 31 31 40 1332 1332 SkeletonMelee KNOCKBACK,SEARCH,CAN_OPEN_DOOR 3 150 7 40 50 150 10 40 50 0 0 0 0 0 0 125 Demon IMMUNE_MAGIC,IMMUNE_FIRE,RESIST_LIGHTNING IMMUNE_MAGIC,IMMUNE_FIRE,IMMUNE_LIGHTNING Bottom,Middle,Top 13333 FirstFrame,EachFrame,EachFrame|Unexplained,EachFrame,EachFrame|Unexplained,EachFrame|Unexplained diff --git a/mods/hf/txtdata/towners/towners.tsv b/mods/hf/txtdata/towners/towners.tsv index 0f218b674..92f576955 100644 --- a/mods/hf/txtdata/towners/towners.tsv +++ b/mods/hf/txtdata/towners/towners.tsv @@ -1,16 +1,16 @@ -type name position_x position_y direction animWidth animPath animFrames animDelay gossipTexts animOrder -TOWN_SMITH Griswold the Blacksmith 62 63 SouthWest 96 towners\smith\smithn 16 3 TEXT_GRISWOLD2,TEXT_GRISWOLD3,TEXT_GRISWOLD4,TEXT_GRISWOLD5,TEXT_GRISWOLD6,TEXT_GRISWOLD7,TEXT_GRISWOLD8,TEXT_GRISWOLD9,TEXT_GRISWOLD10,TEXT_GRISWOLD12,TEXT_GRISWOLD13 4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,14,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,3 -TOWN_HEALER Pepin the Healer 55 79 SouthEast 96 towners\healer\healer 20 6 TEXT_PEPIN2,TEXT_PEPIN3,TEXT_PEPIN4,TEXT_PEPIN5,TEXT_PEPIN6,TEXT_PEPIN7,TEXT_PEPIN9,TEXT_PEPIN10,TEXT_PEPIN11 0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19 -TOWN_DEADGUY Wounded Townsman 24 32 North 96 towners\butch\deadguy 8 6 -TOWN_TAVERN Ogden the Tavern owner 55 62 SouthWest 96 towners\twnf\twnfn 16 3 TEXT_OGDEN2,TEXT_OGDEN3,TEXT_OGDEN4,TEXT_OGDEN5,TEXT_OGDEN6,TEXT_OGDEN8,TEXT_OGDEN9,TEXT_OGDEN10 0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,1,0,15,14,13,13,14,15,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15 -TOWN_STORY Cain the Elder 62 71 South 96 towners\strytell\strytell 25 3 TEXT_STORY2,TEXT_STORY3,TEXT_STORY4,TEXT_STORY5,TEXT_STORY6,TEXT_STORY7,TEXT_STORY9,TEXT_STORY10,TEXT_STORY11 0,0,24,24,23,22,21,20,19,18,17,16,15,14,15,16,17,18,19,20,21,22,23,24,24,24,0,0,0,24,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0 -TOWN_DRUNK Farnham the Drunk 71 84 South 96 towners\drunk\twndrunk 18 3 TEXT_FARNHAM2,TEXT_FARNHAM3,TEXT_FARNHAM4,TEXT_FARNHAM5,TEXT_FARNHAM6,TEXT_FARNHAM8,TEXT_FARNHAM9,TEXT_FARNHAM10,TEXT_FARNHAM11,TEXT_FARNHAM12,TEXT_FARNHAM13 0,0,0,1,2,3,4,5,6,7,8,9,10,10,10,10,11,12,13,14,15,16,17,17,0,0,0,17,16,15,14,13,12,11,10,9,10,11,12,13,14,15,16,17,0,1,2,3,4,4,4,3,2,1 -TOWN_WITCH Adria the Witch 80 20 South 96 towners\townwmn1\witch 19 6 TEXT_ADRIA2,TEXT_ADRIA3,TEXT_ADRIA4,TEXT_ADRIA5,TEXT_ADRIA6,TEXT_ADRIA7,TEXT_ADRIA8,TEXT_ADRIA9,TEXT_ADRIA10,TEXT_ADRIA12,TEXT_ADRIA13 3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,2,1,0,18,17,18,0,1,0,18,17,18,0,1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,14,14,13,12,12,12,12,13,14,14,14,13,12,11,11,11,10,9,9,9,8,7,8,9,9,10,11,12,13,14,15,16,17,18,0,1,0,18,17,18,0,1,0,1,2 -TOWN_BMAID Gillian the Barmaid 43 66 South 96 towners\townwmn1\wmnn 18 6 TEXT_GILLIAN2,TEXT_GILLIAN3,TEXT_GILLIAN4,TEXT_GILLIAN5,TEXT_GILLIAN6,TEXT_GILLIAN7,TEXT_GILLIAN9,TEXT_GILLIAN10 -TOWN_PEGBOY Wirt the Peg-legged boy 11 53 South 96 towners\townboy\pegkid1 20 6 TEXT_WIRT2,TEXT_WIRT3,TEXT_WIRT4,TEXT_WIRT5,TEXT_WIRT6,TEXT_WIRT7,TEXT_WIRT8,TEXT_WIRT9,TEXT_WIRT11,TEXT_WIRT12 +type name position_x position_y direction animWidth animPath animFrames animDelay gossipTexts animOrder shadowRule +TOWN_SMITH Griswold the Blacksmith 62 63 SouthWest 96 towners\smith\smithn 16 3 TEXT_GRISWOLD2,TEXT_GRISWOLD3,TEXT_GRISWOLD4,TEXT_GRISWOLD5,TEXT_GRISWOLD6,TEXT_GRISWOLD7,TEXT_GRISWOLD8,TEXT_GRISWOLD9,TEXT_GRISWOLD10,TEXT_GRISWOLD12,TEXT_GRISWOLD13 4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,14,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,3 FirstFrame +TOWN_HEALER Pepin the Healer 55 79 SouthEast 96 towners\healer\healer 20 6 TEXT_PEPIN2,TEXT_PEPIN3,TEXT_PEPIN4,TEXT_PEPIN5,TEXT_PEPIN6,TEXT_PEPIN7,TEXT_PEPIN9,TEXT_PEPIN10,TEXT_PEPIN11 0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19 FirstFrame +TOWN_DEADGUY Wounded Townsman 24 32 North 96 towners\butch\deadguy 8 6 None +TOWN_TAVERN Ogden the Tavern owner 55 62 SouthWest 96 towners\twnf\twnfn 16 3 TEXT_OGDEN2,TEXT_OGDEN3,TEXT_OGDEN4,TEXT_OGDEN5,TEXT_OGDEN6,TEXT_OGDEN8,TEXT_OGDEN9,TEXT_OGDEN10 0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,1,0,15,14,13,13,14,15,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15 FirstFrame +TOWN_STORY Cain the Elder 62 71 South 96 towners\strytell\strytell 25 3 TEXT_STORY2,TEXT_STORY3,TEXT_STORY4,TEXT_STORY5,TEXT_STORY6,TEXT_STORY7,TEXT_STORY9,TEXT_STORY10,TEXT_STORY11 0,0,24,24,23,22,21,20,19,18,17,16,15,14,15,16,17,18,19,20,21,22,23,24,24,24,0,0,0,24,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0 FirstFrame +TOWN_DRUNK Farnham the Drunk 71 84 South 96 towners\drunk\twndrunk 18 3 TEXT_FARNHAM2,TEXT_FARNHAM3,TEXT_FARNHAM4,TEXT_FARNHAM5,TEXT_FARNHAM6,TEXT_FARNHAM8,TEXT_FARNHAM9,TEXT_FARNHAM10,TEXT_FARNHAM11,TEXT_FARNHAM12,TEXT_FARNHAM13 0,0,0,1,2,3,4,5,6,7,8,9,10,10,10,10,11,12,13,14,15,16,17,17,0,0,0,17,16,15,14,13,12,11,10,9,10,11,12,13,14,15,16,17,0,1,2,3,4,4,4,3,2,1 FirstFrame +TOWN_WITCH Adria the Witch 80 20 South 96 towners\townwmn1\witch 19 6 TEXT_ADRIA2,TEXT_ADRIA3,TEXT_ADRIA4,TEXT_ADRIA5,TEXT_ADRIA6,TEXT_ADRIA7,TEXT_ADRIA8,TEXT_ADRIA9,TEXT_ADRIA10,TEXT_ADRIA12,TEXT_ADRIA13 3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,2,1,0,18,17,18,0,1,0,18,17,18,0,1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,14,14,13,12,12,12,12,13,14,14,14,13,12,11,11,11,10,9,9,9,8,7,8,9,9,10,11,12,13,14,15,16,17,18,0,1,0,18,17,18,0,1,0,1,2 FirstFrame +TOWN_BMAID Gillian the Barmaid 43 66 South 96 towners\townwmn1\wmnn 18 6 TEXT_GILLIAN2,TEXT_GILLIAN3,TEXT_GILLIAN4,TEXT_GILLIAN5,TEXT_GILLIAN6,TEXT_GILLIAN7,TEXT_GILLIAN9,TEXT_GILLIAN10 FirstFrame +TOWN_PEGBOY Wirt the Peg-legged boy 11 53 South 96 towners\townboy\pegkid1 20 6 TEXT_WIRT2,TEXT_WIRT3,TEXT_WIRT4,TEXT_WIRT5,TEXT_WIRT6,TEXT_WIRT7,TEXT_WIRT8,TEXT_WIRT9,TEXT_WIRT11,TEXT_WIRT12 FirstFrame|Stray TOWN_COW Cow 58 16 SouthWest 128 12 3 TOWN_COW Cow 56 14 NorthWest 128 12 3 TOWN_COW Cow 59 20 North 128 12 3 -TOWN_COWFARM Complete Nut 61 22 SouthWest 96 towners\farmer\cfrmrn2 15 3 -TOWN_FARMER Lester the farmer 62 16 South 96 towners\farmer\farmrn2 15 3 -TOWN_GIRL Celia 77 43 South 96 towners\girl\girlw1 20 6 +TOWN_COWFARM Complete Nut 61 22 SouthWest 96 towners\farmer\cfrmrn2 15 3 Baked +TOWN_FARMER Lester the farmer 62 16 South 96 towners\farmer\farmrn2 15 3 FirstFrame +TOWN_GIRL Celia 77 43 South 96 towners\girl\girlw1 20 6 FirstFrame diff --git a/test/clx_shadow_test.cpp b/test/clx_shadow_test.cpp new file mode 100644 index 000000000..034339696 --- /dev/null +++ b/test/clx_shadow_test.cpp @@ -0,0 +1,529 @@ +#include +#include +#include +#include + +#include + +#include "engine/clx_sprite.hpp" +#include "engine/render/clx_render.hpp" +#include "engine/shadow_mask.hpp" +#include "engine/shadow_rule.hpp" +#include "engine/surface.hpp" +#include "utils/clx_decode.hpp" +#include "utils/clx_encode.hpp" +#include "utils/clx_shadow.hpp" +#include "utils/endian_write.hpp" +#include "utils/palette_blending.hpp" +#include "utils/sdl_wrap.h" + +namespace devilution { +namespace { + +/** + * A sprite as rows of characters, top row first: + * '.' is transparent, '#' is black (palette index 0), any other character is that palette index. + */ +using Picture = std::vector; + +void AppendSprite(const Picture &picture, std::vector &out) +{ + const size_t headerPos = out.size(); + out.resize(out.size() + ClxFrameHeaderSize); + WriteLE16(&out[headerPos], ClxFrameHeaderSize); + WriteLE16(&out[headerPos + 2], static_cast(picture[0].size())); + WriteLE16(&out[headerPos + 4], static_cast(picture.size())); + for (size_t i = picture.size(); i-- > 0;) { + for (const char c : picture[i]) { + if (c == '.') { + AppendClxTransparentRun(1, out); + } else { + const uint8_t color = c == '#' ? 0 : static_cast(c); + AppendClxPixelsRun(&color, 1, out); + } + } + } +} + +void AppendList(const std::vector &pictures, std::vector &out) +{ + const size_t begin = out.size(); + out.resize(out.size() + 4 * (2 + pictures.size())); + WriteLE32(&out[begin], static_cast(pictures.size())); + for (size_t i = 0; i < pictures.size(); ++i) { + WriteLE32(&out[begin + 4 * (1 + i)], static_cast(out.size() - begin)); + AppendSprite(pictures[i], out); + } + WriteLE32(&out[begin + 4 * (1 + pictures.size())], static_cast(out.size() - begin)); +} + +OwnedClxSpriteListOrSheet MakeList(const std::vector &pictures) +{ + std::vector data; + AppendList(pictures, data); + std::unique_ptr buffer { new uint8_t[data.size()] }; + std::copy(data.begin(), data.end(), buffer.get()); + return OwnedClxSpriteListOrSheet { std::move(buffer), 0 }; +} + +OwnedClxSpriteListOrSheet MakeSheet(const std::vector> &lists) +{ + std::vector data(lists.size() * 4); + for (size_t i = 0; i < lists.size(); ++i) { + WriteLE32(&data[i * 4], static_cast(data.size())); + AppendList(lists[i], data); + } + std::unique_ptr buffer { new uint8_t[data.size()] }; + std::copy(data.begin(), data.end(), buffer.get()); + return OwnedClxSpriteListOrSheet { std::move(buffer), static_cast(lists.size()) }; +} + +Picture ToPicture(ClxSprite sprite) +{ + const size_t width = sprite.width(); + Picture result(sprite.height(), std::string(width, '.')); + size_t pos = 0; + const auto put = [&](uint8_t color) { + result[sprite.height() - 1 - pos / width][pos % width] = color == 0 ? '#' : static_cast(color); + ++pos; + }; + const uint8_t *src = sprite.pixelData(); + const uint8_t *end = src + sprite.pixelDataSize(); + while (src < end) { + const uint8_t control = *src++; + if (!IsClxOpaque(control)) { + pos += control; + } else if (IsClxOpaqueFill(control)) { + const uint8_t color = *src++; + for (unsigned i = GetClxOpaqueFillWidth(control); i != 0; --i) + put(color); + } else { + for (unsigned i = GetClxOpaquePixelsWidth(control); i != 0; --i) + put(*src++); + } + } + return result; +} + +/** The mask drawn as '#' on a transparent picture of the given size. */ +Picture ToPicture(ShadowMask mask, size_t width, size_t height) +{ + Picture result(height, std::string(width, '.')); + mask.forEachSpan([&](uint16_t rowFromBottom, ShadowSpan span) { + for (unsigned x = span.x; x < static_cast(span.x) + span.width; ++x) + result[height - 1 - rowFromBottom][x] = '#'; + }); + return result; +} + +constexpr ShadowRule FirstFrame { ShadowRuleType::FirstFrame, 0 }; +constexpr ShadowRule EachFrame { ShadowRuleType::EachFrame, 0 }; + +// A 12 pixel tall bar with its baseline on row 11. +// Rows 9..11 and 6..8 shadow the bar itself, rows 3..5 shadow (5, 7) and rows 0..2 shadow (4, 6). +const Picture Bar = { + "......a.", + "......a.", + "......a.", + "......a.", + "......a.", + "......a.", + "....#.a.", + ".....#a.", + "......a.", + "......a.", + "......a.", + "......a.", +}; +const Picture BarWithoutShadow = { + "......a.", + "......a.", + "......a.", + "......a.", + "......a.", + "......a.", + "......a.", + "......a.", + "......a.", + "......a.", + "......a.", + "......a.", +}; +const Picture BarShadow = { + "........", + "........", + "........", + "........", + "........", + "........", + "....#...", + ".....#..", + "......#.", + "......#.", + "........", + "........", +}; + +TEST(ClxShadowTest, ExtractsShadow) +{ + const OwnedClxSpriteListOrSheet sprites = MakeList({ Bar }); + const std::optional result = ExtractClxShadows(sprites, FirstFrame); + ASSERT_TRUE(result.has_value()); + + ASSERT_FALSE(result->isSheet()); + const ClxSpriteList list = result->list(); + ASSERT_EQ(list.numSprites(), 1U); + EXPECT_EQ(list[0].width(), 8); + EXPECT_EQ(list[0].height(), 12); + EXPECT_EQ(ToPicture(list[0]), BarWithoutShadow); + + // The mask includes the shadow underneath the sprite. + ASSERT_TRUE(HasShadowMask(list[0])); + const ShadowMask mask = GetShadowMask(list[0]); + EXPECT_EQ(mask.bottomOffset(), 2); + EXPECT_EQ(mask.numRows(), 4); + EXPECT_EQ(ToPicture(mask, 8, 12), BarShadow); + // 2 bytes of header and one span for each of the 4 rows. + EXPECT_EQ(list[0].headerSize(), ClxSprite::BaseHeaderSize + 2 + 4 * 2); +} + +TEST(ClxShadowTest, KeepsBlackPixelsThatAreNotShadow) +{ + Picture picture = Bar; + // A black pixel of the bar. It casts the same shadow as the pixel it replaces. + picture[2][6] = '#'; + // Its shadow would fall outside of the frame, so it cannot be ruled out as a body pixel. + picture[0][0] = '#'; + // Not in any shadow, and its own shadow would fall on a transparent pixel. + picture[3][3] = '#'; + Picture expected = BarWithoutShadow; + expected[2][6] = '#'; + expected[0][0] = '#'; + expected[3][3] = '#'; + + const OwnedClxSpriteListOrSheet sprites = MakeList({ picture }); + const std::optional result = ExtractClxShadows(sprites, FirstFrame); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(ToPicture(result->list()[0]), expected); + EXPECT_EQ(ToPicture(GetShadowMask(result->list()[0]), 8, 12), BarShadow); +} + +TEST(ClxShadowTest, RecognisesBlackBodyPixelInsideAShadow) +{ + // The baseline is row 23. 'b' at (7, 0) shadows (2, 14), so the black pixel there looks like + // a shadow. It must be a body pixel, because nothing else casts the black pixel at (0, 18). + Picture picture(24, std::string(8, '.')); + picture[0][7] = 'b'; + picture[14][2] = '#'; + picture[18][0] = '#'; + picture[23][7] = 'a'; + Picture expected = picture; + expected[18][0] = '.'; + + const OwnedClxSpriteListOrSheet sprites = MakeList({ picture }); + const std::optional result = ExtractClxShadows(sprites, FirstFrame); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(ToPicture(result->list()[0]), expected); +} + +TEST(ClxShadowTest, BaselineRules) +{ + // The second sprite ends 3 rows higher, with the shadow that its own baseline (row 8) gives. + const Picture shortBar = { + "......a.", + "......a.", + "......a.", + "......a.", + ".....#a.", + "......a.", + "......a.", + "......a.", + "......a.", + "........", + "........", + "........", + }; + Picture shortBarWithoutShadow = shortBar; + shortBarWithoutShadow[4][5] = '.'; + const OwnedClxSpriteListOrSheet sprites = MakeList({ Bar, shortBar }); + + const std::optional eachFrame = ExtractClxShadows(sprites, EachFrame); + ASSERT_TRUE(eachFrame.has_value()); + EXPECT_EQ(ToPicture(eachFrame->list()[0]), BarWithoutShadow); + EXPECT_EQ(ToPicture(eachFrame->list()[1]), shortBarWithoutShadow); + EXPECT_EQ(GetShadowMask(eachFrame->list()[1]).bottomOffset(), 5); + EXPECT_EQ(GetShadowMask(eachFrame->list()[1]).numRows(), 3); + + // With the baseline of the first sprite (row 11) that black pixel is not a shadow. + const std::optional firstFrame = ExtractClxShadows(sprites, FirstFrame); + ASSERT_TRUE(firstFrame.has_value()); + EXPECT_EQ(ToPicture(firstFrame->list()[0]), BarWithoutShadow); + EXPECT_EQ(ToPicture(firstFrame->list()[1]), shortBar); +} + +TEST(ClxShadowTest, BaselineOffset) +{ + // A flying sprite. Its lowest opaque row (row 5) is its shadow. + const Picture picture = { + "......a.", + "......a.", + "......a.", + "........", + "........", + "....#...", + }; + Picture expected = picture; + expected[5][4] = '.'; + const OwnedClxSpriteListOrSheet sprites = MakeList({ picture }); + + // With the baseline on row 5 the sprite only shadows itself, at (6, 2). + const std::optional noOffset = ExtractClxShadows(sprites, FirstFrame); + ASSERT_TRUE(noOffset.has_value()); + EXPECT_EQ(ToPicture(noOffset->list()[0]), picture); + + // With the baseline on row 10, rows 0 and 1 shadow (4, 5). The shadow of row 2 is outside the frame. + const std::optional withOffset = ExtractClxShadows(sprites, ShadowRule { ShadowRuleType::FirstFrame, 5 }); + ASSERT_TRUE(withOffset.has_value()); + EXPECT_EQ(ToPicture(withOffset->list()[0]), expected); + const ShadowMask mask = GetShadowMask(withOffset->list()[0]); + EXPECT_EQ(mask.bottomOffset(), 0); + EXPECT_EQ(mask.numRows(), 1); + EXPECT_EQ(ToPicture(mask, 8, 6), (Picture { "........", "........", "........", "........", "........", "....#..." })); +} + +TEST(ClxShadowTest, PreservesSheetStructure) +{ + const Picture empty(12, std::string(8, '.')); + const OwnedClxSpriteListOrSheet sprites = MakeSheet({ { Bar, Bar }, { empty }, { Bar } }); + const std::optional result = ExtractClxShadows(sprites, FirstFrame); + ASSERT_TRUE(result.has_value()); + + ASSERT_TRUE(result->isSheet()); + const ClxSpriteSheet sheet = result->sheet(); + ASSERT_EQ(sheet.numLists(), 3); + ASSERT_EQ(sheet[0].numSprites(), 2U); + ASSERT_EQ(sheet[1].numSprites(), 1U); + ASSERT_EQ(sheet[2].numSprites(), 1U); + EXPECT_EQ(ToPicture(sheet[0][1]), BarWithoutShadow); + EXPECT_EQ(ToPicture(sheet[1][0]), empty); + EXPECT_EQ(ToPicture(sheet[2][0]), BarWithoutShadow); + + EXPECT_EQ(ToPicture(GetShadowMask(sheet[0][1]), 8, 12), BarShadow); + // A sprite without a shadow has a plain header. + EXPECT_FALSE(HasShadowMask(sheet[1][0])); + EXPECT_EQ(sheet[1][0].headerSize(), ClxSprite::BaseHeaderSize); + EXPECT_EQ(ToPicture(GetShadowMask(sheet[2][0]), 8, 12), BarShadow); +} + +TEST(ClxShadowTest, SplitsLongRunsAndKeepsEmptyRows) +{ + // The baseline is row 8. 'a' (row 0) shadows row 4 shifted left by 1, 'b' (rows 6..8) shadows row 6, + // and nothing shadows row 5. + Picture picture(9, std::string(200, '.')); + for (size_t x = 1; x < 200; ++x) + picture[0][x] = 'a'; + for (size_t x = 0; x < 199; ++x) + picture[4][x] = '#'; + picture[8][10] = 'b'; + picture[7][10] = 'b'; + picture[6][10] = 'b'; + Picture expectedSprite = picture; + expectedSprite[4].assign(200, '.'); + Picture expectedMask(9, std::string(200, '.')); + expectedMask[4].replace(0, 199, std::string(199, '#')); + expectedMask[6][10] = '#'; + + const OwnedClxSpriteListOrSheet sprites = MakeList({ picture }); + const std::optional result = ExtractClxShadows(sprites, FirstFrame); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(ToPicture(result->list()[0]), expectedSprite); + + const ShadowMask mask = GetShadowMask(result->list()[0]); + EXPECT_EQ(mask.bottomOffset(), 2); + EXPECT_EQ(mask.numRows(), 3); + EXPECT_EQ(ToPicture(mask, 200, 9), expectedMask); + // Row 6: one span. Row 5: an empty span. Row 4: 199 pixels as spans of 127 and 72. + const std::vector expectedRows = { 10, 0x80 | 1, 0, 0x80 | 0, 0, 127, 127, 0x80 | 72 }; + EXPECT_EQ(std::vector(mask.rows(), mask.rows() + expectedRows.size()), expectedRows); +} + +/** An 8-bit surface filled with a pattern that has no black in it. */ +SDLSurfaceUniquePtr MakeBackground(int width, int height) +{ + SDLSurfaceUniquePtr surface = SDLWrap::CreateRGBSurfaceWithFormat(/*flags=*/0, width, height, /*depth=*/8, SDL_PIXELFORMAT_INDEX8); + const Surface out { surface.get() }; + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) + *out.at(x, y) = static_cast(1 + (x * 7 + y * 13) % 200); + } + return surface; +} + +std::vector Pixels(const Surface &out) +{ + std::vector result; + for (int y = 0; y < out.h(); ++y) + result.insert(result.end(), out.at(0, y), out.at(0, y) + out.w()); + return result; +} + +// A sprite whose shadow reaches the left edge, with a black body pixel and a body pixel inside the shadow. +const Picture Walker = { + "...........aaa..", + "...........a#a..", + "...........aaa..", + "...........aaa..", + "...........aaa..", + "...........aaa..", + "...........aaa..", + "...........aaa..", + "...........aaa..", + "........###aaa..", + "........###aaa..", + ".........##aaa..", + "...........aaa..", + "...........aaa..", + "...........aaa..", +}; + +TEST(ClxShadowTest, DrawingShadowThenSpriteMatchesTheOriginal) +{ + constexpr int Width = 24, Height = 20; + for (const Picture &picture : { Bar, Walker }) { + const OwnedClxSpriteListOrSheet original = MakeList({ picture }); + const std::optional extracted = ExtractClxShadows(original, FirstFrame); + ASSERT_TRUE(extracted.has_value()); + ASSERT_TRUE(HasShadowMask(extracted->list()[0])); + // The shadow must really have been removed, or this test proves nothing. + ASSERT_NE(ToPicture(extracted->list()[0]), picture); + + // Every position, including those where the sprite is clipped or entirely outside. + const int spriteWidth = static_cast(picture[0].size()); + const int spriteHeight = static_cast(picture.size()); + for (int y = -2; y < Height + spriteHeight + 2; ++y) { + for (int x = -spriteWidth - 2; x < Width + 2; ++x) { + const SDLSurfaceUniquePtr expectedSurface = MakeBackground(Width, Height); + const Surface expected { expectedSurface.get() }; + ClxDraw(expected, { x, y }, original.list()[0]); + + const SDLSurfaceUniquePtr actualSurface = MakeBackground(Width, Height); + const Surface actual { actualSurface.get() }; + ClxDrawShadow(actual, { x, y }, extracted->list()[0]); + ClxDraw(actual, { x, y }, extracted->list()[0]); + + ASSERT_EQ(Pixels(actual), Pixels(expected)) << "at " << x << ", " << y; + } + } + } +} + +TEST(ClxShadowTest, OpaqueRunsThatContinueOnTheNextRow) +{ + // CLX sprites must not have opaque runs that continue on the next row, but sprites that were + // converted before `Cl2ToClx` stopped producing them do. Such a run is taken to continue on the + // next row, as in the image that it was made from, and the result has no such runs. + // A 6x4 sprite, from the bottom row upwards: + // "....ab" and the run goes on with "cd" + // "cd#..." + // "....ee" + // "....e#" + std::vector data(4 * 3 + ClxFrameHeaderSize); + WriteLE32(&data[0], 1); + WriteLE32(&data[4], 12); + WriteLE16(&data[12], ClxFrameHeaderSize); + WriteLE16(&data[14], 6); + WriteLE16(&data[16], 4); + const auto transparent = [&](unsigned width) { AppendClxTransparentRun(width, data); }; + const auto pixels = [&](std::initializer_list colors) { AppendClxPixelsRun(colors.begin(), static_cast(colors.size()), data); }; + transparent(4); + pixels({ 'a', 'b', 'c', 'd' }); + pixels({ 0 }); + transparent(3 + 4); + pixels({ 'e', 'e' }); + transparent(4); + pixels({ 'e', 0 }); + WriteLE32(&data[8], static_cast(data.size())); + std::unique_ptr buffer { new uint8_t[data.size()] }; + std::copy(data.begin(), data.end(), buffer.get()); + const OwnedClxSpriteListOrSheet original { std::move(buffer), 0 }; + + const std::optional extracted = ExtractClxShadows(original, FirstFrame); + ASSERT_TRUE(extracted.has_value()); + const ClxSprite sprite = extracted->list()[0]; + EXPECT_EQ(ToPicture(sprite), (Picture { "....e#", "....ee", "cd#...", "....ab" })); + + // No opaque command of the result goes past the end of its row. + const uint8_t *src = sprite.pixelData(); + const uint8_t *end = src + sprite.pixelDataSize(); + unsigned x = 0; + while (src < end) { + const uint8_t control = *src++; + if (!IsClxOpaque(control)) { + x += control; + } else { + const unsigned width = IsClxOpaqueFill(control) ? GetClxOpaqueFillWidth(control) : GetClxOpaquePixelsWidth(control); + src += IsClxOpaqueFill(control) ? 1 : width; + EXPECT_LE(x + width, 6U); + x += width; + } + x %= 6; + } +} + +TEST(ClxShadowTest, DrawsBlendedShadow) +{ + // A recognisable "blend with black" table: every colour maps to itself plus 50. + for (unsigned i = 0; i < 256; ++i) + paletteTransparencyLookup[0][i] = static_cast(i + 50); +#if DEVILUTIONX_PALETTE_TRANSPARENCY_BLACK_16_LUT + for (unsigned i = 0; i < 256; ++i) { + for (unsigned j = 0; j < 256; ++j) + paletteTransparencyLookupBlack16[i | (j << 8U)] = static_cast(paletteTransparencyLookup[0][i] | (paletteTransparencyLookup[0][j] << 8U)); + } +#endif + + constexpr int Width = 24, Height = 20; + const std::optional extracted = ExtractClxShadows(MakeList({ Walker }), FirstFrame); + ASSERT_TRUE(extracted.has_value()); + const ClxSprite sprite = extracted->list()[0]; + const Picture body = ToPicture(sprite); + const Picture shadow = ToPicture(GetShadowMask(sprite), Walker[0].size(), Walker.size()); + + // Positions that clip the shadow on every side, with odd and even span starts. + for (const Point position : { Point { 3, 16 }, Point { 4, 16 }, Point { -9, 16 }, Point { 12, 10 }, Point { 2, 25 } }) { + const SDLSurfaceUniquePtr backgroundSurface = MakeBackground(Width, Height); + const std::vector background = Pixels(Surface { backgroundSurface.get() }); + + const SDLSurfaceUniquePtr surface = MakeBackground(Width, Height); + const Surface out { surface.get() }; + ClxDrawShadowBlended(out, position, sprite); + ClxDraw(out, position, sprite); + + std::vector expected = background; + for (int spriteY = 0; spriteY < static_cast(Walker.size()); ++spriteY) { + for (int spriteX = 0; spriteX < static_cast(Walker[0].size()); ++spriteX) { + const int x = position.x + spriteX; + const int y = position.y - (static_cast(Walker.size()) - 1 - spriteY); + if (x < 0 || x >= Width || y < 0 || y >= Height) continue; + uint8_t &pixel = expected[static_cast(y) * Width + x]; + if (body[spriteY][spriteX] != '.') { + pixel = body[spriteY][spriteX] == '#' ? 0 : static_cast(body[spriteY][spriteX]); + } else if (shadow[spriteY][spriteX] == '#') { + pixel = static_cast(pixel + 50); + } + } + } + EXPECT_EQ(Pixels(out), expected) << "at " << position.x << ", " << position.y; + } +} + +TEST(ClxShadowTest, NothingToExtractWithoutAKnownRule) +{ + const OwnedClxSpriteListOrSheet sprites = MakeList({ Bar }); + EXPECT_FALSE(ExtractClxShadows(sprites, ShadowRule { ShadowRuleType::Unknown, 0 }).has_value()); + EXPECT_FALSE(ExtractClxShadows(sprites, ShadowRule { ShadowRuleType::None, 0 }).has_value()); + EXPECT_FALSE(ExtractClxShadows(sprites, ShadowRule { ShadowRuleType::Baked, 0 }).has_value()); +} + +} // namespace +} // namespace devilution diff --git a/test/player_test.cpp b/test/player_test.cpp index 4d4108e7f..28ec1bf47 100644 --- a/test/player_test.cpp +++ b/test/player_test.cpp @@ -5,6 +5,8 @@ #include "cursor.h" #include "engine/assets.hpp" #include "init.hpp" +#include "tables/monstdat.h" +#include "tables/objdat.h" #include "tables/playerdat.hpp" using namespace devilution; @@ -187,6 +189,42 @@ static void AssertPlayer(devilution::Player &player) ASSERT_EQ(player._pILMaxDam, 0); } +TEST(Player, ShadowRulesAreLoaded) +{ + LoadCoreArchives(); + LoadPlayerDataFiles(); + LoadMonsterData(); + LoadObjectData(); + + // One of the warrior's sprite files does not reproduce exactly, which the tags record. + EXPECT_EQ(GetPlayerSpriteDataForClass(HeroClass::Warrior).shadowRule, (ShadowRule { ShadowRuleType::FirstFrame, 0, ShadowRuleFlags::Unexplained | ShadowRuleFlags::Stray })); + EXPECT_EQ(GetPlayerSpriteDataForClass(HeroClass::Rogue).shadowRule, (ShadowRule { ShadowRuleType::FirstFrame, 0 })); + EXPECT_EQ(GetPlayerSpriteDataForClass(HeroClass::Monk).shadowRule, (ShadowRule { ShadowRuleType::EachFrame, 0 })); + // Sprites that were never analysed have an empty value. + EXPECT_EQ(GetPlayerSpriteDataForClass(HeroClass::Bard).shadowRule, (ShadowRule { ShadowRuleType::Unknown, 0 })); + + // One rule per animation: stand, walk, attack, hit, death, special. + const MonsterData &zombie = MonstersData[MT_NZOMBIE]; + EXPECT_EQ(zombie.shadowRules[0], (ShadowRule { ShadowRuleType::FirstFrame, 0 })); + EXPECT_EQ(zombie.shadowRules[5], (ShadowRule { ShadowRuleType::None, 0 })); + // Flying monsters have their baseline below the sprite, except when they fall to the ground. + const MonsterData &fiend = MonstersData[MT_FIEND]; + EXPECT_EQ(fiend.shadowRules[0], (ShadowRule { ShadowRuleType::FirstFrame, 15 })); + EXPECT_EQ(fiend.shadowRules[4], (ShadowRule { ShadowRuleType::FirstFrame, 0 })); + const MonsterData &golem = MonstersData[MT_GOLEM]; + EXPECT_EQ(golem.shadowRules[1], (ShadowRule { ShadowRuleType::EachFrame, 0 })); + // Monsters whose sprites are missing from the game data have an empty value. + EXPECT_EQ(MonstersData[MT_WYRM].shadowRules[0], (ShadowRule { ShadowRuleType::Unknown, 0 })); + + EXPECT_EQ(AllObjects[OBJ_BARREL].shadowRule, (ShadowRule { ShadowRuleType::FirstFrame, 0 })); + EXPECT_EQ(AllObjects[OBJ_CHEST1].shadowRule, (ShadowRule { ShadowRuleType::EachFrame, 0 })); + // The cauldron's shadow cannot be told apart from its black side. + EXPECT_EQ(AllObjects[OBJ_CAULDRON].shadowRule, (ShadowRule { ShadowRuleType::Baked, 0 })); + EXPECT_EQ(AllObjects[OBJ_L1LIGHT].shadowRule, (ShadowRule { ShadowRuleType::None, 0 })); + // Doors have black pixels but no cast shadow. + EXPECT_EQ(AllObjects[OBJ_L1LDOOR].shadowRule, (ShadowRule { ShadowRuleType::None, 0 })); +} + TEST(Player, CreatePlayer) { LoadCoreArchives(); diff --git a/test/shadow_rule_test.cpp b/test/shadow_rule_test.cpp new file mode 100644 index 000000000..0f3c4d308 --- /dev/null +++ b/test/shadow_rule_test.cpp @@ -0,0 +1,67 @@ +#include + +#include "engine/shadow_rule.hpp" + +namespace devilution { +namespace { + +TEST(ShadowRuleTest, ParsesTypes) +{ + EXPECT_EQ(ParseShadowRule(""), (ShadowRule { ShadowRuleType::Unknown, 0 })); + EXPECT_EQ(ParseShadowRule("Unknown"), (ShadowRule { ShadowRuleType::Unknown, 0 })); + EXPECT_EQ(ParseShadowRule("None"), (ShadowRule { ShadowRuleType::None, 0 })); + EXPECT_EQ(ParseShadowRule("Baked"), (ShadowRule { ShadowRuleType::Baked, 0 })); + EXPECT_EQ(ParseShadowRule("FirstFrame"), (ShadowRule { ShadowRuleType::FirstFrame, 0 })); + EXPECT_EQ(ParseShadowRule("EachFrame"), (ShadowRule { ShadowRuleType::EachFrame, 0 })); +} + +TEST(ShadowRuleTest, ParsesBaselineOffset) +{ + EXPECT_EQ(ParseShadowRule("FirstFrame+15"), (ShadowRule { ShadowRuleType::FirstFrame, 15 })); + EXPECT_EQ(ParseShadowRule("FirstFrame+255"), (ShadowRule { ShadowRuleType::FirstFrame, 255 })); +} + +TEST(ShadowRuleTest, ParsesTags) +{ + EXPECT_EQ(ParseShadowRule("FirstFrame|Stray"), (ShadowRule { ShadowRuleType::FirstFrame, 0, ShadowRuleFlags::Stray })); + EXPECT_EQ(ParseShadowRule("EachFrame|Unexplained"), (ShadowRule { ShadowRuleType::EachFrame, 0, ShadowRuleFlags::Unexplained })); + EXPECT_EQ(ParseShadowRule("FirstFrame|Unexplained|Stray"), (ShadowRule { ShadowRuleType::FirstFrame, 0, ShadowRuleFlags::Unexplained | ShadowRuleFlags::Stray })); + EXPECT_EQ(ParseShadowRule("FirstFrame+15|Stray"), (ShadowRule { ShadowRuleType::FirstFrame, 15, ShadowRuleFlags::Stray })); + + const ShadowRule rule = *ParseShadowRule("FirstFrame|Stray"); + EXPECT_TRUE(HasAnyOf(rule.flags, ShadowRuleFlags::Stray)); + EXPECT_FALSE(HasAnyOf(rule.flags, ShadowRuleFlags::Unexplained)); + // The tags are notes: the shadow is extracted all the same. + EXPECT_TRUE(rule.canExtractShadow()); + EXPECT_EQ(ParseShadowRule("FirstFrame")->flags, ShadowRuleFlags::None); +} + +TEST(ShadowRuleTest, RejectsInvalidValues) +{ + EXPECT_FALSE(ParseShadowRule("firstframe").has_value()); + EXPECT_FALSE(ParseShadowRule("FirstFrame+").has_value()); + EXPECT_FALSE(ParseShadowRule("FirstFrame+0").has_value()); + EXPECT_FALSE(ParseShadowRule("FirstFrame+256").has_value()); + EXPECT_FALSE(ParseShadowRule("FirstFrame+1x").has_value()); + EXPECT_FALSE(ParseShadowRule("FirstFrame-1").has_value()); + EXPECT_FALSE(ParseShadowRule("EachFrame+1").has_value()); + EXPECT_FALSE(ParseShadowRule("FirstFrame|").has_value()); + EXPECT_FALSE(ParseShadowRule("FirstFrame|stray").has_value()); + EXPECT_FALSE(ParseShadowRule("FirstFrame|Stray|Other").has_value()); + EXPECT_FALSE(ParseShadowRule("Stray").has_value()); + // A comma separates the rules of a list, not a rule from its tags. + EXPECT_FALSE(ParseShadowRule("FirstFrame,Stray").has_value()); +} + +TEST(ShadowRuleTest, CanExtractShadow) +{ + EXPECT_FALSE(ShadowRule {}.canExtractShadow()); + EXPECT_FALSE((ShadowRule { ShadowRuleType::None, 0 }).canExtractShadow()); + // A baked shadow is known but stays part of the sprite. + EXPECT_FALSE((ShadowRule { ShadowRuleType::Baked, 0 }).canExtractShadow()); + EXPECT_TRUE((ShadowRule { ShadowRuleType::FirstFrame, 11 }).canExtractShadow()); + EXPECT_TRUE((ShadowRule { ShadowRuleType::EachFrame, 0 }).canExtractShadow()); +} + +} // namespace +} // namespace devilution diff --git a/test/townerdat_test.cpp b/test/townerdat_test.cpp index 52343ca5f..bfb4bf3ea 100644 --- a/test/townerdat_test.cpp +++ b/test/townerdat_test.cpp @@ -83,6 +83,12 @@ TEST(TownerDat, LoadTownerData) ASSERT_GE(smith->animOrder.size(), 4u); EXPECT_EQ(smith->animOrder[0], 4); EXPECT_EQ(smith->animOrder[3], 7); + EXPECT_EQ(smith->shadowRule, (ShadowRule { ShadowRuleType::FirstFrame, 0 })); + + // Wirt's shadow has a few pixels that the rule does not account for. + const TownerDataEntry *pegBoy = FindTownerDataByType(TOWN_PEGBOY); + ASSERT_NE(pegBoy, nullptr) << "Should find TOWN_PEGBOY data"; + EXPECT_EQ(pegBoy->shadowRule, (ShadowRule { ShadowRuleType::FirstFrame, 0, ShadowRuleFlags::Stray })); // Check Pepin (TOWN_HEALER) const TownerDataEntry *healer = FindTownerDataByType(TOWN_HEALER); diff --git a/tools/shadow_search/CMakeLists.txt b/tools/shadow_search/CMakeLists.txt new file mode 100644 index 000000000..bfe8dc138 --- /dev/null +++ b/tools/shadow_search/CMakeLists.txt @@ -0,0 +1,30 @@ +# Research tool: searches for the transform that was used to generate +# the baked-in shadows of the original player sprites. +find_package(ZLIB REQUIRED) +add_executable(shadow_search shadow_search.cpp) +set_target_properties(shadow_search PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}) +target_include_directories(shadow_search PRIVATE "${PROJECT_SOURCE_DIR}/Source") +target_link_dependencies(shadow_search + PRIVATE + tl + libdevilutionx_cel_to_clx + libdevilutionx_cl2_to_clx + libdevilutionx_mpq + ZLIB::ZLIB +) + +# Checks the game's shadow extraction (utils/clx_shadow.hpp) against the original sprites. +add_executable(shadow_extract_check extract_check.cpp) +set_target_properties(shadow_extract_check PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}) +target_include_directories(shadow_extract_check PRIVATE "${PROJECT_SOURCE_DIR}/Source") +target_link_dependencies(shadow_extract_check + PRIVATE + tl + libdevilutionx_cel_to_clx + libdevilutionx_cl2_to_clx + DevilutionX::SDL + libdevilutionx_clx_render + libdevilutionx_clx_shadow + libdevilutionx_mpq + libdevilutionx_surface +) diff --git a/tools/shadow_search/demo_template.html b/tools/shadow_search/demo_template.html new file mode 100644 index 000000000..4fdc63a6e --- /dev/null +++ b/tools/shadow_search/demo_template.html @@ -0,0 +1,606 @@ +Diablo Sprite Shadows + + + +
+
+
+

DevilutionX research notes

+

How Diablo drew its sprite shadows

+ +

The shadow under every Diablo character is part of its sprite, stored as black pixels. Each shadow is a copy of the sprite's own outline, squashed to a third of its height and sheared to the left, so a few lines of integer arithmetic can recompute it from the rest of the frame. The same function works for players, monsters, townspeople and dungeon objects, in both Diablo and the Hellfire expansion.

+
+
+ +
Diablo's walk animation as stored in the game data. The black shape at his feet is part of each frame.
+
+
+ +
+
139,943
frames checked, from 1,438 sprite files in Diablo and Hellfire
+
99.77%
of the 136,936 frames with a shadow are reproduced pixel for pixel
+
763 of 765
original player files in which every frame is reproduced exactly
+
30,744 of 30,744
frames of the Hellfire monk reproduced exactly
+
3
rules for placing the ground line, which is the function's only input besides the sprite
+
+ +
+
+

The shadows were added in 2D

+

Diablo's characters and monsters were modelled and animated in 3D, then rendered to small 2D frames, one set per facing direction. The game ships only those frames. Each one is a grid of palette indices, and its shadow is part of it, as a patch of pure black pixels next to the feet.

+

These shadows did not come from the 3D renderer. A rendered shadow would depend on the depth of the model, and the ones in the game files can be computed from the flat frame alone. Each is an affine transform of the 2D sprite: the silhouette is sheared to the left, squashed to a third of its height and filled with black. Done in integer arithmetic, with the rounding described in the next section, the transform matches the game's art to the pixel.

+

It is applied to every pixel that is not transparent, including black pixels of the body such as dark boots, so it must have run on the finished 2D image.

+
+
+ +
The same animation with the shadow removed. The function recomputes the shadow from these pixels alone.
+
+
+ +
+
+

The function

+

The function takes the sprite and one number: B, the row of the ground line that the shadow is measured from. Rows are numbered downwards from the top of the frame.

+
+
// B: the row of the ground line, chosen by one of the rules further down.
+// Called for every pixel (x, y) of the shadowless sprite with y <= B.
+// Pixels below B cast nothing. Both divisions are integer divisions.
+int v  = (B - y) / 3;        // three sprite rows collapse into one shadow row
+int ys = B - 2 - v;          // the shadow starts two rows above the ground line
+int xs = x - (3 * v) / 4;    // sheared left by 3/4 px per shadow row
+shadow[ys][xs] = BLACK;      // the sprite is then drawn on top
+
+

In matrix form, with h = B - y for the height of a pixel above the ground line, this is a shear combined with a vertical squash:

+
+
[ x' ]   [ 1  -1/4 ] [ x ]
+[ h' ] = [ 0   1/3 ] [ h ]        y' = B - 2 - h'
+
+

A pixel moves left by a quarter of its height and keeps a third of its height. The matrix only approximates the code, which rounds twice: it first reduces the height to a whole shadow row and then computes the shift from that row. Rounding the matrix product once puts some pixels in a different column, up to 1.25 pixels away. Only the two-step integer version matches the game's art exactly.

+
+
+ +
+
+

Try it on the original sprites

+

This page runs the function in your browser. For each original frame it removes the shadow, casts a new one from what is left, and compares the result with the original.

+
+ +
+ + + +
+
+
OriginalAs stored in the game data
+
Shadow removedThe frame as it was before the shadow pass
+
ReproducedShadow cast by the function, sprite drawn on top
+
Where each black pixel comes from 
+
+
    +
  • shadow, reproduced
  • +
  • black pixel of the body
  • +
  • unexplained: black pixel that nothing in the sprite casts
  • +
  • stray: shadow cast onto a transparent pixel
  • +
  • rest of the body
  • +
+
+
+ + + +
DirectionFramesGround line ruleRow BBlack pixelsReproduced shadowBody blackUnexplainedStrayExact frames
+
+
+

Five sprites are in the list because the function does not fully explain them: the warrior with only a shield, standing and walking in town, Wirt, Na-Krul's death animation, and the cauldron. They are covered under What is not reproduced. Every sprite is drawn with the town palette, so the colours of dungeon monsters may be slightly off.

+
+
+ +
+
+

How the ground line is chosen

+

The ground line is always derived from the art, in one of three ways, and the choice differs from file to file. The table counts animation directions of the original game's monsters.

+
+
+ + + + + + + + +
Ground line row BMonster directionsUsed by
Lowest opaque row of the first frame1,223all original players, most monsters, townspeople, barrels, levers, sarcophagi
Lowest opaque row of each frame75golem, some Diablo, mage, snake and mega demon animations, chests, books, nearly all Hellfire art
First frame's lowest row plus a constant72flying monsters: bat +15 rows, gargoyle +11
No shadow in the art80two monsters with no black pixels at all
+
+
+

Under the first-frame rule the shadow stays where it is while the body moves. Under the each-frame rule it follows a body that bobs up and down, as the golem's does.

+

For flying monsters the ground line lies below the sprite, which separates the shadow from the body. The offsets of 15 and 11 rows make more sense when measured from the body. The ground line is exactly 40 rows below the lowest non-black row of the first frame for the bat, in all 32 directions checked, and 28 rows below it for the gargoyle, in all 40. In those first frames the lowest opaque row is the bottom of the shadow, not of the body, which is why the offset from it comes out as +15 and +11.

+

We found no property of a sprite that predicts which rule its file uses. Animations of the same monster can use different rules, and no single rule covers both the first-frame and the each-frame files. Our guess is that the choice reflects how each file was processed at the time.

+
+
+ +
+

Hellfire

+

Hellfire was made by a different studio a year later, and it uses the same function. Its ground line is almost always the lowest opaque row of each frame, where the original game mostly uses the first frame.

+
    +
  • The monk has 291 files and 30,744 frames. With the each-frame rule every frame is reproduced pixel for pixel. The first-frame rule works only where the bottom row never moves, which is the case in 536 of 2,328 directions.
  • +
  • Of the 125 Hellfire monster files checked, 114 have a shadow. All 114 fit the each-frame rule with no stray pixels, and none needs the first-frame rule. Four of them leave unexplained black pixels, 4,815 in total, nearly all in two Na-Krul animations.
  • +
  • The other 11 are death animations with no cast shadow. Six have no black pixels at all. In the other five the few black pixels are painted detail: Hork Spawn, Hell Bug, Hellbat, Arch Lich and Hellfire's Satyr Lord. Black makes up at most 0.5% of their pixels, against 15% or more in death animations that have a shadow.
  • +
+
+ +
+
+

Results by sprite set

+

Each row is one set of sprite files, measured with the ground line rule that the DevilutionX data files record for each file. The rule column counts files. The table above counts monster animation directions, so the two do not match.

+
+
+ + + + + + + + + + + + + +
Sprite setFilesFramesFiles with a shadowFirst / each / first + offsetFiles with no shadowFrames with a shadow reproduced exactlyStray pixelsUnexplained pixels
Players, original game76578,016765765 / 0 / 0077,872 of 78,016 (99.82%)7711,333
Monk, Hellfire29130,7442910 / 291 / 0030,744 of 30,744 (100%)00
Monsters, original game18318,088173151 / 13 / 91016,912 of 16,912 (100%)00
Monsters, Hellfire12512,4241141 / 113 / 01110,717 of 10,872 (98.57%)04,815
Townspeople, original game916088 / 0 / 01132 of 152 (86.84%)380
Townspeople, Hellfire35033 / 0 / 0050 of 50 (100%)00
Objects, original game533962815 / 13 / 025161 of 161 (100%)00
Objects, Hellfire96531 / 2 / 0629 of 29 (100%)00
All sprite sets1,438139,9431,385944 / 432 / 953136,617 of 136,936 (99.77%)8096,148
+
+
+
    +
  • A frame is reproduced exactly when removing the shadow, casting a new one with the function and drawing the body on top gives back the original frame, pixel for pixel. Only frames of files with a shadow are counted.
  • +
  • Stray pixels are transparent pixels that the function casts a shadow onto. Each pixel is counted once.
  • +
  • Unexplained pixels are black pixels that are neither reproduced shadow nor body. Nothing left in the sprite casts them, and if they were body pixels they would cast a stray shadow themselves.
  • +
  • The rule for each file is the one recorded in the DevilutionX data files. A file recorded as having no shadow is counted that way even if it has black pixels. This applies to 13 files: five Hellfire death animations, six original objects and two Hellfire objects.
  • +
  • The Hellfire rows count only the files found in the Hellfire archives. The Hellfire object row uses the same object list as the original game.
  • +
+
+
+ +
+
+

What is not reproduced

+

Of the 136,936 frames with a shadow, 319 are not reproduced exactly. They fall into four groups. A fifth case, the cauldron, is reproduced exactly but cannot be split cleanly into body and shadow. The sprites on the right are drawn as stored, with unexplained pixels marked in pink and stray pixels in red.

+
+
+
+

The warrior with only a shield, standing in town in heavy armour. This file leaves 771 stray and 1,318 unexplained pixels over its eight directions, and we have not found the reason. Five directions fit best with the normal ground line and three with one a row higher, but no direction is exact. The errors are on the edges of the shield's shadow. We tried and ruled out a different ground line per frame, a shifted shadow, casting from another frame or another weapon variant, a separately shadowed shield layer with its own ground line or position, and two ground lines split by row. Our best guess is that the shield was redrawn after the shadow pass, but we have no direct evidence for it.

+
+
+ +
Facing north-east, the direction with the most errors.
+
+
+
+
+

The same warrior walking in light armour. One direction leaves 15 unexplained pixels, two or three per frame on the upper left edge of the shadow, in seven of its eight frames.

+
+
+ +
Facing north-west, the only direction with errors.
+
+
+
+
+

Wirt. He fits the first-frame rule except for 38 stray pixels over his 20 frames. They are cast by 44 pixels on the edge of his outline, one of them next to the peg leg in every frame. No other ground line does better. Those edge pixels were probably added after the shadow pass, though again we have no direct evidence.

+
+
+ +
Wirt has a single view. Every frame has one to four stray pixels.
+
+
+
+
+

Four Hellfire monster files. They have no stray pixels but leave 4,815 unexplained black pixels, 4,787 of them in Na-Krul's death and special animations. We have not investigated these yet.

+
+
+ +
Na-Krul's death animation, facing south.
+
+
+
+
+

The cauldron. All three frames count as reproduced exactly, but the split into body and shadow is wrong. The pot is nearly black: 664 to 802 of its pixels per frame are pure black, and 365 of them lie where the pixels above them cast a shadow. A black body pixel in that position cannot be told apart from shadow, so the function takes the lower part of the pot for shadow, and removing the shadow removes it too.

+
+
+ +
The cauldron with the shadow removed. The gaps in its base are body pixels that were taken for shadow.
+
+
+
+

Some sprites have black pixels and no cast shadow at all. In the original game these are two door types, the bookcase, the trap hole and the mushroom patch. Their black pixels are painted cracks, outlines and holes, and the area where a shadow would fall is empty. The fountain of tears has eight frames: the four that the game plays have no black pixels, and the other four carry a shadow that the function reproduces exactly. Hellfire's two pod objects have four black pixels each and no shadow.

+
+
+ +
+

How it was found

+
    +
  • The starting point was Joan Karadimov's description of the transform and an approximate matrix for it, with a height scale near 1/3 and a shear near 0.26, posted in a comment on DevilutionX issue #8718. Fitting a single transform, in several number formats, got close but never exact.
  • +
  • The next pass assumed no formula. For each sprite row it searched for the shadow row and horizontal shift that never put a shadow pixel on a transparent one. Across all frames exactly one combination survived per row, and together they form the table that the function above generates.
  • +
  • The ground line was first taken to be the lowest non-black row, which fits the shareware warrior. The rogue's heavy armour has black boots, and for it only the lowest opaque row works.
  • +
  • A black body pixel that lies inside the shadow of the pixels above it cannot be told apart from shadow. Where such a pixel is in the right place to cast an otherwise unexplained shadow pixel, the page counts it as body. In the original player sprites that leaves 1,333 black pixels unexplained, out of 23 million.
  • +
  • Monsters and objects were classified by the rule under which their non-black pixels cast no stray shadow. Objects are weaker evidence because most have only a few frames. Of 34 original objects with black pixels, 15 fit the first-frame rule and 13 the each-frame rule.
  • +
+
+
+ + diff --git a/tools/shadow_search/extract_check.cpp b/tools/shadow_search/extract_check.cpp new file mode 100644 index 000000000..0375b3c8f --- /dev/null +++ b/tools/shadow_search/extract_check.cpp @@ -0,0 +1,675 @@ +/** + * @file extract_check.cpp + * + * Runs the game's `ExtractClxShadows` on every player sprite of an MPQ and + * checks the result against the original sprites: + * + * - only black pixels are removed from a sprite + * - drawing the new sprite over its shadow mask filled with black gives back + * the original + * + * Prints the number of pixels removed as shadow, to compare with the research + * tool's `verify` command ("reproduced as shadow"). + * + * Also reports the size of the shadow masks, which are stored in the CLX frame + * headers. + * + * Usage: shadow_extract_check + * [--monsters|--towners|--objects][=] + * + * The rule is either one rule for every file (e.g. `FirstFrame`) or `@` + * followed by the output of `shadow_search ... filerules` (path and rule per + * line). Files without a shadow are skipped. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "engine/render/clx_render.hpp" +#include "engine/surface.hpp" +#include "sprites.hpp" +#include "utils/clx_shadow.hpp" +#include "utils/palette_blending.hpp" +#include "utils/sdl_wrap.h" + +namespace devilution { + +// The game library reports fatal errors through this function. +[[noreturn]] void app_fatal(std::string_view str) { + std::fprintf(stderr, "fatal: %.*s\n", static_cast(str.size()), + str.data()); + std::abort(); +} + +// Called by the SDL wrappers when SDL fails. +[[noreturn]] void ErrDlg(const char *title, std::string_view error, + std::string_view /*logFilePath*/, int /*logLineNr*/) { + std::fprintf(stderr, "%s: %.*s\n", title, static_cast(error.size()), + error.data()); + std::abort(); +} + +} // namespace devilution + +int main(int argc, char **argv) { + using namespace shadow_search; + if (argc < 3) { + std::fprintf(stderr, + "usage: %s " + "[--monsters|--towners|--objects][=]\n", + argv[0]); + return 2; + } + auto archive = devilution::MpqArchive::Open(argv[1]); + if (!archive) { + std::fprintf(stderr, "failed to open %s: %s\n", argv[1], + archive.error().c_str()); + return 1; + } + + // `trn0 ...`: prints what each colour translation table maps black + // (index 0) to. A shadow drawn separately as black only matches the baked-in + // one if that is 0. + if (std::string(argv[2]) == "trn0") { + int nonZero = 0, found = 0; + for (int i = 3; i < argc; ++i) { + size_t size = 0; + const std::unique_ptr data = + archive->HasFile(argv[i]) ? ReadMpqFile(*archive, argv[i], size) + : nullptr; + if (data == nullptr || size == 0) + continue; + ++found; + if (data[0] != 0) { + ++nonZero; + std::printf("%s maps black to %d\n", argv[i], data[0]); + } + } + std::printf("%d tables found, %d map black to another colour\n", found, + nonZero); + return 0; + } + + // `overrun-cl2`: the same count as `overrun` but on the raw CL2 commands, + // before conversion to CLX. + if (std::string(argv[2]) == "overrun-cl2") { + long long frames = 0, framesWithOverrun = 0, rowEndsOpaqueThenOpaque = 0; + const SpriteSet players = LoadPlayerSprites(*archive); + for (const SpriteFile &file : players.files) { + size_t size = 0; + const std::unique_ptr data = + ReadMpqFile(*archive, file.path, size); + const auto le32 = [&](size_t pos) { + return static_cast( + data[pos] | (data[pos + 1] << 8) | (data[pos + 2] << 16) | + (static_cast(data[pos + 3]) << 24)); + }; + const uint32_t first = le32(0); + const uint32_t numGroups = le32(first * 4 + 4) != size ? first / 4 : 1; + for (uint32_t g = 0; g < numGroups; ++g) { + const size_t group = numGroups == 1 ? 0 : le32(g * 4); + const uint32_t numFrames = le32(group); + for (uint32_t f = 0; f < numFrames; ++f) { + const uint8_t *begin = &data[group + le32(group + 4 * (f + 1))]; + const uint8_t *end = &data[group + le32(group + 4 * (f + 2))]; + const uint8_t *src = begin + (begin[0] | (begin[1] << 8)); + long long x = 0; + bool any = false, prevOpaqueEndedRow = false; + ++frames; + while (src < end) { + const uint8_t control = *src++; + long long len; + bool opaque = true; + if (!devilution::IsClxOpaque(control)) { + len = control; + opaque = false; + } else if (devilution::IsClxOpaqueFill(control)) { + len = devilution::GetClxOpaqueFillWidth(control); + ++src; + } else { + len = devilution::GetClxOpaquePixelsWidth(control); + src += len; + } + if (opaque && x + len > file.width) + any = true; + // An opaque run that ends exactly at the row end, followed by an + // opaque run: the converter joins the two. + if (opaque && prevOpaqueEndedRow) + ++rowEndsOpaqueThenOpaque; + prevOpaqueEndedRow = opaque && x + len == file.width; + x = (x + len) % file.width; + } + if (any) + ++framesWithOverrun; + } + } + } + std::printf("%lld raw CL2 frames, %lld with an opaque run past the end of " + "a row, %lld places where an opaque run ends a row and another " + "begins the next\n", + frames, framesWithOverrun, rowEndsOpaqueThenOpaque); + return 0; + } + + // `overrun`: counts opaque runs that continue past the end of a sprite row in + // the player sprites. The renderer draws such a run on to the right, outside + // of the sprite, instead of wrapping it. + if (std::string(argv[2]) == "overrun") { + long long sprites = 0, spritesWithOverrun = 0, overrunPixels = 0; + std::string example; + const SpriteSet players = LoadPlayerSprites(*archive); + for (const SpriteFile &file : players.files) { + const int width = file.width; + size_t size = 0; + std::unique_ptr data = ReadMpqFile(*archive, file.path, size); + const devilution::OwnedClxSpriteListOrSheet clx = devilution::Cl2ToClx( + std::move(data), size, + devilution::PointerOrValue{static_cast(width)}); + long long fileOverrun = 0; + for (uint16_t l = 0; l < clx.numLists(); ++l) { + const ClxSpriteList list = clx.sheet()[l]; + for (uint32_t i = 0; i < list.numSprites(); ++i) { + const ClxSprite sprite = list[i]; + ++sprites; + const uint8_t *src = sprite.pixelData(); + const uint8_t *end = src + sprite.pixelDataSize(); + long long x = 0; + bool any = false; + while (src < end) { + const uint8_t control = *src++; + long long len; + bool opaque = true; + if (!devilution::IsClxOpaque(control)) { + len = control; + opaque = false; + } else if (devilution::IsClxOpaqueFill(control)) { + len = devilution::GetClxOpaqueFillWidth(control); + ++src; + } else { + len = devilution::GetClxOpaquePixelsWidth(control); + src += len; + } + if (opaque && x + len > width) { + any = true; + fileOverrun += x + len - width; + } + x = (x + len) % width; + } + if (any) + ++spritesWithOverrun; + } + } + overrunPixels += fileOverrun; + if (fileOverrun != 0 && example.empty()) + example = file.path; + } + std::printf("%lld sprites, %lld with an opaque run past the end of a row, " + "%lld such pixels, e.g. %s\n", + sprites, spritesWithOverrun, overrunPixels, example.c_str()); + return 0; + } + + // `render [--monsters...]`: times drawing every sprite + // frame with its shadow baked in (as before shadow extraction) against + // drawing the shadow mask and then the sprite. + if (std::string(argv[2]) == "render" && argc >= 4) { + using Clock = std::chrono::steady_clock; + std::map renderRules; + std::string renderRuleForAll = argv[3]; + if (renderRuleForAll[0] == '@') { + for (const auto &row : ReadTsv(renderRuleForAll.substr(1))) + if (row.size() >= 2) + renderRules[row[0]] = row[1]; + renderRuleForAll.clear(); + } + SpriteSet renderSet; + const std::string renderSelector = argc >= 5 ? argv[4] : ""; + if (renderSelector.empty()) { + renderSet = LoadPlayerSprites(*archive); + } else { + const size_t eq = renderSelector.find('='); + const std::string kind = renderSelector.substr( + 2, eq == std::string::npos ? std::string::npos : eq - 2); + const std::string defaultTsv = kind == "monsters" ? "monstdat" + : kind == "objects" ? "objdat" + : "towners"; + const std::string tsv = + eq == std::string::npos + ? "assets/txtdata/" + kind + "/" + defaultTsv + ".tsv" + : renderSelector.substr(eq + 1); + renderSet = kind == "monsters" ? LoadMonsterSprites(*archive, tsv) + : kind == "objects" ? LoadObjectSprites(*archive, tsv) + : LoadTownerSprites(*archive, tsv); + } + renderSet.frames.clear(); + + // Every frame of every file that has an extractable shadow, before and + // after extraction. + std::vector owned; + std::vector> sprites; + for (const SpriteFile &file : renderSet.files) { + const auto rule = devilution::ParseShadowRule( + renderRuleForAll.empty() ? renderRules[file.path] : renderRuleForAll); + if (!rule) + return 2; + if (!rule->canExtractShadow()) + continue; + size_t size = 0; + std::unique_ptr data = ReadMpqFile(*archive, file.path, size); + const devilution::PointerOrValue width{ + static_cast(file.width)}; + const bool isCel = + file.path.size() > 4 && + file.path.compare(file.path.size() - 4, 4, ".cel") == 0; + owned.push_back(isCel + ? devilution::CelToClx(data.get(), size, width) + : devilution::Cl2ToClx(std::move(data), size, width)); + const size_t originalIndex = owned.size() - 1; + std::optional extracted = + devilution::ExtractClxShadows(owned[originalIndex], *rule); + if (!extracted.has_value()) + continue; + owned.push_back(std::move(*extracted)); + const devilution::OwnedClxSpriteListOrSheet &before = + owned[originalIndex]; + const devilution::OwnedClxSpriteListOrSheet &after = owned.back(); + const auto addList = [&](ClxSpriteList a, ClxSpriteList b) { + for (uint32_t i = 0; i < a.numSprites(); ++i) + sprites.emplace_back(a[i], b[i]); + }; + if (before.isSheet()) { + for (uint16_t l = 0; l < before.numLists(); ++l) + addList(before.sheet()[l], after.sheet()[l]); + } else { + addList(before.list(), after.list()); + } + } + + const devilution::SDLSurfaceUniquePtr sdlSurface = + devilution::SDLWrap::CreateRGBSurfaceWithFormat( + /*flags=*/0, /*width=*/640, /*height=*/480, /*depth=*/8, + SDL_PIXELFORMAT_INDEX8); + const devilution::Surface out{sdlSurface.get()}; + // A light table, as used for most sprites in the dungeon. Black stays + // black. + uint8_t lightTable[256]; + for (unsigned i = 0; i < 256; ++i) + lightTable[i] = static_cast(i == 0 ? 0 : 1 + (i * 7) % 255); + for (unsigned i = 0; i < 256; ++i) + devilution::paletteTransparencyLookup[0][i] = static_cast(i / 2); +#if DEVILUTIONX_PALETTE_TRANSPARENCY_BLACK_16_LUT + for (unsigned i = 0; i < 256; ++i) + for (unsigned j = 0; j < 256; ++j) + devilution::paletteTransparencyLookupBlack16[i | (j << 8U)] = + static_cast( + devilution::paletteTransparencyLookup[0][i] | + (devilution::paletteTransparencyLookup[0][j] << 8U)); +#endif + + // Fully on screen, away from 4-byte alignment. + const devilution::Point position{201, 400}; + constexpr int Passes = 7; + const int repeats = static_cast( + std::max(1, 2000000 / std::max(1, sprites.size()))); + const auto measure = [&](auto &&draw) { + double best = 1e30; + for (int pass = 0; pass < Passes; ++pass) { + const Clock::time_point t0 = Clock::now(); + for (int r = 0; r < repeats; ++r) + for (const auto &[before, after] : sprites) + draw(before, after); + best = std::min( + best, std::chrono::duration(Clock::now() - t0) + .count()); + } + return best / (static_cast(repeats) * sprites.size()); + }; + struct Variant { + const char *name; + double direct, lit; + }; + using devilution::ClxDraw; + using devilution::ClxDrawShadow; + using devilution::ClxDrawShadowBlended; + using devilution::ClxDrawTRN; + const Variant variants[] = { + {"baked shadow (before)", measure([&](ClxSprite before, ClxSprite) { + ClxDraw(out, position, before); + }), + measure([&](ClxSprite before, ClxSprite) { + ClxDrawTRN(out, position, before, lightTable); + })}, + {"separate shadow, opaque", measure([&](ClxSprite, ClxSprite after) { + ClxDrawShadow(out, position, after); + ClxDraw(out, position, after); + }), + measure([&](ClxSprite, ClxSprite after) { + ClxDrawShadow(out, position, after); + ClxDrawTRN(out, position, after, lightTable); + })}, + {"separate shadow, transparent", + measure([&](ClxSprite, ClxSprite after) { + ClxDrawShadowBlended(out, position, after); + ClxDraw(out, position, after); + }), + measure([&](ClxSprite, ClxSprite after) { + ClxDrawShadowBlended(out, position, after); + ClxDrawTRN(out, position, after, lightTable); + })}, + {"sprite only, no shadow drawn", + measure([&](ClxSprite, ClxSprite after) { + ClxDraw(out, position, after); + }), + measure([&](ClxSprite, ClxSprite after) { + ClxDrawTRN(out, position, after, lightTable); + })}, + }; + std::printf("%zu sprite frames, %d repeats, best of %d passes; ns per " + "sprite drawn\n", + sprites.size(), repeats, Passes); + std::printf("%-30s %12s %9s %14s %9s\n", "", "no lighting", "", + "light table", ""); + for (const Variant &v : variants) + std::printf("%-30s %9.1f ns %+8.1f%% %11.1f ns %+8.1f%%\n", v.name, + v.direct, 100.0 * (v.direct / variants[0].direct - 1), v.lit, + 100.0 * (v.lit / variants[0].lit - 1)); + return 0; + } + + // `timing [--monsters...]`: times the CL2/CEL conversion + // that the game already does when it loads sprites against the shadow + // extraction, per sprite file, on one thread. + if (std::string(argv[2]) == "timing" && argc >= 4) { + using Clock = std::chrono::steady_clock; + std::map timingRules; + std::string timingRuleForAll = argv[3]; + if (timingRuleForAll[0] == '@') { + for (const auto &row : ReadTsv(timingRuleForAll.substr(1))) + if (row.size() >= 2) + timingRules[row[0]] = row[1]; + timingRuleForAll.clear(); + } + SpriteSet timingSet; + const std::string timingSelector = argc >= 5 ? argv[4] : ""; + if (timingSelector.empty()) { + timingSet = LoadPlayerSprites(*archive); + } else { + const size_t eq = timingSelector.find('='); + const std::string kind = timingSelector.substr( + 2, eq == std::string::npos ? std::string::npos : eq - 2); + const std::string defaultTsv = kind == "monsters" ? "monstdat" + : kind == "objects" ? "objdat" + : "towners"; + const std::string tsv = + eq == std::string::npos + ? "assets/txtdata/" + kind + "/" + defaultTsv + ".tsv" + : timingSelector.substr(eq + 1); + timingSet = kind == "monsters" ? LoadMonsterSprites(*archive, tsv) + : kind == "objects" ? LoadObjectSprites(*archive, tsv) + : LoadTownerSprites(*archive, tsv); + } + std::vector frameCount(timingSet.files.size()); + for (const Frame &f : timingSet.frames) + ++frameCount[f.file]; + + struct Timing { + double read = 0, convert = 0, extract = 0; + long long frames = 0, files = 0, bytes = 0; + }; + Timing total; + // Players: one entry per class/armour/weapon (what one character has loaded + // at most). Monsters: one entry per monster. Others: one entry per file. + std::map byGroup; + constexpr int Repeats = 5; + for (size_t fileIndex = 0; fileIndex < timingSet.files.size(); + ++fileIndex) { + const SpriteFile &file = timingSet.files[fileIndex]; + const auto rule = devilution::ParseShadowRule( + timingRuleForAll.empty() ? timingRules[file.path] : timingRuleForAll); + if (!rule) + return 2; + const bool isCel = + file.path.size() > 4 && + file.path.compare(file.path.size() - 4, 4, ".cel") == 0; + const devilution::PointerOrValue width{ + static_cast(file.width)}; + // Best of several runs, to leave out cache warm-up and scheduling noise. + double bestRead = 1e30, bestConvert = 1e30, bestExtract = 1e30; + size_t size = 0; + for (int repeat = 0; repeat < Repeats; ++repeat) { + const Clock::time_point t0 = Clock::now(); + std::unique_ptr data = + ReadMpqFile(*archive, file.path, size); + const Clock::time_point t1 = Clock::now(); + const devilution::OwnedClxSpriteListOrSheet clx = + isCel ? devilution::CelToClx(data.get(), size, width) + : devilution::Cl2ToClx(std::move(data), size, width); + const Clock::time_point t2 = Clock::now(); + const std::optional extracted = + devilution::ExtractClxShadows(clx, *rule); + const Clock::time_point t3 = Clock::now(); + bestRead = std::min( + bestRead, + std::chrono::duration(t1 - t0).count()); + bestConvert = std::min( + bestConvert, + std::chrono::duration(t2 - t1).count()); + bestExtract = std::min( + bestExtract, + std::chrono::duration(t3 - t2).count()); + } + const std::string group = file.classChar == 'M' + ? file.name.substr(0, file.name.size() - 1) + : file.classChar == 'T' || file.classChar == 'O' + ? file.name + : file.name.substr(0, 3); + for (Timing *t : {&total, &byGroup[group]}) { + t->read += bestRead; + t->convert += bestConvert; + t->extract += bestExtract; + t->frames += frameCount[fileIndex]; + t->bytes += static_cast(size); + ++t->files; + } + } + std::printf("files %lld, frames %lld, %.1f MB\n", total.files, total.frames, + total.bytes / 1e6); + std::printf("all files: read %.0f ms, convert %.0f ms, extract shadows " + "%.0f ms (%.2fx the conversion, %.1f us per frame)\n", + total.read, total.convert, total.extract, + total.extract / total.convert, + 1000.0 * total.extract / total.frames); + double sumExtract = 0, maxExtract = 0, sumBase = 0; + std::string maxGroup; + for (const auto &[name, t] : byGroup) { + sumExtract += t.extract; + sumBase += t.read + t.convert; + if (t.extract > maxExtract) { + maxExtract = t.extract; + maxGroup = name; + } + } + const Timing &worst = byGroup[maxGroup]; + std::printf("per group (%zu groups): read+convert %.2f ms on average, " + "extract %.2f ms on average\n", + byGroup.size(), sumBase / byGroup.size(), + sumExtract / byGroup.size()); + std::printf("slowest group %s: %lld files, %lld frames, read+convert %.2f " + "ms, extract %.2f ms\n", + maxGroup.c_str(), worst.files, worst.frames, + worst.read + worst.convert, worst.extract); + return 0; + } + + std::map ruleByPath; + std::string ruleForAll = argv[2]; + if (ruleForAll[0] == '@') { + for (const auto &row : ReadTsv(ruleForAll.substr(1))) + if (row.size() >= 2) + ruleByPath[row[0]] = row[1]; + ruleForAll.clear(); + } + + SpriteSet set; + const std::string selector = argc >= 4 ? argv[3] : ""; + if (selector.empty()) { + set = LoadPlayerSprites(*archive); + } else { + const size_t eq = selector.find('='); + const std::string kind = selector.substr( + 2, eq == std::string::npos ? std::string::npos : eq - 2); + const std::string defaultTsv = kind == "monsters" ? "monstdat" + : kind == "objects" ? "objdat" + : "towners"; + const std::string tsv = + eq == std::string::npos + ? "assets/txtdata/" + kind + "/" + defaultTsv + ".tsv" + : selector.substr(eq + 1); + set = kind == "monsters" ? LoadMonsterSprites(*archive, tsv) + : kind == "objects" ? LoadObjectSprites(*archive, tsv) + : LoadTownerSprites(*archive, tsv); + } + + size_t files = 0, maxMaskBytes = 0, lists = 0; + uint64_t outputHash = 1469598103934665603ULL; + std::string maxMaskFile; + long long frames = 0, blackPixels = 0, removed = 0, removedNotBlack = 0, + added = 0, maskPixels = 0, maskOnTransparent = 0, mismatches = 0; + long long originalBytes = 0, spriteBytes = 0, maskBytes = 0; + size_t frameIndex = 0; + for (size_t fileIndex = 0; fileIndex < set.files.size(); ++fileIndex) { + const SpriteFile &file = set.files[fileIndex]; + const std::string ruleName = + ruleForAll.empty() ? ruleByPath[file.path] : ruleForAll; + const auto rule = devilution::ParseShadowRule(ruleName); + if (!rule) { + std::fprintf(stderr, "%s: %s\n", file.path.c_str(), rule.error().c_str()); + return 2; + } + if (!rule->canExtractShadow()) { + while (frameIndex < set.frames.size() && + set.frames[frameIndex].file == static_cast(fileIndex)) + ++frameIndex; + continue; + } + size_t size = 0; + std::unique_ptr data = ReadMpqFile(*archive, file.path, size); + const devilution::PointerOrValue width{ + static_cast(file.width)}; + const bool isCel = file.path.size() > 4 && + file.path.compare(file.path.size() - 4, 4, ".cel") == 0; + const devilution::OwnedClxSpriteListOrSheet original = + isCel ? devilution::CelToClx(data.get(), size, width) + : devilution::Cl2ToClx(std::move(data), size, width); + const std::optional result = + devilution::ExtractClxShadows(original, *rule); + if (!result.has_value()) { + std::fprintf(stderr, "no result for %s\n", file.path.c_str()); + return 1; + } + ++files; + originalBytes += static_cast(original.dataSize()); + spriteBytes += static_cast(result->dataSize()); + { + // A fingerprint of the output bytes, to compare implementations of the + // extraction. + const devilution::ClxSpriteListOrSheet view{*result}; + const uint8_t *bytes = + view.isSheet() ? view.sheet().data() : view.list().data(); + for (size_t b = 0, n = result->dataSize(); b < n; ++b) + outputHash = (outputHash ^ bytes[b]) * 1099511628211ULL; + } + + const auto checkList = [&](ClxSpriteList sprites) { + ++lists; + for (uint32_t i = 0; i < sprites.numSprites(); ++i, ++frameIndex) { + const size_t maskSize = + sprites[i].headerSize() - devilution::ClxSprite::BaseHeaderSize; + maskBytes += static_cast(maskSize); + if (maskSize > maxMaskBytes) { + maxMaskBytes = maskSize; + maxMaskFile = file.path; + } + const Frame &before = set.frames[frameIndex]; + const Frame after = DecodeFrame(sprites[i]); + if (before.file != static_cast(fileIndex) || + after.width != before.width || after.height != before.height) { + std::fprintf(stderr, "frame mismatch in %s\n", file.path.c_str()); + std::exit(1); + } + std::vector mask(before.pixels.size(), 0); + if (devilution::HasShadowMask(sprites[i])) { + devilution::GetShadowMask(sprites[i]) + .forEachSpan([&](uint16_t rowFromBottom, + devilution::ShadowSpan span) { + for (unsigned x = span.x; + x < static_cast(span.x) + span.width; ++x) + mask[static_cast(before.height - 1 - rowFromBottom) * + before.width + + x] = 1; + }); + } + ++frames; + for (size_t p = 0; p < before.pixels.size(); ++p) { + const int16_t b = before.pixels[p], a = after.pixels[p]; + if (b == 0) + ++blackPixels; + if (mask[p] != 0) { + ++maskPixels; + if (b == Transparent) + ++maskOnTransparent; + } + if (a != Transparent && a != b) + ++added; + if (a == Transparent && b != Transparent) { + ++removed; + if (b != 0) + ++removedNotBlack; + } + // Redraw: the sprite over its black shadow. + const int16_t redrawn = a != Transparent ? a + : mask[p] != 0 ? 0 + : Transparent; + if (redrawn != b) + ++mismatches; + } + } + }; + if (result->isSheet()) { + for (uint16_t l = 0; l < result->numLists(); ++l) + checkList(result->sheet()[l]); + } else { + checkList(result->list()); + } + } + + std::printf("files with a known shadow rule: %zu of %zu\n", files, + set.files.size()); + std::printf("output fingerprint: %016llx\n", + static_cast(outputHash)); + std::printf("sprite lists: %zu\n", lists); + std::printf("largest shadow mask: %zu bytes (%s)\n", + maxMaskBytes, maxMaskFile.c_str()); + std::printf("frames: %lld\n", frames); + std::printf("black pixels in the originals: %lld\n", blackPixels); + std::printf("pixels removed as shadow: %lld\n", removed); + std::printf("black pixels kept in the sprites: %lld\n", + blackPixels - removed); + std::printf("removed pixels that were not black: %lld\n", removedNotBlack); + std::printf("pixels added or changed: %lld\n", added); + std::printf("mask pixels: %lld\n", maskPixels); + std::printf("mask pixels on transparent pixels: %lld\n", maskOnTransparent); + std::printf("pixels that differ when redrawn: %lld\n", mismatches); + std::printf("CLX bytes, originals: %lld\n", originalBytes); + std::printf("CLX bytes, sprites with masks: %lld\n", spriteBytes); + std::printf(" of which shadow masks: %lld\n", maskBytes); + return removedNotBlack == 0 && added == 0 && maskOnTransparent == 0 && + mismatches == 0 + ? 0 + : 1; +} diff --git a/tools/shadow_search/shadow_search.cpp b/tools/shadow_search/shadow_search.cpp new file mode 100644 index 000000000..e212c4821 --- /dev/null +++ b/tools/shadow_search/shadow_search.cpp @@ -0,0 +1,3568 @@ +/** + * @file shadow_search.cpp + * + * Research tool: finds and verifies the function that generated the shadows + * baked into the original player sprites. + * + * Result (see `ShadowRowFor`), verified on all 78,016 player frames of + * DIABDAT.MPQ. The only file with stray shadow pixels is + * plrgfx\\warrior\\whu\\whust.cl2. + * + * B = lowest opaque row in frame 0 of the animation/direction + * V = (B - y) / 3 + * y' = B - 2 - V + * x' = x - (3 * V) / 4 + * + * How it was found: + * 1. `fit` sweeps a row model `y' = (S*y + E) / sDen`, `x' = x + (A*y + C) / + * aDen` (Q.6 fixed-point or exact ratios) per file and direction. + * 2. `relmap` uses the ground offsets from (1) to tabulate, without assuming + * any formula, which (shadow row, shift) each source row maps to. + * 3. `intfit` fits the only remaining parameter of the integer model, the + * ground line. + * 4. `verify` checks the final parameter-free function; `html` writes a demo + * page. + * + * Build with -DBUILD_SHADOW_SEARCH=ON, then run `shadow_search + * `. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "sprites.hpp" + +namespace shadow_search { +namespace { + +int CmdList(const SpriteSet &set) { + std::vector counts(set.files.size()); + std::vector heights(set.files.size()); + for (const Frame &f : set.frames) { + ++counts[f.file]; + heights[f.file] = f.height; + } + for (size_t i = 0; i < set.files.size(); ++i) + std::printf("%s\t%dx%d\t%d frames\n", set.files[i].name.c_str(), + set.files[i].width, heights[i], counts[i]); + std::printf("%zu files, %zu frames\n", set.files.size(), set.frames.size()); + return 0; +} + +int Popcount(RowMask m) { + return std::popcount(m.w[0]) + std::popcount(m.w[1]) + std::popcount(m.w[2]) + + std::popcount(m.w[3]); +} + +RowMask Shift(RowMask m, int dx) { return dx >= 0 ? m << dx : m >> -dx; } + +RowMask WidthMask(int width) { return ~(~RowMask{} << width); } + +constexpr int FloorDiv(int a, int b) { + return a >= 0 ? a / b : -((-a + b - 1) / b); +} + +struct RowModel { + int S, E, A, C; + /** Denominators. 64 for Q.6 fixed-point. */ + int SD = 64, AD = 64; + [[nodiscard]] int y2(int y) const { return FloorDiv(S * y + E, SD); } + [[nodiscard]] int dx(int y) const { return FloorDiv(A * y + C, AD); } +}; + +struct Fit { + int64_t viol = 0; // warped pixels landing on transparent pixels + int64_t hits = 0; // black pixels covered by the warp + int64_t black = 0; // black pixels in total + int64_t covered = 0; // warped pixels landing on any opaque pixel +}; + +/** Exact evaluation of the row model on a frame. Sources are the non-black + * opaque pixels. */ +Fit Evaluate(const Frame &f, const RowModel &m, + std::vector *warpedOut = nullptr) { + std::vector warped(f.height, 0); + for (int y = 0; y < f.height; ++y) { + const RowMask src = f.opaque[y] & ~f.black[y]; + if (src == 0) + continue; + const int y2 = m.y2(y); + if (y2 < 0 || y2 >= f.height) + continue; + const int dx = m.dx(y); + if (dx <= -MaxWidth || dx >= MaxWidth) + continue; + warped[y2] |= Shift(src, dx); + } + const RowMask wmask = WidthMask(f.width); + Fit fit; + for (int y = 0; y < f.height; ++y) { + warped[y] &= wmask; + fit.viol += Popcount(warped[y] & ~f.opaque[y]); + fit.hits += Popcount(warped[y] & f.black[y]); + fit.black += Popcount(f.black[y]); + fit.covered += Popcount(warped[y] & f.opaque[y]); + } + if (warpedOut != nullptr) + *warpedOut = std::move(warped); + return fit; +} + +struct Group { + std::string name; + int file, direction; + std::vector frames; +}; + +std::vector MakeGroups(const SpriteSet &set, const std::string &filter, + bool perFrame) { + std::vector groups; + for (const Frame &f : set.frames) { + const std::string &name = set.files[f.file].name; + // The filter is matched against "/", e.g. "whust/2". + if (!filter.empty() && + (name + "/" + std::to_string(f.direction)).find(filter) == + std::string::npos) + continue; + if (perFrame || groups.empty() || groups.back().file != f.file || + groups.back().direction != f.direction) { + std::string groupName = name + "/" + std::to_string(f.direction); + if (perFrame) + groupName += "/" + std::to_string(f.index); + groups.push_back({groupName, f.file, f.direction, {}}); + } + groups.back().frames.push_back(&f); + } + return groups; +} + +struct SearchRange { + int sMin = 18, sMax = 24, sDen = 64; + int aMin = 13, aMax = 21, aDen = 64; + /** Score is `hits - penalty * violations`. */ + int64_t penalty = 4; +}; + +struct GroupResult { + RowModel best{}; // min violations, then max hits + int64_t bestViol = 0, bestHits = 0; + int64_t numTied = 0; // number of models tied with `best` + RowModel tiedMin{}, tiedMax{}; +}; + +/** + * Brute-force search of the row model on a group of frames that are assumed to + * share it. Uses additive per-row tables, so hits are slightly over-counted + * where rows overlap. + */ +GroupResult SearchGroup(const Group &group, const SearchRange &range) { + const int width = group.frames[0]->width; + const int height = group.frames[0]->height; + constexpr int DxMin = -72, DxMax = 40, NumDx = DxMax - DxMin + 1; + const RowMask wmask = WidthMask(width); + // score[y][y2][dx] = hits - Penalty * violations + const int64_t Penalty = range.penalty; + std::vector table(static_cast(height) * height * NumDx); + std::vector used(height); + for (const Frame *f : group.frames) { + for (int y = 0; y < height; ++y) { + const RowMask src = f->opaque[y] & ~f->black[y]; + if (src == 0) + continue; + used[y] = 1; + for (int y2 = 0; y2 < height; ++y2) { + const RowMask transparent = ~f->opaque[y2] & wmask; + const RowMask black = f->black[y2]; + int64_t *cells = &table[(static_cast(y) * height + y2) * NumDx]; + for (int dx = DxMin; dx <= DxMax; ++dx) { + const RowMask m = Shift(src, dx); + cells[dx - DxMin] += + Popcount(m & black) - Penalty * Popcount(m & transparent); + } + } + } + } + std::vector rows; + for (int y = 0; y < height; ++y) + if (used[y]) + rows.push_back(y); + + GroupResult result; + if (rows.empty()) + return result; + int64_t bestScore = INT64_MIN; + std::vector rowCells(rows.size()); + std::vector scoreByC; + const int SD = range.sDen, AD = range.aDen; + for (int S = range.sMin; S <= range.sMax; ++S) { + // Shadows are in the lower part of the sprite. + for (int E = (height / 3) * SD; E < height * SD; ++E) { + bool ok = true; + for (size_t i = 0; i < rows.size(); ++i) { + const int y2 = FloorDiv(S * rows[i] + E, SD); + if (y2 < 0 || y2 >= height) { + ok = false; + break; + } + rowCells[i] = + &table[(static_cast(rows[i]) * height + y2) * NumDx]; + } + if (!ok) + continue; + for (int A = range.aMin; A <= range.aMax; ++A) { + const int cMin = DxMin * AD - A * rows.front(); + const int cMax = (DxMax + 1) * AD - 1 - A * rows.back(); + if (cMax < cMin) + continue; + // score(C) = sum over rows of rowCells[row][((A * y + C) >> 6) - + // DxMin]. Each row's term is piecewise constant in C, so accumulate + // differences. + scoreByC.assign(cMax - cMin + 2, 0); + for (size_t i = 0; i < rows.size(); ++i) { + const int base = A * rows[i]; + const int kFirst = FloorDiv(base + cMin, AD); + const int kLast = FloorDiv(base + cMax, AD); + int64_t prev = 0; + for (int k = kFirst; k <= kLast; ++k) { + const int cStart = std::max(cMin, k * AD - base); + const int64_t value = rowCells[i][k - DxMin]; + scoreByC[cStart - cMin] += value - prev; + prev = value; + } + } + int64_t score = 0; + for (int C = cMin; C <= cMax; ++C) { + score += scoreByC[C - cMin]; + if (score < bestScore) + continue; + const RowModel m{S, E, A, C, SD, AD}; + if (score > bestScore) { + bestScore = score; + result.best = m; + result.numTied = 0; + result.tiedMin = result.tiedMax = m; + } + ++result.numTied; + result.tiedMin = {std::min(result.tiedMin.S, S), + std::min(result.tiedMin.E, E), + std::min(result.tiedMin.A, A), + std::min(result.tiedMin.C, C), + SD, + AD}; + result.tiedMax = {std::max(result.tiedMax.S, S), + std::max(result.tiedMax.E, E), + std::max(result.tiedMax.A, A), + std::max(result.tiedMax.C, C), + SD, + AD}; + } + } + } + } + result.bestViol = 0; + result.bestHits = bestScore; + return result; +} + +int CmdFit(const SpriteSet &set, const std::string &filter, bool perFrame, + int stride, const SearchRange &range) { + std::vector all = MakeGroups(set, filter, perFrame); + std::vector groups; + for (size_t i = 0; i < all.size(); i += stride) + groups.push_back(all[i]); + std::vector results(groups.size()); + std::atomic next{0}; + std::vector threads; + for (unsigned t = 0; t < std::max(1U, std::thread::hardware_concurrency()); + ++t) { + threads.emplace_back([&] { + for (size_t i; (i = next.fetch_add(1)) < groups.size();) + results[i] = SearchGroup(groups[i], range); + }); + } + for (std::thread &t : threads) + t.join(); + std::printf("group\tframes\tS\tE\tA\tC\tviol\thits\tblack\texactViol\texactHi" + "ts\ttied\tS..\tE..\tA..\tC.." + "\tbodyTop\tbodyBottom\topaqueBottom\tbodyLeft\tbodyRight\n"); + for (size_t i = 0; i < groups.size(); ++i) { + const GroupResult &r = results[i]; + Fit total; + for (const Frame *f : groups[i].frames) { + const Fit fit = Evaluate(*f, r.best); + total.viol += fit.viol; + total.hits += fit.hits; + total.black += fit.black; + } + // Bounding box of the non-black pixels of the first frame. + const Frame &first = *groups[i].frames[0]; + int bodyTop = -1, bodyBottom = -1, opaqueBottom = -1, + bodyLeft = first.width, bodyRight = -1; + for (int y = 0; y < first.height; ++y) { + if (first.opaque[y] != 0) + opaqueBottom = y; + const RowMask body = first.opaque[y] & ~first.black[y]; + if (body == 0) + continue; + if (bodyTop < 0) + bodyTop = y; + bodyBottom = y; + for (int x = 0; x < first.width; ++x) { + if (((body >> x) & 1) == 0) + continue; + bodyLeft = std::min(bodyLeft, x); + bodyRight = std::max(bodyRight, x); + } + } + std::printf( + "%s\t%zu\t%d\t%d\t%d\t%d\t%lld\t%lld\t%lld\t%lld\t%lld\t%lld\t%d-%d\t%" + "d-%d\t%d-%d\t%d-%d\t%d\t%d\t%d\t%d\t%d\n", + groups[i].name.c_str(), groups[i].frames.size(), r.best.S, r.best.E, + r.best.A, r.best.C, static_cast(r.bestViol), + static_cast(r.bestHits), static_cast(total.black), + static_cast(total.viol), static_cast(total.hits), + static_cast(r.numTied), r.tiedMin.S, r.tiedMax.S, + r.tiedMin.E, r.tiedMax.E, r.tiedMin.A, r.tiedMax.A, r.tiedMin.C, + r.tiedMax.C, bodyTop, bodyBottom, opaqueBottom, bodyLeft, bodyRight); + } + return 0; +} + +/** Finds the ground offset `g` of every (file, direction) group using the + * integer row model. */ +std::vector FindGroundOffsets(const std::vector &groups) { + std::vector ground(groups.size()); + std::atomic next{0}; + std::vector threads; + SearchRange range; + range.sMin = range.sMax = 1; + range.sDen = 3; + range.aMin = range.aMax = 1; + range.aDen = 4; + for (unsigned t = 0; t < std::max(1U, std::thread::hardware_concurrency()); + ++t) { + threads.emplace_back([&] { + for (size_t i; (i = next.fetch_add(1)) < groups.size();) + ground[i] = -SearchGroup(groups[i], range).best.C; + }); + } + for (std::thread &t : threads) + t.join(); + return ground; +} + +/** + * Model-free analysis in ground-relative coordinates, pooled over all groups. + * + * For every source height `h = g - y`, counts violations and hits for every + * candidate (destination row relative to `g`, horizontal shift). + */ +int CmdRelMap(const SpriteSet &set, const std::string &filter, + const std::string &exclude) { + std::vector groups = MakeGroups(set, filter, false); + if (!exclude.empty()) + std::erase_if(groups, [&](const Group &g) { + return g.name.find(exclude) != std::string::npos; + }); + const std::vector ground = FindGroundOffsets(groups); + constexpr int HMin = -16, HMax = 127, NumH = HMax - HMin + 1; + constexpr int TMin = -48, TMax = 16, + NumT = TMax - TMin + 1; // destination row minus g + constexpr int DxMin = -40, DxMax = 8, NumDx = DxMax - DxMin + 1; + struct Cell { + int64_t viol = 0, hits = 0; + }; + std::vector table(static_cast(NumH) * NumT * NumDx); + std::vector pixels(NumH); + for (size_t gi = 0; gi < groups.size(); ++gi) { + const int g = ground[gi]; + for (const Frame *f : groups[gi].frames) { + const RowMask wmask = WidthMask(f->width); + for (int y = 0; y < f->height; ++y) { + const RowMask src = f->opaque[y] & ~f->black[y]; + const int h = g - y; + if (src == 0 || h < HMin || h > HMax) + continue; + pixels[h - HMin] += Popcount(src); + for (int t = TMin; t <= TMax; ++t) { + const int y2 = g + t; + if (y2 < 0 || y2 >= f->height) + continue; + const RowMask transparent = ~f->opaque[y2] & wmask; + const RowMask black = f->black[y2]; + Cell *cells = + &table[(static_cast(h - HMin) * NumT + (t - TMin)) * + NumDx]; + for (int dx = DxMin; dx <= DxMax; ++dx) { + const RowMask m = Shift(src, dx); + cells[dx - DxMin].viol += Popcount(m & transparent); + cells[dx - DxMin].hits += Popcount(m & black); + } + } + } + } + } + for (int h = HMax; h >= HMin; --h) { + if (pixels[h - HMin] == 0) + continue; + // Prediction of the integer model: y2 = floor((y + 2g + 4) / 3), dx = + // floor((y - g) / 4). + const int predT = FloorDiv(4 - h, 3); + const int predDx = FloorDiv(-h, 4); + struct Cand { + int t, dx; + Cell c; + }; + std::vector cands; + for (int t = std::max(TMin, predT - 2); t <= std::min(TMax, predT + 2); ++t) + for (int dx = std::max(DxMin, predDx - 3); + dx <= std::min(DxMax, predDx + 3); ++dx) + cands.push_back( + {t, dx, + table[(static_cast(h - HMin) * NumT + (t - TMin)) * NumDx + + (dx - DxMin)]}); + std::sort(cands.begin(), cands.end(), [](const Cand &a, const Cand &b) { + if (a.c.viol != b.c.viol) + return a.c.viol < b.c.viol; + return a.c.hits > b.c.hits; + }); + std::printf("h=%4d n=%8lld pred=(%d,%d):", h, + static_cast(pixels[h - HMin]), predT, predDx); + for (size_t i = 0; i < std::min(cands.size(), 4); ++i) + std::printf(" (t=%d dx=%d v=%lld h=%lld)", cands[i].t, cands[i].dx, + static_cast(cands[i].c.viol), + static_cast(cands[i].c.hits)); + std::putchar('\n'); + } + return 0; +} + +/** + * The integer shadow transform: + * + * U = floor((G - y) / 3) height of the shadow row above the ground + * line y' = G - U x' = x - ceil(3 * U / 4) = x - U + floor(U / 4) + */ +struct IntModel { + int G; + /** Source rows with `G - y < minH` cast no shadow. */ + int minH = INT_MIN; + /** Whether black source pixels cast a shadow too. */ + bool blackCasts = false; +}; + +Fit EvaluateInt(const Frame &f, const IntModel &m, + std::vector *warpedOut = nullptr) { + std::vector warped(f.height, 0); + for (int y = 0; y < f.height; ++y) { + const RowMask src = m.blackCasts ? f.opaque[y] : f.opaque[y] & ~f.black[y]; + if (src == 0) + continue; + const int H = m.G - y; + if (H < m.minH) + continue; + const int U = FloorDiv(H, 3); + const int y2 = m.G - U; + if (y2 < 0 || y2 >= f.height) + continue; + const int dx = -(U - FloorDiv(U, 4)); + if (dx <= -MaxWidth || dx >= MaxWidth) + continue; + warped[y2] |= Shift(src, dx); + } + const RowMask wmask = WidthMask(f.width); + Fit fit; + for (int y = 0; y < f.height; ++y) { + warped[y] &= wmask; + fit.viol += Popcount(warped[y] & ~f.opaque[y]); + fit.hits += Popcount(warped[y] & f.black[y]); + fit.black += Popcount(f.black[y]); + fit.covered += Popcount(warped[y] & f.opaque[y]); + } + if (warpedOut != nullptr) + *warpedOut = std::move(warped); + return fit; +} + +struct IntFit { + int G = 0; + Fit fit; + /** Range of G values with zero violations and the same number of hits. */ + int gMin = 0, gMax = 0; +}; + +IntFit FitInt(const std::vector &frames, int minH, + bool blackCasts) { + IntFit best; + int64_t bestScore = INT64_MIN; + for (int G = 0; G < frames[0]->height + 32; ++G) { + Fit total; + for (const Frame *f : frames) { + const Fit fit = EvaluateInt(*f, IntModel{G, minH, blackCasts}); + total.viol += fit.viol; + total.hits += fit.hits; + total.black += fit.black; + } + const int64_t score = total.hits - 4 * total.viol; + if (score > bestScore) { + bestScore = score; + best.G = best.gMin = best.gMax = G; + best.fit = total; + } else if (score == bestScore) { + best.gMax = G; + } + } + return best; +} + +int CmdIntFit(const SpriteSet &set, const std::string &filter, bool perFrame, + int minH, bool blackCasts) { + const std::vector groups = MakeGroups(set, filter, perFrame); + Fit total; + size_t perfect = 0, frames = 0; + std::printf("group\tframes\tG\tviol\thits\tblack\tmissed\tbodyBottom\tbelow\t" + "Grange\n"); + for (const Group &group : groups) { + const IntFit r = FitInt(group.frames, minH, blackCasts); + // Lowest non-black row over the group and the number of non-black pixels + // below the last casting row. + int bodyBottom = -1; + int64_t below = 0; + for (const Frame *f : group.frames) { + for (int y = 0; y < f->height; ++y) { + const RowMask body = f->opaque[y] & ~f->black[y]; + if (body == 0) + continue; + bodyBottom = std::max(bodyBottom, y); + if (r.G - y < minH) + below += Popcount(body); + } + } + std::printf("%s\t%zu\t%d\t%lld\t%lld\t%lld\t%lld\t%d\t%lld\t%d-%d\n", + group.name.c_str(), group.frames.size(), r.G, + static_cast(r.fit.viol), + static_cast(r.fit.hits), + static_cast(r.fit.black), + static_cast(r.fit.black - r.fit.hits), bodyBottom, + static_cast(below), r.gMin, r.gMax); + total.viol += r.fit.viol; + total.hits += r.fit.hits; + total.black += r.fit.black; + for (const Frame *f : group.frames) { + ++frames; + if (EvaluateInt(*f, IntModel{r.G, minH, blackCasts}).viol == 0) + ++perfect; + } + } + std::printf("TOTAL\t%zu\t-\t%lld\t%lld\t%lld\t%lld\n", frames, + static_cast(total.viol), + static_cast(total.hits), + static_cast(total.black), + static_cast(total.black - total.hits)); + std::printf("frames with zero violations: %zu / %zu\n", perfect, frames); + return 0; +} + +/** + * The final, parameter-free shadow function. + * + * `baseline` is the lowest opaque row of the first frame of the animation (for + * the given direction). Shadows end 2 rows above it, so that row always belongs + * to the body. Body pixels below the baseline cast no shadow. + * + * V = (baseline - y) / 3 + * y' = baseline - 2 - V + * x' = x - (3 * V) / 4 + */ +struct ShadowRow { + int y2, dx; +}; + +/** Experimental offsets of the shadow relative to the function, see `CmdTweak`. + * All zero normally. */ +struct Tweak { + int dy = 0, dx = 0; +}; +Tweak tweak; + +bool ShadowRowFor(int baseline, int y, ShadowRow &out) { + if (y > baseline) + return false; + const int v = (baseline - y) / 3; + out = {baseline - 2 - v + tweak.dy, -((3 * v) / 4) + tweak.dx}; + return true; +} + +int Baseline(const Frame &firstFrame) { + int baseline = -1; + for (int y = 0; y < firstFrame.height; ++y) + if (firstFrame.opaque[y] != 0) + baseline = y; + return baseline; +} + +struct CastCount { + int64_t stray = 0; // distinct transparent pixels that a shadow is cast onto + int64_t hits = 0; // distinct black pixels that a shadow is cast onto +}; + +/** + * Counts the pixels shadowed by the non-black pixels of the frame, which are + * certainly not shadow themselves. + * + * Up to 3 source rows cast onto the same shadow row, so the shadow is assembled + * first and each shadowed pixel is counted once, however many pixels cast onto + * it. + */ +CastCount CountNonBlackCast(const Frame &f, int baseline) { + std::vector shadow(f.height); + const RowMask wmask = WidthMask(f.width); + for (int y = 0; y < f.height; ++y) { + ShadowRow row; + const RowMask src = f.opaque[y] & ~f.black[y]; + if (src == 0 || !ShadowRowFor(baseline, y, row) || row.y2 < 0 || + row.y2 >= f.height) + continue; + shadow[row.y2] |= Shift(src, row.dx) & wmask; + } + CastCount count; + for (int y = 0; y < f.height; ++y) { + count.stray += Popcount(shadow[y] & ~f.opaque[y]); + count.hits += Popcount(shadow[y] & f.black[y]); + } + return count; +} + +struct Reconstruction { + /** Pixels of the shadowless sprite: all non-black pixels and the black pixels + * that are not shadow. */ + std::vector body; + /** Shadow cast by `body`, clipped to the frame. */ + std::vector shadow; + /** + * Black pixels that are neither: not covered by the shadow of any pixel of + * the sprite, yet they cannot be body pixels because their own shadow would + * fall on a transparent pixel. + */ + std::vector orphan; + int64_t violations = 0; // shadow pixels that are transparent in the original + int64_t black = 0; // black pixels in the original + int64_t shadowBlack = 0; // black pixels explained as shadow + int64_t bodyBlack = 0; // black pixels that belong to the body + int64_t orphanBlack = 0; +}; + +/** + * Splits a frame into body and shadow. + * + * A black pixel belongs to the body iff it is not covered by the shadow of the + * body pixels above it. Shadows are always cast downwards (except within the 3 + * lowest rows, where the transform maps onto the same column), so a single + * top-down pass is enough. + * + * Body pixels must themselves cast a shadow onto opaque pixels. The few + * uncovered black pixels that would not are reported as orphans rather than as + * body pixels. + */ +Reconstruction Reconstruct(const Frame &f, int baseline) { + Reconstruction r; + r.body.assign(f.height, 0); + r.shadow.assign(f.height, 0); + r.orphan.assign(f.height, 0); + const RowMask wmask = WidthMask(f.width); + const auto cast = [&](int y, RowMask src) { + ShadowRow row; + if (src == 0 || !ShadowRowFor(baseline, y, row) || row.y2 < 0 || + row.y2 >= f.height) + return; + r.shadow[row.y2] |= Shift(src, row.dx) & wmask; + }; + for (int y = 0; y < f.height; ++y) + cast(y, f.opaque[y] & ~f.black[y]); + for (int y = 0; y < f.height; ++y) { + RowMask bodyBlack = f.black[y] & ~r.shadow[y]; + ShadowRow row; + if (bodyBlack != 0 && ShadowRowFor(baseline, y, row) && row.y2 >= 0 && + row.y2 < f.height) { + const RowMask transparent = ~f.opaque[row.y2] & wmask; + r.orphan[y] = bodyBlack & Shift(transparent, -row.dx); + bodyBlack &= ~r.orphan[y]; + } + // A black pixel that lies inside the shadow of the pixels above it may + // still be a body pixel. If such a pixel is in the right place to cast an + // otherwise orphan pixel, that is what it is. + if (r.orphan[y] != 0) { + const int v = baseline - 2 - y + tweak.dy; + const int shift = (3 * v) / 4 - tweak.dx; + for (int sy = baseline - 3 * v - 2; v >= 2 && sy <= baseline - 3 * v; + ++sy) { + if (sy < 0 || sy >= f.height) + continue; + const RowMask hidden = f.black[sy] & ~r.body[sy] & ~r.orphan[sy] & + Shift(r.orphan[y], shift); + if (hidden == 0) + continue; + r.body[sy] |= hidden; + r.shadow[y] |= Shift(hidden, -shift); + r.orphan[y] &= ~Shift(hidden, -shift); + } + } + r.body[y] = (f.opaque[y] & ~f.black[y]) | bodyBlack; + cast(y, bodyBlack); + } + for (int y = 0; y < f.height; ++y) { + r.violations += Popcount(r.shadow[y] & ~f.opaque[y]); + r.black += Popcount(f.black[y]); + r.bodyBlack += Popcount(r.body[y] & f.black[y]); + r.orphanBlack += Popcount(r.orphan[y]); + r.shadowBlack += Popcount(f.black[y] & ~r.body[y] & ~r.orphan[y]); + } + return r; +} + +int CmdShow(const SpriteSet &set, const std::string &name, int direction, + int index, int baselineOffset = 0, bool ownBaseline = false) { + const Frame *first = nullptr; + for (const Frame &f : set.frames) { + if (set.files[f.file].name != name || f.direction != direction) + continue; + if (first == nullptr) + first = &f; + if (f.index != index) + continue; + const int baseline = Baseline(ownBaseline ? f : *first) + baselineOffset; + const Reconstruction r = Reconstruct(f, baseline); + std::printf("%s dir=%d frame=%d baseline=%d violations=%lld black=%lld " + "shadow=%lld bodyBlack=%lld orphan=%lld\n", + name.c_str(), direction, index, baseline, + static_cast(r.violations), + static_cast(r.black), + static_cast(r.shadowBlack), + static_cast(r.bodyBlack), + static_cast(r.orphanBlack)); + for (int y = 0; y < f.height; ++y) { + std::printf("%3d ", y); + for (int x = 0; x < f.width; ++x) { + const int16_t c = f.at(x, y); + const bool body = ((r.body[y] >> x) & 1) != 0; + const bool shadow = ((r.shadow[y] >> x) & 1) != 0; + const bool orphan = ((r.orphan[y] >> x) & 1) != 0; + // ':' body, 'b' black body pixel, '#' shadow, '?' orphan shadow pixel, + // 'X' predicted shadow on a transparent pixel + std::putchar(c == Transparent ? (shadow ? 'X' : '.') + : orphan ? '?' + : body ? (c == 0 ? 'b' : ':') + : '#'); + } + std::putchar('\n'); + } + return 0; + } + std::fprintf(stderr, "frame not found\n"); + return 1; +} + +struct BaselineRule; +BaselineRule DetectRule(const Group &group); +int BaselineFor(const Group &group, const Frame &f, bool autoRule); + +/** + * Checks the shadow function on every frame. With `autoRule` each group uses + * the baseline rule found by `DetectRule` instead of always the first frame's + * lowest opaque row. + */ +int CmdVerify(const SpriteSet &set, const std::string &filter, bool verbose, + bool autoRule) { + const std::vector groups = MakeGroups(set, filter, false); + int64_t frames = 0, noViolations = 0, exact = 0, violations = 0, fitNeg = 0; + int64_t opaque = 0, black = 0, shadowBlack = 0, bodyBlack = 0, + orphanBlack = 0; + if (verbose) + std::printf("group\tframes\tbaseline\tviolations\tblack\tshadow\tbodyBlack" + "\torphan\n"); + for (const Group &group : groups) { + int baseline = Baseline(*group.frames[0]); + int64_t gViol = 0, gBlack = 0, gShadow = 0, gBody = 0, gOrphan = 0; + for (const Frame *f : group.frames) { + baseline = BaselineFor(group, *f, autoRule); + const Reconstruction r = Reconstruct(*f, baseline); + ++frames; + if (r.violations == 0) + ++noViolations; + // The frame is reproduced exactly iff drawing the body over its shadow + // gives back the original. + if (r.violations == 0 && r.orphanBlack == 0) + ++exact; + gViol += r.violations; + gBlack += r.black; + gShadow += r.shadowBlack; + gBody += r.bodyBlack; + gOrphan += r.orphanBlack; + for (int y = 0; y < f->height; ++y) + opaque += Popcount(f->opaque[y]); + // `fit_neg` with only the pixels that are certainly not shadow as + // sources. + fitNeg += CountNonBlackCast(*f, baseline).stray; + } + if (verbose) + std::printf( + "%s\t%zu\t%d\t%lld\t%lld\t%lld\t%lld\t%lld\n", group.name.c_str(), + group.frames.size(), baseline, static_cast(gViol), + static_cast(gBlack), static_cast(gShadow), + static_cast(gBody), static_cast(gOrphan)); + violations += gViol; + black += gBlack; + shadowBlack += gShadow; + bodyBlack += gBody; + orphanBlack += gOrphan; + } + const auto ll = [](int64_t v) { return static_cast(v); }; + std::printf("groups (file x direction): %zu\n", groups.size()); + std::printf("frames: %lld\n", ll(frames)); + std::printf("opaque pixels: %lld\n", ll(opaque)); + std::printf("black pixels: %lld\n", ll(black)); + std::printf(" reproduced as shadow (fit_pos): %lld (%.4f%%)\n", + ll(shadowBlack), 100.0 * shadowBlack / black); + std::printf(" black pixels of the body: %lld (%.4f%%)\n", + ll(bodyBlack), 100.0 * bodyBlack / black); + std::printf(" orphan shadow pixels: %lld (%.4f%%)\n", + ll(orphanBlack), 100.0 * orphanBlack / black); + std::printf("fit_neg, non-black sources: %lld\n", ll(fitNeg)); + std::printf("fit_neg, all body pixels: %lld\n", ll(violations)); + std::printf("frames with fit_neg == 0: %lld / %lld\n", + ll(noViolations), ll(frames)); + std::printf("frames reproduced pixel for pixel: %lld / %lld\n", ll(exact), + ll(frames)); + return violations == 0 ? 0 : 1; +} + +/** + * Classifies every (file, direction) group by the baseline rule that reproduces + * it: + * + * - `first`: lowest opaque row of the first frame (the player rule) + * - `own`: lowest opaque row of each frame + * - `first+N`: the first frame's lowest opaque row plus a constant (flying + * monsters) + * - `per-frame`: some baseline exists for every frame, but none of the rules + * above gives it + * - `no shadow`: the sprite has no black pixels outside what could be body + * - `none`: some frame has stray shadow pixels under every baseline + * + * A rule is accepted if no shadow pixel cast by the non-black pixels is + * transparent (`fit_neg == 0`). Prints one line per file with the rule of each + * direction. + */ +int CmdRules(const SpriteSet &set, const std::string &filter) { + const auto lowest = [](const Frame &f) { return Baseline(f); }; + // Stray pixels and covered black pixels for a baseline, non-black sources + // only. + const auto evaluate = [](const Frame &f, int baseline, int64_t &stray, + int64_t &hits) { + const CastCount count = CountNonBlackCast(f, baseline); + stray += count.stray; + hits += count.hits; + }; + std::map> byFile; + std::vector order; + std::map totals; + for (const Group &group : MakeGroups(set, filter, false)) { + const std::string &name = set.files[group.file].name; + if (byFile.find(name) == byFile.end()) + order.push_back(name); + int64_t black = 0; + for (const Frame *f : group.frames) + for (int y = 0; y < f->height; ++y) + black += Popcount(f->black[y]); + const int first = lowest(*group.frames[0]); + std::string rule; + const auto strayWith = [&](auto baselineOf) { + int64_t stray = 0, hits = 0; + for (const Frame *f : group.frames) + evaluate(*f, baselineOf(*f), stray, hits); + return stray; + }; + if (black == 0) { + rule = "no shadow"; + } else if (strayWith([&](const Frame &) { return first; }) == 0) { + rule = "first"; + } else if (strayWith([&](const Frame &f) { return lowest(f); }) == 0) { + rule = "own"; + } else { + for (int n = 1; n <= 48 && rule.empty(); ++n) { + // Require the offset baseline to explain black pixels, not just to + // avoid stray ones. + int64_t stray = 0, hits = 0; + for (const Frame *f : group.frames) + evaluate(*f, first + n, stray, hits); + if (stray == 0 && hits * 2 > black) + rule = "first+" + std::to_string(n); + } + if (rule.empty()) { + bool all = true; + for (const Frame *f : group.frames) { + bool any = false; + int64_t frameBlack = 0; + for (int y = 0; y < f->height; ++y) + frameBlack += Popcount(f->black[y]); + for (int b = 0; b < f->height + 48 && !any; ++b) { + int64_t stray = 0, hits = 0; + evaluate(*f, b, stray, hits); + any = stray == 0 && hits * 2 > frameBlack; + } + all = all && (any || frameBlack == 0); + } + rule = all ? "per-frame" : "none"; + } + } + byFile[name].push_back(rule); + ++totals[rule.rfind("first+", 0) == 0 ? "first+N" : rule]; + } + for (const std::string &name : order) { + std::printf("%s", name.c_str()); + for (const std::string &rule : byFile[name]) + std::printf("\t%s", rule.c_str()); + std::putchar('\n'); + } + std::printf("--\n"); + for (const auto &[rule, count] : totals) + std::printf("%s\t%d groups\n", rule.c_str(), count); + return 0; +} + +/** + * For groups the function does not reproduce: tries moving the baseline and + * offsetting the shadow by a few pixels, and prints the variant with the fewest + * wrong pixels. + */ +int CmdTweak(const SpriteSet &set, const std::string &filter) { + std::printf("group\tbaseline\twrong\tbest dB\tdy\tdx\twrong\n"); + for (const Group &group : MakeGroups(set, filter, false)) { + const int baseline = Baseline(*group.frames[0]); + const auto wrong = [&](int dB) { + int64_t n = 0; + for (const Frame *f : group.frames) { + const Reconstruction r = Reconstruct(*f, baseline + dB); + n += r.violations + r.orphanBlack; + } + return n; + }; + tweak = {}; + const int64_t base = wrong(0); + if (base == 0) + continue; + int64_t best = base; + int bestB = 0; + Tweak bestTweak; + for (int dB = -4; dB <= 4; ++dB) { + for (tweak.dy = -3; tweak.dy <= 3; ++tweak.dy) { + for (tweak.dx = -3; tweak.dx <= 3; ++tweak.dx) { + const int64_t n = wrong(dB); + if (n >= best) + continue; + best = n; + bestB = dB; + bestTweak = tweak; + } + } + } + tweak = {}; + std::printf("%s\t%d\t%lld\t%d\t%d\t%d\t%lld\n", group.name.c_str(), + baseline, static_cast(base), bestB, bestTweak.dy, + bestTweak.dx, static_cast(best)); + } + return 0; +} + +/** + * Tests whether the shadows of a file were cast from the frames of another file + * (same class, armour, animation and direction, any weapon) or from the same + * file with a frame offset. + * + * Prints, per donor, the donor shadow pixels that are transparent in the target + * and the target's black pixels that the donor shadow does not cover. + */ +int CmdDonor(const SpriteSet &set, const std::string &target) { + const std::vector all = MakeGroups(set, "", false); + std::printf("target\tdonor\tframeOffset\tstray\tuncoveredBlack\n"); + for (const Group &tg : all) { + const SpriteFile &tf = set.files[tg.file]; + if (tf.name != target) + continue; + struct Row { + std::string donor; + int offset; + int64_t stray, uncovered; + }; + std::vector rows; + for (const Group &dg : all) { + const SpriteFile &df = set.files[dg.file]; + if (dg.direction != tg.direction || df.classChar != tf.classChar || + df.armorChar != tf.armorChar || df.anim != tf.anim) + continue; + if (dg.frames.size() != tg.frames.size() || + dg.frames[0]->width != tg.frames[0]->width) + continue; + const int baseline = Baseline(*dg.frames[0]); + const int n = static_cast(tg.frames.size()); + for (int offset = (dg.file == tg.file ? -2 : 0); + offset <= (dg.file == tg.file ? 2 : 0); ++offset) { + Row row{df.name, offset, 0, 0}; + for (int i = 0; i < n; ++i) { + const Frame &f = *tg.frames[i]; + const Reconstruction r = + Reconstruct(*dg.frames[((i + offset) % n + n) % n], baseline); + for (int y = 0; y < f.height; ++y) { + row.stray += Popcount(r.shadow[y] & ~f.opaque[y]); + row.uncovered += Popcount(f.black[y] & ~r.shadow[y]); + } + } + rows.push_back(row); + } + } + std::sort(rows.begin(), rows.end(), [](const Row &a, const Row &b) { + return a.stray + a.uncovered < b.stray + b.uncovered; + }); + for (size_t i = 0; i < std::min(rows.size(), 3); ++i) + std::printf("%s\t%s\t%d\t%lld\t%lld\n", tg.name.c_str(), + rows[i].donor.c_str(), rows[i].offset, + static_cast(rows[i].stray), + static_cast(rows[i].uncovered)); + } + return 0; +} + +/** + * Characterises the black pixels the function does not reproduce, per class and + * armour. + * + * For every orphan pixel, looks at the 3 positions a source pixel would have to + * occupy: + * - `cropped`: all of them are outside the frame + * - `edge`: one of them is next to (8-neighbourhood) a body pixel + * - `far`: none of the above + * Also counts how many shadow pixels are only explained by black body pixels + * casting. + */ +int CmdOrphans(const SpriteSet &set, const std::string &filter) { + struct Stats { + int64_t frames = 0, bodyBlack = 0, onlyByBlack = 0, orphan = 0, cropped = 0, + edge = 0, far = 0; + }; + std::map stats; + for (const Group &group : MakeGroups(set, filter, false)) { + const SpriteFile &file = set.files[group.file]; + Stats &st = stats[std::string{file.classChar, file.armorChar}]; + const int baseline = Baseline(*group.frames[0]); + for (const Frame *fp : group.frames) { + const Frame &f = *fp; + const Reconstruction r = Reconstruct(f, baseline); + ++st.frames; + st.bodyBlack += r.bodyBlack; + st.orphan += r.orphanBlack; + // Shadow cast by the non-black pixels alone. + std::vector nonBlackShadow(f.height, 0); + for (int y = 0; y < f.height; ++y) { + ShadowRow row; + const RowMask src = f.opaque[y] & ~f.black[y]; + if (src == 0 || !ShadowRowFor(baseline, y, row) || row.y2 < 0 || + row.y2 >= f.height) + continue; + nonBlackShadow[row.y2] |= Shift(src, row.dx); + } + const auto isBody = [&](int x, int y) { + return x >= 0 && y >= 0 && x < f.width && y < f.height && + ((r.body[y] >> x) & 1) != 0; + }; + for (int y = 0; y < f.height; ++y) { + st.onlyByBlack += Popcount(f.black[y] & ~r.body[y] & ~r.orphan[y] & + ~nonBlackShadow[y]); + if (r.orphan[y] == 0) + continue; + const int v = baseline - 2 - y; + for (int x = 0; x < f.width; ++x) { + if (((r.orphan[y] >> x) & 1) == 0) + continue; + const int sx = x + (3 * v) / 4; + bool anyInside = false, nearBody = false; + for (int sy = baseline - 3 * v - 2; sy <= baseline - 3 * v; ++sy) { + if (sx >= 0 && sx < f.width && sy >= 0 && sy < f.height) + anyInside = true; + for (int dy = -1; dy <= 1; ++dy) + for (int dx = -1; dx <= 1; ++dx) + if (isBody(sx + dx, sy + dy)) + nearBody = true; + } + if (v < 0 || !anyInside) { + ++st.cropped; + } else if (nearBody) { + ++st.edge; + } else { + ++st.far; + } + } + } + } + } + std::printf("class\tframes\tbodyBlack\tshadowOnlyFromBlack\torphan\tcropped\t" + "edge\tfar\n"); + for (const auto &[name, st] : stats) + std::printf( + "%s\t%lld\t%lld\t%lld\t%lld\t%lld\t%lld\t%lld\n", name.c_str(), + static_cast(st.frames), static_cast(st.bodyBlack), + static_cast(st.onlyByBlack), + static_cast(st.orphan), static_cast(st.cropped), + static_cast(st.edge), static_cast(st.far)); + return 0; +} + +/** + * Like `donor`, but frame by frame and without assuming that the frames line + * up: for every frame of the target, tries the shadow of every frame of every + * file matching `donorFilter` (same direction and frame size), with the donor + * baseline moved by -4..4 rows and the shadow by up to 2 pixels. Prints the + * best donors per target frame; wrong = stray + target black pixels left + * uncovered. + */ +int CmdFrameDonor(const SpriteSet &set, const std::string &targetFilter, + const std::string &donorFilter) { + const std::vector all = MakeGroups(set, "", false); + std::printf( + "target\tframe\tdonor\tdonorFrame\tdB\tdy\tdx\tstray\tuncovered\n"); + for (const Group &tg : MakeGroups(set, targetFilter, false)) { + for (const Frame *tf : tg.frames) { + struct Row { + std::string donor; + int frame, dB, dy, dx; + int64_t stray, uncovered; + }; + std::vector rows; + for (const Group &dg : all) { + if (dg.direction != tg.direction || + dg.name.find(donorFilter) == std::string::npos) + continue; + if (dg.frames[0]->width != tf->width || + dg.frames[0]->height != tf->height) + continue; + for (const Frame *df : dg.frames) { + for (int dB = -4; dB <= 4; ++dB) { + const Reconstruction r = + Reconstruct(*df, Baseline(*dg.frames[0]) + dB); + for (int dy = -2; dy <= 2; ++dy) { + for (int dx = -2; dx <= 2; ++dx) { + Row row{dg.name, df->index, dB, dy, dx, 0, 0}; + for (int y = 0; y < tf->height; ++y) { + const int sy = y - dy; + const RowMask shadow = + sy < 0 || sy >= tf->height + ? RowMask{} + : Shift(r.shadow[sy], dx) & WidthMask(tf->width); + row.stray += Popcount(shadow & ~tf->opaque[y]); + row.uncovered += Popcount(tf->black[y] & ~shadow); + } + rows.push_back(row); + } + } + } + } + } + std::sort(rows.begin(), rows.end(), [](const Row &a, const Row &b) { + return a.stray + a.uncovered < b.stray + b.uncovered; + }); + for (size_t i = 0; i < std::min(rows.size(), 3); ++i) + std::printf("%s\t%d\t%s\t%d\t%d\t%d\t%d\t%lld\t%lld\n", tg.name.c_str(), + tf->index, rows[i].donor.c_str(), rows[i].frame, rows[i].dB, + rows[i].dy, rows[i].dx, + static_cast(rows[i].stray), + static_cast(rows[i].uncovered)); + } + } + return 0; +} + +/** + * Pixel comparison of two files frame by frame (same direction and frame + * index). With `show` set to "/" prints that frame pair as ASCII + * instead of the summary: + * '=' same colour, 'x' different colours (neither black), 'A'/'B' opaque + * non-black only in A / B, + * '#' black in both, 'a'/'b' black only in A / B (other transparent), 'p'/'q' + * black in A / B and coloured in the other. + */ +int CmdCompare(const SpriteSet &set, const std::string &nameA, + const std::string &nameB, const std::string &show) { + const std::vector all = MakeGroups(set, "", false); + if (show.empty()) + std::printf("dir\tframe\tsame\tdiffColour\tonlyA\tonlyB\tblackBoth\tblackOn" + "lyA\tblackOnlyB\tblackA_colB\tcolA_blackB\n"); + for (const Group &ga : all) { + if (set.files[ga.file].name != nameA) + continue; + for (const Group &gb : all) { + if (set.files[gb.file].name != nameB || gb.direction != ga.direction) + continue; + for (size_t i = 0; i < std::min(ga.frames.size(), gb.frames.size()); + ++i) { + const Frame &a = *ga.frames[i]; + const Frame &b = *gb.frames[i]; + const bool print = + show == std::to_string(ga.direction) + "/" + std::to_string(i); + int64_t n[9] = {}; + for (int y = 0; y < std::max(a.height, b.height); ++y) { + if (print) + std::printf("%3d ", y); + for (int x = 0; x < std::max(a.width, b.width); ++x) { + const int16_t ca = a.at(x, y), cb = b.at(x, y); + char c = '.'; + int k = -1; + if (ca == Transparent && cb == Transparent) { + } else if (ca == 0 && cb == 0) { + c = '#', k = 4; + } else if (ca == 0) { + c = cb == Transparent ? 'a' : 'p', k = cb == Transparent ? 5 : 7; + } else if (cb == 0) { + c = ca == Transparent ? 'b' : 'q', k = ca == Transparent ? 6 : 8; + } else if (ca == Transparent) { + c = 'B', k = 3; + } else if (cb == Transparent) { + c = 'A', k = 2; + } else { + c = ca == cb ? '=' : 'x', k = ca == cb ? 0 : 1; + } + if (k >= 0) + ++n[k]; + if (print) + std::putchar(c); + } + if (print) + std::putchar('\n'); + } + if (show.empty()) { + std::printf("%d\t%zu", ga.direction, i); + for (const int64_t v : n) + std::printf("\t%lld", static_cast(v)); + std::putchar('\n'); + } + } + } + } + return 0; +} + +/** + * Two-layer hypothesis: file A is file B (shadow included) plus an extra layer + * (e.g. a shield) that was shadowed separately. The layer is every non-black + * pixel of A that B does not have (mode 0), or that differs from B in any way + * (mode 1). Sweeps the layer's baseline and a shadow offset per direction (or + * per frame) and prints the best: stray = (shadow of B's frame + layer + * shadow) on transparent pixels of A uncovered = black pixels of A covered by + * neither lost = non-black pixels of B that are black in A + */ +int CmdLayers(const SpriteSet &set, const std::string &nameA, + const std::string &nameB, int mode, bool perFrame) { + const std::vector all = MakeGroups(set, "", false); + std::printf("group\tlayerPixels\tbaselineB\tbest layer " + "baseline\tdy\tdx\tstray\tuncovered\tlost\t| layer baseline = " + "baselineB: stray\tuncovered\n"); + for (const Group &ga : all) { + if (set.files[ga.file].name != nameA) + continue; + for (const Group &gb : all) { + if (set.files[gb.file].name != nameB || gb.direction != ga.direction || + gb.frames.size() != ga.frames.size()) + continue; + const int baselineB = Baseline(*gb.frames[0]); + for (size_t begin = 0; begin < ga.frames.size(); + begin = perFrame ? begin + 1 : ga.frames.size()) { + const size_t end = perFrame ? begin + 1 : ga.frames.size(); + int64_t best[3] = {INT64_MAX / 2, INT64_MAX / 2, 0}, same[2] = {0, 0}, + layerPixels = 0; + int bestB = 0, bestDy = 0, bestDx = 0; + for (int bl = baselineB - 40; bl <= baselineB + 12; ++bl) { + for (int dy = -3; dy <= 3; ++dy) { + for (int dx = -3; dx <= 3; ++dx) { + int64_t stray = 0, uncovered = 0, lost = 0, pixels = 0; + for (size_t i = begin; i < end; ++i) { + const Frame &a = *ga.frames[i]; + const Frame &b = *gb.frames[i]; + const Reconstruction rb = Reconstruct(b, baselineB); + std::vector shadow = rb.shadow; + const RowMask wmask = WidthMask(a.width); + for (int y = 0; y < a.height; ++y) { + RowMask layer = + a.opaque[y] & ~a.black[y] & ~(b.opaque[y] & ~b.black[y]); + if (mode == 1) { + for (int x = 0; x < a.width; ++x) + if (a.at(x, y) > 0 && a.at(x, y) != b.at(x, y)) + layer |= static_cast(1) << x; + } + pixels += Popcount(layer); + lost += Popcount(b.opaque[y] & ~b.black[y] & a.black[y]); + ShadowRow row; + if (layer == 0 || !ShadowRowFor(bl, y, row)) + continue; + row.y2 += dy; + if (row.y2 < 0 || row.y2 >= a.height) + continue; + shadow[row.y2] |= Shift(layer, row.dx + dx) & wmask; + } + for (int y = 0; y < a.height; ++y) { + stray += Popcount(shadow[y] & ~a.opaque[y]); + uncovered += Popcount(a.black[y] & ~shadow[y]); + } + } + layerPixels = pixels; + if (bl == baselineB && dy == 0 && dx == 0) + same[0] = stray, same[1] = uncovered; + if (stray + uncovered < best[0] + best[1]) { + best[0] = stray, best[1] = uncovered, best[2] = lost; + bestB = bl, bestDy = dy, bestDx = dx; + } + } + } + } + std::printf( + "%s%s\t%lld\t%d\t%d\t%d\t%d\t%lld\t%lld\t%lld\t|\t%lld\t%lld\n", + ga.name.c_str(), + perFrame ? ("/" + std::to_string(begin)).c_str() : "", + static_cast(layerPixels), baselineB, bestB, bestDy, + bestDx, static_cast(best[0]), + static_cast(best[1]), static_cast(best[2]), + static_cast(same[0]), static_cast(same[1])); + } + } + } + return 0; +} + +/** + * Paste-over hypothesis: the shadow of A is the union of the shadow of B's + * frame (B's own baseline) and the shadow of A's own non-black pixels cast with + * A's baseline moved by `dB`. Prints stray and unexplained black pixels per + * direction, or one frame as ASCII when `show` is "/": + * ':' non-black, 'n' black covered only by B's shadow, 'u' only by A's + * shadow, '#' by both, + * '?' black covered by neither, 'N' / 'X' stray pixel of B's / A's shadow. + */ +int CmdUnion(const SpriteSet &set, const std::string &nameA, + const std::string &nameB, int dB, const std::string &show) { + const std::vector all = MakeGroups(set, "", false); + if (show.empty()) + std::printf("group\tbaselineA\tbaselineB\tstrayFromB\tstrayFromA\tblack\ton" + "lyB\tonlyA\tboth\tneither\tframesExact\n"); + for (const Group &ga : all) { + if (set.files[ga.file].name != nameA) + continue; + for (const Group &gb : all) { + if (set.files[gb.file].name != nameB || gb.direction != ga.direction || + gb.frames.size() != ga.frames.size()) + continue; + const int baselineA = Baseline(*ga.frames[0]) + dB; + const int baselineB = Baseline(*gb.frames[0]); + int64_t n[7] = {}; + int exact = 0; + for (size_t i = 0; i < ga.frames.size(); ++i) { + const Frame &a = *ga.frames[i]; + const Reconstruction rb = Reconstruct(*gb.frames[i], baselineB); + std::vector own(a.height, 0); + const RowMask wmask = WidthMask(a.width); + for (int y = 0; y < a.height; ++y) { + ShadowRow row; + const RowMask src = a.opaque[y] & ~a.black[y]; + if (src == 0 || !ShadowRowFor(baselineA, y, row) || row.y2 < 0 || + row.y2 >= a.height) + continue; + own[row.y2] |= Shift(src, row.dx) & wmask; + } + const bool print = + show == std::to_string(ga.direction) + "/" + std::to_string(i); + int64_t bad = 0; + for (int y = 0; y < a.height; ++y) { + const RowMask sb = + y < static_cast(rb.shadow.size()) ? rb.shadow[y] : RowMask{}; + n[0] += Popcount(sb & ~a.opaque[y]); + n[1] += Popcount(own[y] & ~a.opaque[y]); + n[2] += Popcount(a.black[y]); + n[3] += Popcount(a.black[y] & sb & ~own[y]); + n[4] += Popcount(a.black[y] & ~sb & own[y]); + n[5] += Popcount(a.black[y] & sb & own[y]); + n[6] += Popcount(a.black[y] & ~sb & ~own[y]); + bad += Popcount((sb | own[y]) & ~a.opaque[y]) + + Popcount(a.black[y] & ~sb & ~own[y]); + if (!print) + continue; + std::printf("%3d ", y); + for (int x = 0; x < a.width; ++x) { + const bool inB = ((sb >> x) & 1) != 0, + inA = ((own[y] >> x) & 1) != 0; + const int16_t c = a.at(x, y); + std::putchar(c == Transparent ? (inB ? 'N' + : inA ? 'X' + : '.') + : c != 0 ? ':' + : inB && inA ? '#' + : inB ? 'n' + : inA ? 'u' + : '?'); + } + std::putchar('\n'); + } + if (bad == 0) + ++exact; + } + if (!show.empty()) + continue; + std::printf("%s\t%d\t%d", ga.name.c_str(), baselineA, baselineB); + for (const int64_t v : n) + std::printf("\t%lld", static_cast(v)); + std::printf("\t%d/%zu\n", exact, ga.frames.size()); + } + } + return 0; +} + +/** + * ASCII view of the two-layer hypothesis of `layers` for one frame, layer + * baseline = B's baseline + dB: + * ':' pixel shared with B, 'L' layer pixel, 'n' black covered only by the + * shadow of B's frame, 'l' only by the layer's shadow, '#' by both, '?' by + * neither, 'N' / 'X' stray pixel of B's / the layer's shadow. + */ +int CmdLayerShow(const SpriteSet &set, const std::string &nameA, + const std::string &nameB, int mode, int direction, int index, + int dB) { + const Frame *a = nullptr, *b = nullptr, *firstB = nullptr; + for (const Frame &f : set.frames) { + if (f.direction != direction) + continue; + if (set.files[f.file].name == nameA && f.index == index) + a = &f; + if (set.files[f.file].name == nameB && f.index == 0) + firstB = &f; + if (set.files[f.file].name == nameB && f.index == index) + b = &f; + } + if (a == nullptr || b == nullptr || firstB == nullptr) + return 1; + const int baselineB = Baseline(*firstB); + const Reconstruction rb = Reconstruct(*b, baselineB); + std::vector layer(a->height, 0), cast(a->height, 0); + for (int y = 0; y < a->height; ++y) { + layer[y] = a->opaque[y] & ~a->black[y] & ~(b->opaque[y] & ~b->black[y]); + if (mode == 1) { + for (int x = 0; x < a->width; ++x) + if (a->at(x, y) > 0 && a->at(x, y) != b->at(x, y)) + layer[y] |= static_cast(1) << x; + } + ShadowRow row; + if (layer[y] == 0 || !ShadowRowFor(baselineB + dB, y, row) || row.y2 < 0 || + row.y2 >= a->height) + continue; + cast[row.y2] |= Shift(layer[y], row.dx) & WidthMask(a->width); + } + for (int y = 0; y < a->height; ++y) { + std::printf("%3d ", y); + for (int x = 0; x < a->width; ++x) { + const bool inB = ((rb.shadow[y] >> x) & 1) != 0, + inL = ((cast[y] >> x) & 1) != 0; + const int16_t c = a->at(x, y); + std::putchar(c == Transparent ? (inB ? 'N' + : inL ? 'X' + : '.') + : c != 0 ? (((layer[y] >> x) & 1) != 0 ? 'L' : ':') + : inB && inL ? '#' + : inB ? 'n' + : inL ? 'l' + : '?'); + } + std::putchar('\n'); + } + return 0; +} + +/** + * Per frame: stray/orphan pixels of the reconstruction for baselines around the + * first frame's lowest opaque row (dB = -lo..hi), plus each frame's own lowest + * opaque and lowest non-black row. + */ +int CmdFrameBaselines(const SpriteSet &set, const std::string &filter, int lo, + int hi) { + std::printf("group\tframe\tfirst\town\townNonBlack\tbestdB"); + for (int dB = lo; dB <= hi; ++dB) + std::printf("\tdB=%d", dB); + std::putchar('\n'); + for (const Group &group : MakeGroups(set, filter, false)) { + const int first = Baseline(*group.frames[0]); + for (const Frame *f : group.frames) { + int nonBlack = -1; + for (int y = 0; y < f->height; ++y) + if ((f->opaque[y] & ~f->black[y]) != 0) + nonBlack = y; + std::string cells; + int64_t best = INT64_MAX; + int bestB = 0; + for (int dB = lo; dB <= hi; ++dB) { + const Reconstruction r = Reconstruct(*f, first + dB); + cells += "\t" + std::to_string(r.violations) + "/" + + std::to_string(r.orphanBlack); + if (r.violations + r.orphanBlack < best) + best = r.violations + r.orphanBlack, bestB = dB; + } + std::printf("%s\t%d\t%d\t%d\t%d\t%d%s\n", group.name.c_str(), f->index, + first, Baseline(*f), nonBlack, bestB, cells.c_str()); + } + } + return 0; +} + +/** + * For every frame of A, the frame of B (same direction, any index) that shares + * the most pixels with it. Prints the pixel counts: same colour, different + * colour, only in A, only in B (black pixels excluded). + */ +int CmdBestMatch(const SpriteSet &set, const std::string &nameA, + const std::string &nameB) { + const std::vector all = MakeGroups(set, "", false); + std::printf("dir\tframe\tbestFrameB\tsame\tdiffColour\tonlyA\tonlyB\t| same " + "index: same\tdiffColour\tonlyA\tonlyB\n"); + for (const Group &ga : all) { + if (set.files[ga.file].name != nameA) + continue; + for (const Group &gb : all) { + if (set.files[gb.file].name != nameB || gb.direction != ga.direction) + continue; + for (size_t i = 0; i < ga.frames.size(); ++i) { + const Frame &a = *ga.frames[i]; + int64_t best[4] = {-1, 0, 0, 0}, same[4] = {}; + size_t bestJ = 0; + for (size_t j = 0; j < gb.frames.size(); ++j) { + const Frame &b = *gb.frames[j]; + int64_t n[4] = {}; + for (int y = 0; y < a.height; ++y) { + for (int x = 0; x < a.width; ++x) { + const int16_t ca = a.at(x, y), cb = b.at(x, y); + if (ca > 0 && cb > 0) { + ++n[ca == cb ? 0 : 1]; + } else if (ca > 0) { + ++n[2]; + } else if (cb > 0) { + ++n[3]; + } + } + } + if (n[0] > best[0]) + std::copy(n, n + 4, best), bestJ = j; + if (j == i) + std::copy(n, n + 4, same); + } + std::printf( + "%d\t%zu\t%zu\t%lld\t%lld\t%lld\t%lld\t|\t%lld\t%lld\t%lld\t%lld\n", + ga.direction, i, bestJ, static_cast(best[0]), + static_cast(best[1]), static_cast(best[2]), + static_cast(best[3]), static_cast(same[0]), + static_cast(same[1]), static_cast(same[2]), + static_cast(same[3])); + } + } + } + return 0; +} + +/** + * Per source row of one frame: the number of stray pixels its non-black pixels + * cast for each baseline first+lo..first+hi ('.' = none). Shows whether + * different parts of a sprite were shadowed with different baselines. + */ +int CmdRowBaselines(const SpriteSet &set, const std::string &name, + int direction, int index, int lo, int hi) { + const Frame *first = nullptr; + for (const Frame &f : set.frames) { + if (set.files[f.file].name != name || f.direction != direction) + continue; + if (first == nullptr) + first = &f; + if (f.index != index) + continue; + const int base = Baseline(*first); + std::printf("row\tpixels"); + for (int dB = lo; dB <= hi; ++dB) + std::printf("\t%d", dB); + std::putchar('\n'); + for (int y = 0; y < f.height; ++y) { + const RowMask src = f.opaque[y] & ~f.black[y]; + if (src == 0) + continue; + std::printf("%d\t%d", y, Popcount(src)); + for (int dB = lo; dB <= hi; ++dB) { + ShadowRow row; + if (!ShadowRowFor(base + dB, y, row) || row.y2 < 0 || + row.y2 >= f.height) { + std::printf("\t-"); + continue; + } + const int stray = Popcount(Shift(src, row.dx) & WidthMask(f.width) & + ~f.opaque[row.y2]); + if (stray == 0) { + std::printf("\t."); + } else { + std::printf("\t%d", stray); + } + } + std::putchar('\n'); + } + return 0; + } + return 1; +} + +/** + * Moved-layer hypothesis: A is B plus a layer (see `layers` for `mode`) whose + * shadow was cast with B's baseline + dB while the layer sat (sx, sy) pixels + * away from where it is drawn now. Sweeps dB, sx and sy per direction and + * prints the `top` best variants (stray, then uncovered black). + */ +int CmdLayerMove(const SpriteSet &set, const std::string &nameA, + const std::string &nameB, int mode, int top, + const std::string &dirFilter) { + const std::vector all = MakeGroups(set, "", false); + std::printf("group\tdB\tsx\tsy\tstray\tuncovered\n"); + for (const Group &ga : all) { + if (set.files[ga.file].name != nameA) + continue; + if (!dirFilter.empty() && dirFilter.find(static_cast( + '0' + ga.direction)) == std::string::npos) + continue; + for (const Group &gb : all) { + if (set.files[gb.file].name != nameB || gb.direction != ga.direction || + gb.frames.size() != ga.frames.size()) + continue; + const int baselineB = Baseline(*gb.frames[0]); + std::vector> layers, shadows; + for (size_t i = 0; i < ga.frames.size(); ++i) { + const Frame &a = *ga.frames[i]; + const Frame &b = *gb.frames[i]; + shadows.push_back(Reconstruct(b, baselineB).shadow); + layers.emplace_back(a.height, 0); + for (int y = 0; y < a.height; ++y) { + layers.back()[y] = + a.opaque[y] & ~a.black[y] & ~(b.opaque[y] & ~b.black[y]); + if (mode == 1) { + for (int x = 0; x < a.width; ++x) + if (a.at(x, y) > 0 && a.at(x, y) != b.at(x, y)) + layers.back()[y] |= static_cast(1) << x; + } + } + } + struct Row { + int dB, sx, sy; + int64_t stray, uncovered; + }; + std::vector rows; + for (int dB = -3; dB <= 3; ++dB) { + for (int sy = -8; sy <= 8; ++sy) { + for (int sx = -4; sx <= 4; ++sx) { + Row row{dB, sx, sy, 0, 0}; + for (size_t i = 0; i < ga.frames.size(); ++i) { + const Frame &a = *ga.frames[i]; + std::vector shadow = shadows[i]; + for (int y = 0; y < a.height; ++y) { + ShadowRow sr; + if (layers[i][y] == 0 || + !ShadowRowFor(baselineB + dB, y + sy, sr) || sr.y2 < 0 || + sr.y2 >= a.height) + continue; + shadow[sr.y2] |= + Shift(layers[i][y], sr.dx + sx) & WidthMask(a.width); + } + for (int y = 0; y < a.height; ++y) { + row.stray += Popcount(shadow[y] & ~a.opaque[y]); + row.uncovered += Popcount(a.black[y] & ~shadow[y]); + } + } + rows.push_back(row); + } + } + } + std::sort(rows.begin(), rows.end(), [](const Row &x, const Row &y) { + return x.stray != y.stray ? x.stray < y.stray + : x.uncovered < y.uncovered; + }); + for (int i = 0; i < top && i < static_cast(rows.size()); ++i) + std::printf("%s\t%d\t%d\t%d\t%lld\t%lld\n", ga.name.c_str(), rows[i].dB, + rows[i].sx, rows[i].sy, + static_cast(rows[i].stray), + static_cast(rows[i].uncovered)); + } + } + return 0; +} + +/** + * Splits the non-black pixels of A into those identical in B (layer 0) and the + * rest (layer 1) and, independently for each layer, sweeps baseline offset dB + * and a source displacement (sx, sy). Note that (dB, sx, sy) and (dB + 4, sx + + * 3, sy - 8) are the same transform, so dB only spans 4 values. Prints the + * `top` variants per layer with the fewest stray pixels (ties: most black + * pixels covered). + */ +int CmdTwoLayer(const SpriteSet &set, const std::string &nameA, + const std::string &nameB, int top, + const std::string &dirFilter) { + const std::vector all = MakeGroups(set, "", false); + std::printf("group\tlayer\tpixels\tdB\tsx\tsy\tstray\tblackCovered\n"); + for (const Group &ga : all) { + if (set.files[ga.file].name != nameA) + continue; + if (!dirFilter.empty() && dirFilter.find(static_cast( + '0' + ga.direction)) == std::string::npos) + continue; + for (const Group &gb : all) { + if (set.files[gb.file].name != nameB || gb.direction != ga.direction || + gb.frames.size() != ga.frames.size()) + continue; + const int baseline = Baseline(*ga.frames[0]); + for (int layer = 0; layer < 2; ++layer) { + std::vector> masks; + int64_t pixels = 0; + for (size_t i = 0; i < ga.frames.size(); ++i) { + const Frame &a = *ga.frames[i]; + const Frame &b = *gb.frames[i]; + masks.emplace_back(a.height, 0); + for (int y = 0; y < a.height; ++y) { + for (int x = 0; x < a.width; ++x) { + if (a.at(x, y) <= 0 || (a.at(x, y) == b.at(x, y)) != (layer == 0)) + continue; + masks.back()[y] |= static_cast(1) << x; + ++pixels; + } + } + } + struct Row { + int dB, sx, sy; + int64_t stray, hits; + }; + std::vector rows; + for (int dB = -2; dB <= 1; ++dB) { + for (int sy = -8; sy <= 8; ++sy) { + for (int sx = -4; sx <= 4; ++sx) { + Row row{dB, sx, sy, 0, 0}; + for (size_t i = 0; i < ga.frames.size(); ++i) { + const Frame &a = *ga.frames[i]; + std::vector shadow(a.height, 0); + for (int y = 0; y < a.height; ++y) { + ShadowRow sr; + if (masks[i][y] == 0 || + !ShadowRowFor(baseline + dB, y + sy, sr) || sr.y2 < 0 || + sr.y2 >= a.height) + continue; + shadow[sr.y2] |= + Shift(masks[i][y], sr.dx + sx) & WidthMask(a.width); + } + for (int y = 0; y < a.height; ++y) { + row.stray += Popcount(shadow[y] & ~a.opaque[y]); + row.hits += Popcount(a.black[y] & shadow[y]); + } + } + rows.push_back(row); + } + } + } + std::sort(rows.begin(), rows.end(), [](const Row &x, const Row &y) { + return x.stray != y.stray ? x.stray < y.stray : x.hits > y.hits; + }); + for (int i = 0; i < top && i < static_cast(rows.size()); ++i) + std::printf("%s\t%d\t%lld\t%d\t%d\t%d\t%lld\t%lld\n", ga.name.c_str(), + layer, static_cast(pixels), rows[i].dB, + rows[i].sx, rows[i].sy, + static_cast(rows[i].stray), + static_cast(rows[i].hits)); + } + } + } + return 0; +} + +/** + * Difference-layer hypothesis: A is B's frame (with B's shadow) plus the layer + * of pixels of A that differ from B, and that layer was shadowed on its own, + * with the baseline taken from the layer: its lowest row in frame 0 (`first`) + * or in each frame (`each`). Prints per direction the layer's frame 0 baseline + * next to A's and the stray / uncovered pixels of both rules. + */ +int CmdDiffLayer(const SpriteSet &set, const std::string &nameA, + const std::string &nameB, const std::string &show) { + const std::vector all = MakeGroups(set, "", false); + if (show.empty()) + std::printf( + "group\tbaselineA\tlayerFirst\tfirst: " + "stray\tuncovered\texactFrames\teach: stray\tuncovered\texactFrames\n"); + for (const Group &ga : all) { + if (set.files[ga.file].name != nameA) + continue; + for (const Group &gb : all) { + if (set.files[gb.file].name != nameB || gb.direction != ga.direction || + gb.frames.size() != ga.frames.size()) + continue; + const int baselineB = Baseline(*gb.frames[0]); + int layerFirst = -1; + int64_t stray[2] = {}, uncovered[2] = {}; + int exact[2] = {}; + for (size_t i = 0; i < ga.frames.size(); ++i) { + const Frame &a = *ga.frames[i]; + const Frame &b = *gb.frames[i]; + std::vector layer(a.height, 0); + int lowest = -1; + for (int y = 0; y < a.height; ++y) { + for (int x = 0; x < a.width; ++x) + if (a.at(x, y) > 0 && a.at(x, y) != b.at(x, y)) + layer[y] |= static_cast(1) << x; + if (layer[y] != 0) + lowest = y; + } + if (i == 0) + layerFirst = lowest; + const Reconstruction rb = Reconstruct(b, baselineB); + for (int rule = 0; rule < 2; ++rule) { + const int baseline = rule == 0 ? layerFirst : lowest; + std::vector shadow = rb.shadow, own(a.height, 0); + for (int y = 0; y < a.height; ++y) { + ShadowRow sr; + if (layer[y] == 0 || !ShadowRowFor(baseline, y, sr) || sr.y2 < 0 || + sr.y2 >= a.height) + continue; + own[sr.y2] |= Shift(layer[y], sr.dx) & WidthMask(a.width); + } + int64_t s = 0, u = 0; + for (int y = 0; y < a.height; ++y) { + s += Popcount((shadow[y] | own[y]) & ~a.opaque[y]); + u += Popcount(a.black[y] & ~shadow[y] & ~own[y]); + } + stray[rule] += s; + uncovered[rule] += u; + if (s == 0 && u == 0) + ++exact[rule]; + if (rule != 0 || + show != std::to_string(ga.direction) + "/" + std::to_string(i)) + continue; + std::printf("layer baseline %d\n", baseline); + for (int y = 0; y < a.height; ++y) { + std::printf("%3d ", y); + for (int x = 0; x < a.width; ++x) { + const bool inB = ((shadow[y] >> x) & 1) != 0, + inL = ((own[y] >> x) & 1) != 0; + const int16_t c = a.at(x, y); + std::putchar(c == Transparent ? (inB ? 'N' + : inL ? 'X' + : '.') + : c != 0 ? (((layer[y] >> x) & 1) != 0 ? 'L' : ':') + : inB && inL ? '#' + : inB ? 'n' + : inL ? 'l' + : '?'); + } + std::putchar('\n'); + } + } + } + if (!show.empty()) + continue; + std::printf("%s\t%d\t%d\t%lld\t%lld\t%d/%zu\t%lld\t%lld\t%d/%zu\n", + ga.name.c_str(), Baseline(*ga.frames[0]), layerFirst, + static_cast(stray[0]), + static_cast(uncovered[0]), exact[0], + ga.frames.size(), static_cast(stray[1]), + static_cast(uncovered[1]), exact[1], + ga.frames.size()); + } + } + return 0; +} + +/** + * Row-split hypothesis: source rows above `y0` were shadowed with one baseline + * and the rows from `y0` down with another (both within first-1..first+1). Per + * frame, prints the best split and its stray / uncovered black pixels + * (non-black sources only), next to the unsplit result. + */ +int CmdRowSplit(const SpriteSet &set, const std::string &filter) { + std::printf("group\tframe\tfirst\ty0\ttop dB\tbottom dB\tstray\tuncovered\t| " + "unsplit: stray\tuncovered\n"); + for (const Group &group : MakeGroups(set, filter, false)) { + const int first = Baseline(*group.frames[0]); + int64_t total[4] = {}; + for (const Frame *f : group.frames) { + const auto evaluate = [&](int y0, int top, int bottom, int64_t &stray, + int64_t &uncovered) { + std::vector shadow(f->height, 0); + for (int y = 0; y < f->height; ++y) { + ShadowRow row; + const RowMask src = f->opaque[y] & ~f->black[y]; + if (src == 0 || + !ShadowRowFor(first + (y < y0 ? top : bottom), y, row) || + row.y2 < 0 || row.y2 >= f->height) + continue; + shadow[row.y2] |= Shift(src, row.dx) & WidthMask(f->width); + } + stray = uncovered = 0; + for (int y = 0; y < f->height; ++y) { + stray += Popcount(shadow[y] & ~f->opaque[y]); + uncovered += Popcount(f->black[y] & ~shadow[y]); + } + }; + int64_t best[2] = {INT64_MAX / 2, INT64_MAX / 2}, plain[2]; + int bestY = 0, bestTop = 0, bestBottom = 0; + evaluate(0, 0, 0, plain[0], plain[1]); + for (int top = -1; top <= 1; ++top) { + for (int bottom = -1; bottom <= 1; ++bottom) { + for (int y0 = 0; y0 <= f->height; ++y0) { + int64_t stray, uncovered; + evaluate(y0, top, bottom, stray, uncovered); + if (stray + uncovered >= best[0] + best[1]) + continue; + best[0] = stray, best[1] = uncovered; + bestY = y0, bestTop = top, bestBottom = bottom; + } + } + } + std::printf( + "%s\t%d\t%d\t%d\t%d\t%d\t%lld\t%lld\t|\t%lld\t%lld\n", + group.name.c_str(), f->index, first, bestY, bestTop, bestBottom, + static_cast(best[0]), static_cast(best[1]), + static_cast(plain[0]), static_cast(plain[1])); + total[0] += best[0], total[1] += best[1], total[2] += plain[0], + total[3] += plain[1]; + } + std::printf( + "%s\tTOTAL\t\t\t\t\t%lld\t%lld\t|\t%lld\t%lld\n", group.name.c_str(), + static_cast(total[0]), static_cast(total[1]), + static_cast(total[2]), static_cast(total[3])); + } + return 0; +} + +/** + * Where the black pixels of a sprite are, per file: are they a cast shadow or + * painted detail? + * - `interior`: all 4 neighbours are opaque and not black + * - `edge`: at least one neighbour is transparent + * - `largest`: size of the largest 4-connected black component in any frame + * - `leftmost`: black pixels with no non-black pixel to their left in the row + * (a cast shadow sticks out to the left) Also the number of frames that have + * any black pixel and the largest black count in one frame. + */ +int CmdBlackStats(const SpriteSet &set, const std::string &filter) { + const std::vector groups = MakeGroups(set, filter, false); + std::printf("file\tframes\tframesWithBlack\topaque\tblack\tinterior\tedge\tle" + "ftmost\tlargest\tmaxPerFrame\n"); + for (size_t begin = 0; begin < groups.size();) { + size_t end = begin; + int64_t frames = 0, withBlack = 0, opaque = 0, black = 0, interior = 0, + edge = 0, leftmost = 0, largest = 0, maxPerFrame = 0; + for (; end < groups.size() && groups[end].file == groups[begin].file; + ++end) { + for (const Frame *fp : groups[end].frames) { + const Frame &f = *fp; + ++frames; + int64_t frameBlack = 0; + std::vector seen(static_cast(f.width) * f.height, 0); + for (int y = 0; y < f.height; ++y) { + opaque += Popcount(f.opaque[y]); + bool bodySeen = false; + for (int x = 0; x < f.width; ++x) { + const int16_t c = f.at(x, y); + if (c > 0) + bodySeen = true; + if (c != 0) + continue; + ++frameBlack; + if (!bodySeen) + ++leftmost; + const int16_t n[4] = {f.at(x - 1, y), f.at(x + 1, y), + f.at(x, y - 1), f.at(x, y + 1)}; + if (n[0] == Transparent || n[1] == Transparent || + n[2] == Transparent || n[3] == Transparent) { + ++edge; + } else if (n[0] > 0 && n[1] > 0 && n[2] > 0 && n[3] > 0) { + ++interior; + } + if (seen[static_cast(y) * f.width + x] != 0) + continue; + std::vector> stack{{x, y}}; + seen[static_cast(y) * f.width + x] = 1; + int64_t size = 0; + while (!stack.empty()) { + const auto [cx, cy] = stack.back(); + stack.pop_back(); + ++size; + const int dx[4] = {1, -1, 0, 0}, dy[4] = {0, 0, 1, -1}; + for (int k = 0; k < 4; ++k) { + const int nx = cx + dx[k], ny = cy + dy[k]; + if (f.at(nx, ny) != 0 || + seen[static_cast(ny) * f.width + nx] != 0) + continue; + seen[static_cast(ny) * f.width + nx] = 1; + stack.emplace_back(nx, ny); + } + } + largest = std::max(largest, size); + } + } + black += frameBlack; + if (frameBlack != 0) + ++withBlack; + maxPerFrame = std::max(maxPerFrame, frameBlack); + } + } + const auto ll = [](int64_t v) { return static_cast(v); }; + std::printf("%s\t%lld\t%lld\t%lld\t%lld\t%lld\t%lld\t%lld\t%lld\t%lld\n", + set.files[groups[begin].file].path.c_str(), ll(frames), + ll(withBlack), ll(opaque), ll(black), ll(interior), ll(edge), + ll(leftmost), ll(largest), ll(maxPerFrame)); + begin = end; + } + return 0; +} + +/** + * Lists every stray shadow pixel (predicted on a transparent pixel) with the + * body pixels that cast it: position, palette index and how many of the pixel's + * 8 neighbours are transparent. `rule`: 0 first-frame baseline, 1 each frame's + * own, other values are added to the first-frame baseline. + */ +int CmdStrays(const SpriteSet &set, const std::string &filter, int rule) { + int64_t strays = 0, singleSource = 0, sources = 0, edgeSources = 0; + for (const Group &group : MakeGroups(set, filter, false)) { + for (const Frame *fp : group.frames) { + const Frame &f = *fp; + const int baseline = + rule == 1 ? Baseline(f) + : Baseline(*group.frames[0]) + (rule == 0 ? 0 : rule); + const Reconstruction r = Reconstruct(f, baseline); + for (int y2 = 0; y2 < f.height; ++y2) { + for (int x2 = 0; x2 < f.width; ++x2) { + if (((r.shadow[y2] >> x2) & 1) == 0 || f.at(x2, y2) != Transparent) + continue; + ++strays; + std::printf("%s/%d\tstray (%d,%d)\tfrom", group.name.c_str(), f.index, + x2, y2); + int n = 0; + for (int y = 0; y <= baseline && y < f.height; ++y) { + ShadowRow row; + if (!ShadowRowFor(baseline, y, row) || row.y2 != y2) + continue; + const int x = x2 - row.dx; + if (x < 0 || x >= f.width || ((r.body[y] >> x) & 1) == 0) + continue; + int open = 0; + for (int dy = -1; dy <= 1; ++dy) + for (int dx = -1; dx <= 1; ++dx) + if (f.at(x + dx, y + dy) == Transparent) + ++open; + std::printf("\t(%d,%d) colour %d open %d", x, y, f.at(x, y), open); + ++n; + ++sources; + if (open > 0) + ++edgeSources; + } + if (n == 1) + ++singleSource; + std::putchar('\n'); + } + } + } + } + std::printf( + "strays %lld, with a single source pixel %lld; source pixels %lld, on " + "the silhouette edge %lld\n", + static_cast(strays), static_cast(singleSource), + static_cast(sources), static_cast(edgeSources)); + return 0; +} + +/** + * Looks for one baseline rule that covers both the first-frame and the + * each-frame sprites. For each candidate, counts the groups (file x direction, + * with a shadow) whose non-black pixels cast no stray shadow, split by which of + * the two known rules works for the group: first, own, max(first, own), + * min(first, own), max / min over the direction, max / min over the file, + * previous frame's row, lowest row of the first frame that has any pixel. + */ +int CmdUnified(const SpriteSet &set, const std::string &filter, bool verbose) { + constexpr int Rules = 10; + static const char *const Names[Rules] = {"first", + "own", + "max(first,own)", + "min(first,own)", + "max of direction", + "min of direction", + "max of file", + "min of file", + "previous frame", + "lowest non-black row of first"}; + // Subsets: 0 both known rules work, 1 only first, 2 only own, 3 neither. + int64_t ok[Rules][4] = {}, count[4] = {}; + const std::vector groups = MakeGroups(set, filter, false); + std::map> fileRange; + for (const Group &group : groups) { + for (const Frame *f : group.frames) { + const int b = Baseline(*f); + if (b < 0) + continue; + auto it = fileRange.find(group.file); + if (it == fileRange.end()) { + fileRange[group.file] = {b, b}; + } else { + it->second.first = std::min(it->second.first, b); + it->second.second = std::max(it->second.second, b); + } + } + } + for (const Group &group : groups) { + int64_t black = 0; + int dirMin = INT_MAX, dirMax = -1; + for (const Frame *f : group.frames) { + for (int y = 0; y < f->height; ++y) + black += Popcount(f->black[y]); + const int b = Baseline(*f); + if (b < 0) + continue; + dirMin = std::min(dirMin, b); + dirMax = std::max(dirMax, b); + } + if (black == 0) + continue; + const int first = Baseline(*group.frames[0]); + int firstNonBlack = -1; + for (int y = 0; y < group.frames[0]->height; ++y) + if ((group.frames[0]->opaque[y] & ~group.frames[0]->black[y]) != 0) + firstNonBlack = y; + int64_t stray[Rules] = {}; + for (size_t i = 0; i < group.frames.size(); ++i) { + const Frame &f = *group.frames[i]; + const int own = Baseline(f); + const int baselines[Rules] = {first, + own, + std::max(first, own), + std::min(first, own), + dirMax, + dirMin, + fileRange[group.file].second, + fileRange[group.file].first, + i == 0 ? own + : Baseline(*group.frames[i - 1]), + firstNonBlack}; + for (int rule = 0; rule < Rules; ++rule) + stray[rule] += CountNonBlackCast(f, baselines[rule]).stray; + } + const int subset = stray[0] == 0 && stray[1] == 0 ? 0 + : stray[0] == 0 ? 1 + : stray[1] == 0 ? 2 + : 3; + // Per group: how much the frames' own bottom rows move, and the tallest + // frame content. + if (verbose) { + int top = INT_MAX; + for (const Frame *f : group.frames) + for (int y = 0; y < f->height && y < top; ++y) + if ((f->opaque[y] & ~f->black[y]) != 0) + top = y; + std::printf( + "%s\t%s\tframes %zu\tfirst %d\tmin %d\tmax %d\ttop %d\theight %d\n", + group.name.c_str(), + subset == 0 ? "both" + : subset == 1 ? "first" + : subset == 2 ? "own" + : "neither", + group.frames.size(), first, dirMin, dirMax, top, + group.frames[0]->height); + } + ++count[subset]; + for (int rule = 0; rule < Rules; ++rule) + if (stray[rule] == 0) + ++ok[rule][subset]; + } + std::printf( + "rule\tboth work (%lld)\tonly first (%lld)\tonly own (%lld)\tneither " + "(%lld)\n", + static_cast(count[0]), static_cast(count[1]), + static_cast(count[2]), static_cast(count[3])); + for (int rule = 0; rule < Rules; ++rule) + std::printf("%s\t%lld\t%lld\t%lld\t%lld\n", Names[rule], + static_cast(ok[rule][0]), + static_cast(ok[rule][1]), + static_cast(ok[rule][2]), + static_cast(ok[rule][3])); + return 0; +} + +struct BaselineRule { + bool own = + false; // lowest opaque row of each frame instead of the first frame + int offset = 0; // rows added to the first frame's lowest opaque row +}; + +/** The simplest baseline rule under which the non-black pixels of the group + * cast no stray shadow. */ +BaselineRule DetectRule(const Group &group) { + const auto stray = [&](bool own, int offset, int64_t &hits) { + int64_t n = 0; + const int first = Baseline(*group.frames[0]); + for (const Frame *f : group.frames) { + const CastCount count = + CountNonBlackCast(*f, own ? Baseline(*f) : first + offset); + n += count.stray; + hits += count.hits; + } + return n; + }; + int64_t black = 0, hits = 0; + for (const Frame *f : group.frames) + for (int y = 0; y < f->height; ++y) + black += Popcount(f->black[y]); + if (stray(false, 0, hits) == 0) + return {}; + if (stray(true, 0, hits) == 0) + return {true, 0}; + for (int offset = 1; offset <= 48; ++offset) { + hits = 0; + if (stray(false, offset, hits) == 0 && hits * 2 > black) + return {false, offset}; + } + return {}; +} + +/** + * Compares the two baseline rules head to head: for every group that has black + * pixels, whether the non-black pixels cast no stray shadow with the first + * frame's lowest opaque row, with each frame's own lowest opaque row, with both + * or with neither. + */ +int CmdRuleCompare(const SpriteSet &set, const std::string &filter) { + int64_t groups = 0, both = 0, firstOnly = 0, ownOnly = 0, neither = 0, + sameBaseline = 0; + int64_t strayFirst = 0, strayOwn = 0, frames = 0, framesOwnDiffers = 0; + for (const Group &group : MakeGroups(set, filter, false)) { + int64_t black = 0; + for (const Frame *f : group.frames) + for (int y = 0; y < f->height; ++y) + black += Popcount(f->black[y]); + if (black == 0) + continue; + const int first = Baseline(*group.frames[0]); + int64_t stray[2] = {0, 0}; + bool allSame = true; + for (const Frame *f : group.frames) { + const int own = Baseline(*f); + ++frames; + if (own != first) { + allSame = false; + ++framesOwnDiffers; + } + stray[0] += CountNonBlackCast(*f, first).stray; + stray[1] += CountNonBlackCast(*f, own).stray; + } + ++groups; + strayFirst += stray[0]; + strayOwn += stray[1]; + if (allSame) + ++sameBaseline; + if (stray[0] == 0 && stray[1] == 0) { + ++both; + } else if (stray[0] == 0) { + ++firstOnly; + } else if (stray[1] == 0) { + ++ownOnly; + } else { + ++neither; + } + } + const auto ll = [](int64_t v) { return static_cast(v); }; + std::printf("groups with a shadow: %lld\n", ll(groups)); + std::printf(" both rules work: %lld (of which %lld " + "have the same bottom row in every frame)\n", + ll(both), ll(sameBaseline)); + std::printf(" only the first-frame rule works: %lld\n", + ll(firstOnly)); + std::printf(" only the each-frame rule works: %lld\n", ll(ownOnly)); + std::printf(" neither works: %lld\n", ll(neither)); + std::printf("frames whose own bottom row differs: %lld / %lld\n", + ll(framesOwnDiffers), ll(frames)); + std::printf("stray shadow pixels, first-frame rule: %lld\n", + ll(strayFirst)); + std::printf("stray shadow pixels, each-frame rule: %lld\n", + ll(strayOwn)); + return 0; +} + +int BaselineFor(const Group &group, const Frame &f, bool autoRule) { + // The rule of the most recently queried group is cached; frames are visited + // group by group. + static const Group *cachedGroup = nullptr; + static BaselineRule cachedRule; + if (!autoRule) + return Baseline(*group.frames[0]); + if (cachedGroup != &group) { + cachedGroup = &group; + cachedRule = DetectRule(group); + } + return cachedRule.own ? Baseline(f) + : Baseline(*group.frames[0]) + cachedRule.offset; +} + +/** + * Prints one baseline rule per sprite file, the value of the `shadowRule` + * columns of the TSV files: + * + * - `None`: no black pixels, so no shadow + * - `FirstFrame`: baseline is the lowest opaque row of the first frame of + * each direction + * - `EachFrame`: baseline is the lowest opaque row of each frame + * - `FirstFrame+N`: the first frame's lowest opaque row plus N + * - `Unknown`: no single rule gives a shadow without stray pixels in + * every direction + * + * The last columns give the stray shadow pixels under `FirstFrame` and + * `EachFrame`. + */ +int CmdFileRules(const SpriteSet &set, const std::string &filter) { + const std::vector groups = MakeGroups(set, filter, false); + for (size_t begin = 0; begin < groups.size();) { + size_t end = begin; + while (end < groups.size() && groups[end].file == groups[begin].file) + ++end; + int64_t black = 0; + // stray[0]: FirstFrame, stray[1]: EachFrame, stray[2 + n]: FirstFrame+(n+1) + std::vector stray(2 + 48), hits(2 + 48); + for (size_t g = begin; g < end; ++g) { + const int first = Baseline(*groups[g].frames[0]); + for (const Frame *f : groups[g].frames) { + for (int y = 0; y < f->height; ++y) + black += Popcount(f->black[y]); + for (size_t rule = 0; rule < stray.size(); ++rule) { + const int baseline = rule == 0 ? first + : rule == 1 ? Baseline(*f) + : first + static_cast(rule) - 1; + const CastCount count = CountNonBlackCast(*f, baseline); + stray[rule] += count.stray; + hits[rule] += count.hits; + } + } + } + std::string rule = "Unknown"; + if (black == 0) { + rule = "None"; + } else if (stray[0] == 0) { + rule = "FirstFrame"; + } else if (stray[1] == 0) { + rule = "EachFrame"; + } else { + for (size_t n = 2; n < stray.size(); ++n) { + if (stray[n] != 0 || hits[n] * 2 <= black) + continue; + rule = "FirstFrame+" + std::to_string(n - 1); + break; + } + } + std::printf("%s\t%s\t%lld\t%lld\t%lld\n", + set.files[groups[begin].file].path.c_str(), rule.c_str(), + static_cast(black), static_cast(stray[0]), + static_cast(stray[1])); + begin = end; + } + return 0; +} + +/** + * Sizes up candidate in-memory formats for the shadow mask. + * + * Counts, per frame, the horizontal runs of the visible shadow (black pixels + * that are not body) and of the full cast shadow (which continues underneath + * the body), and estimates the bytes of + * - a CLX mask: one fill command (2 bytes) per run plus skip commands (1 byte + * per 127 pixels) + * - a span list: 2 bytes of frame header, 1 byte per shadow row, 2 bytes per + * run + */ +int CmdMaskStats(const SpriteSet &set, const std::string &filter) { + const auto runs = [](RowMask m, int width) { + int n = 0; + bool prev = false; + for (int x = 0; x < width; ++x) { + const bool cur = ((m >> x) & 1) != 0; + if (cur && !prev) + ++n; + prev = cur; + } + return n; + }; + int64_t frames = 0, visibleRuns = 0, castRuns = 0, visibleRows = 0, + castRows = 0, clxVisible = 0, clxCast = 0, pixels = 0, + visiblePixels = 0, castPixels = 0; + for (const Group &group : MakeGroups(set, filter, false)) { + for (const Frame *f : group.frames) { + const Reconstruction r = Reconstruct(*f, BaselineFor(group, *f, true)); + ++frames; + pixels += static_cast(f->width) * f->height; + for (int kind = 0; kind < 2; ++kind) { + int64_t frameRuns = 0, rows = 0, transparent = 0, skips = 0; + for (int y = 0; y < f->height; ++y) { + // Never add pixels that are not opaque in the original. + const RowMask m = kind == 0 ? f->black[y] & ~r.body[y] & ~r.orphan[y] + : r.shadow[y] & f->opaque[y]; + const int n = runs(m, f->width); + frameRuns += n; + if (n != 0) + ++rows; + (kind == 0 ? visiblePixels : castPixels) += Popcount(m); + // CLX skips run across line ends, so count transparent gaps between + // runs. + bool prev = false; + for (int x = 0; x < f->width; ++x) { + const bool cur = ((m >> x) & 1) != 0; + if (cur && !prev) { + skips += (transparent + 126) / 127; + transparent = 0; + } + if (!cur) + ++transparent; + prev = cur; + } + } + skips += (transparent + 126) / 127; + (kind == 0 ? visibleRuns : castRuns) += frameRuns; + (kind == 0 ? visibleRows : castRows) += rows; + (kind == 0 ? clxVisible : clxCast) += 6 + 2 * frameRuns + skips; + } + } + } + const double n = static_cast(frames); + std::printf("frames: %lld, average frame %0.f pixels\n", + static_cast(frames), pixels / n); + std::printf(" rows/frame runs/frame pixels/frame CLX " + "mask bytes span list bytes\n"); + std::printf("visible shadow %10.1f %10.1f %12.1f %14.1f %15.1f\n", + visibleRows / n, visibleRuns / n, visiblePixels / n, + clxVisible / n, 2 + (visibleRows + 2 * visibleRuns) / n); + std::printf("cast shadow %10.1f %10.1f %12.1f %14.1f %15.1f\n", + castRows / n, castRuns / n, castPixels / n, clxCast / n, + 2 + (castRows + 2 * castRuns) / n); + return 0; +} + +/** Base64 without line breaks. */ +std::string Base64(const std::vector &data) { + static const char Alphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + out.reserve((data.size() + 2) / 3 * 4); + for (size_t i = 0; i < data.size(); i += 3) { + const uint32_t n = (data[i] << 16) | + (i + 1 < data.size() ? data[i + 1] << 8 : 0) | + (i + 2 < data.size() ? data[i + 2] : 0); + out += Alphabet[n >> 18]; + out += Alphabet[(n >> 12) & 63]; + out += i + 1 < data.size() ? Alphabet[(n >> 6) & 63] : '='; + out += i + 2 < data.size() ? Alphabet[n & 63] : '='; + } + return out; +} + +/** + * Prints one frame as text, for scripts that turn it into an image: the size + * and the baseline, the palette, the palette index of every pixel (-1 if + * transparent), and how each pixel is classified ('.' transparent, 'B' body, + * 'K' black pixel kept as body, 'S' black pixel extracted as shadow, 'O' + * unexplained black pixel). A last grid marks the full cast shadow ('#'), + * including the part under the body. + */ +int CmdDumpFrame(devilution::MpqArchive &archive, const SpriteSet &set, + const std::string &name, int direction, int index) { + size_t paletteSize = 0; + const std::unique_ptr palette = + ReadMpqFile(archive, "levels\\towndata\\town.pal", paletteSize); + const Frame *first = nullptr; + for (const Frame &f : set.frames) { + if (set.files[f.file].name != name || f.direction != direction) + continue; + if (first == nullptr) + first = &f; + if (f.index != index) + continue; + const int baseline = Baseline(*first); + const Reconstruction r = Reconstruct(f, baseline); + std::printf("%d %d %d\n", f.width, f.height, baseline); + for (size_t i = 0; i < 768; ++i) + std::printf("%d%c", + palette != nullptr && paletteSize == 768 + ? palette[i] + : static_cast(i / 3), + i + 1 == 768 ? '\n' : ' '); + for (int y = 0; y < f.height; ++y) { + for (int x = 0; x < f.width; ++x) + std::printf("%d%c", f.at(x, y), x + 1 == f.width ? '\n' : ' '); + } + for (int y = 0; y < f.height; ++y) { + for (int x = 0; x < f.width; ++x) { + const int16_t c = f.at(x, y); + const bool body = ((r.body[y] >> x) & 1) != 0; + const bool orphan = ((r.orphan[y] >> x) & 1) != 0; + std::putchar(c == Transparent ? '.' + : orphan ? 'O' + : c != 0 ? 'B' + : body ? 'K' + : 'S'); + } + std::putchar('\n'); + } + for (int y = 0; y < f.height; ++y) { + for (int x = 0; x < f.width; ++x) + std::putchar(((r.shadow[y] >> x) & 1) != 0 ? '#' : '.'); + std::putchar('\n'); + } + return 0; + } + std::fprintf(stderr, "frame not found\n"); + return 1; +} + +/** + * Writes the demo page: replaces the SHADOW_DATA placeholder comment of the + * template with a JSON object holding the palette and the frames of the given + * sprite files. + * + * `names` is a comma-separated list of `[m:|t:|o:][=