diff --git a/.gitattributes b/.gitattributes index e8c6f340..740a0d27 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,3 +10,4 @@ *.png binary *.jpg binary *.ico binary +*.blp binary diff --git a/CMakeLists.txt b/CMakeLists.txt index 2e724d00..67730977 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -76,13 +76,14 @@ endif() add_library(${PROJECT_NAME} SHARED ${SOURCES} ${HEADERS} ${TINYCBOR_SOURCES}) -# Embed the addons under `src/embed/AddOns/` and `src/bindings/Bindings.xml` as RCDATA -# resources. The file-read hook in `src/addons/Embedded.cpp` serves the -# addon files when the engine asks for paths under -# `Interface\AddOns\\`, so the addons load even when the user -# doesn't have them on disk. `src/bindings/Inject.cpp` splices the bindings -# XML (our TARGETING-group additions) into the engine's -# `Interface\FrameXML\Bindings.xml` at load time. +# Embed the addons under `src/embed/AddOns/`, `src/bindings/Bindings.xml` and +# the textures under `src/embed/Textures/` as RCDATA resources. The file-read +# hook in `src/addons/Embedded.cpp` serves the addon files when the engine +# asks for paths under `Interface\AddOns\\`, so the addons load even +# when the user doesn't have them on disk. `src/bindings/Inject.cpp` splices +# the bindings XML (our TARGETING-group additions) into the engine's +# `Interface\FrameXML\Bindings.xml` at load time. `src/texture/Builtin.cpp` +# serves each texture at its game path when no file has it. include(cmake/embed_resources.cmake) embed_resources( TARGET ${PROJECT_NAME} @@ -90,6 +91,7 @@ embed_resources( ADDONS "!!!ClassicAPI" "ClassicAPI_DebugTools" "ClassicAPI_EventTrace" ADDON_VERSION "${CLASSICAPI_TOC_VERSION}" BINDINGS_XML "${CMAKE_CURRENT_SOURCE_DIR}/src/bindings/Bindings.xml" + TEXTURES_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/src/embed/Textures" OUT_DIR "${CMAKE_BINARY_DIR}/generated") target_compile_definitions( diff --git a/README.md b/README.md index ded5eeb3..d9790744 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,7 @@ open the game menu and click AddOn Options, then ClassicAPI. | [Bag sorting](docs/API.md#c_containergetsortbagsrighttoleft--c_containersetsortbagsrighttoleftenable) | `sortBagsRightToLeft` | `0` | `1` makes a sort fill your bags from the last slot of the last bag. | | | `backpackAutosortDisabled` | `0` | `1` keeps the items in your backpack in place when you sort your bags. | | | `bankAutosortDisabled` | `0` | `1` keeps the items in the main bank slots in place when you sort your bank. | +| [Expanded cursors](docs/API.md#expanded-cursors-expandedcursors) | `expandedCursors` | `0` | `1` makes the cursor show what you can do with an NPC or an object: an anvil for repairs, a hearthstone for an inn, a horseshoe for a stable, `!` or `?` for quests, and a fish hook for your fishing bobber. | | [Equipment sets](docs/API.md#equipment-sets-on-item-tooltips) | `dontShowEquipmentSetsOnItems` | `0` | `1` removes the Equipment Sets line from item tooltips. | | [Name completion](docs/API.md#name-completion-settings) | `autoCompleteResortNamesOnRecency` | `1` | `1` puts the players that you whispered first in `C_AutoComplete` results, the most recent first. `0` uses alphabetical order. | | | `autoCompleteWhenEditingFromCenter` | `1` | `1` also matches the text after the cursor when you type inside a name. | diff --git a/cmake/embed_resources.cmake b/cmake/embed_resources.cmake index 8b40ef40..6e52e098 100644 --- a/cmake/embed_resources.cmake +++ b/cmake/embed_resources.cmake @@ -1,5 +1,5 @@ -# embed_resources.cmake — embed the addons and the bindings XML as Win32 -# RCDATA resources. +# embed_resources.cmake — embed the addons, the bindings XML and the +# built-in textures as Win32 RCDATA resources. # # Runs at configure time. It writes two files into OUT_DIR: # @@ -16,20 +16,27 @@ # constexpr File kFiles[] = { ... }; # constexpr size_t kFileCount = ...; # } +# namespace Texture::Builtin::Manifest { +# struct File { const char *path; int resourceId; int index; }; +# constexpr File kFiles[] = { ... }; +# constexpr size_t kFileCount = ...; +# } # # Each resource is a pack: one gzip stream of several files, which # pack_files.cmake joins at build time into `OUT_DIR/embed/.pack.gz`. # Resource 1 is the bindings XML, a pack of one; then each addon is one -# pack, in ADDONS order. A pack compresses each file against the files -# before it, so the addon text packs to about 22% of its size, against 27% -# for one gzip stream per file. The DLL inflates a whole pack with the -# engine's zlib on first use (src/embed/Resource.cpp), so the -# load-on-demand addon costs nothing until it loads. +# pack, in ADDONS order; then the textures, one pack. A pack compresses each +# file against the files before it, so the addon text packs to about 22% of +# its size, against 27% for one gzip stream per file. The DLL inflates a +# whole pack with the engine's zlib on first use (src/embed/Resource.cpp), +# so the load-on-demand addon costs nothing until it loads. # -# File paths are relative to ADDONS_ROOT, in backslash form +# Addon file paths are relative to ADDONS_ROOT, in backslash form # (`!!!ClassicAPI\Util\Print.lua`) — the suffix the engine builds after -# `Interface\AddOns\`. The DLL compares them ignoring case. A file's index -# is its place in its addon's pack, which is in path order. +# `Interface\AddOns\`. Texture paths are relative to TEXTURES_ROOT, where +# each file sits at its game path (`Interface\Cursor\Quest.blp`), for +# src/texture/Builtin.cpp. The DLL compares both ignoring case. A file's +# index is its place in its pack, which is in path order. # # Each addon's `.toc` is the one file that is not embedded verbatim: its # `## Version:` line is stamped with ADDON_VERSION into a copy under @@ -46,7 +53,8 @@ # # Usage: # embed_resources(TARGET ADDONS_ROOT ADDONS -# ADDON_VERSION BINDINGS_XML OUT_DIR ) +# ADDON_VERSION BINDINGS_XML +# TEXTURES_ROOT OUT_DIR ) set(_EMBED_PACK_SCRIPT "${CMAKE_CURRENT_LIST_DIR}/pack_files.cmake") @@ -81,7 +89,8 @@ function(_embed_write_if_changed path content) endfunction() function(embed_resources) - cmake_parse_arguments(E "" "TARGET;ADDONS_ROOT;ADDON_VERSION;BINDINGS_XML;OUT_DIR" + cmake_parse_arguments(E "" + "TARGET;ADDONS_ROOT;ADDON_VERSION;BINDINGS_XML;TEXTURES_ROOT;OUT_DIR" "ADDONS" ${ARGN}) set(rc "// Generated by cmake/embed_resources.cmake - do not edit.\n\n") @@ -125,6 +134,22 @@ function(embed_resources) math(EXPR id "${id} + 1") endforeach() + file(GLOB_RECURSE textures CONFIGURE_DEPENDS RELATIVE + "${E_TEXTURES_ROOT}" "${E_TEXTURES_ROOT}/*") + list(SORT textures) # deterministic indexes + set(texture_paths "") + set(texture_manifest "") + set(index 0) + foreach(rel ${textures}) + list(APPEND texture_paths "${E_TEXTURES_ROOT}/${rel}") + string(REPLACE "/" "\\\\" rel_bs "${rel}") + string(APPEND texture_manifest " { \"${rel_bs}\", ${id}, ${index} },\n") + math(EXPR index "${index} + 1") + endforeach() + _embed_pack("${E_OUT_DIR}" "Textures" ${texture_paths}) + string(APPEND rc "${id} RCDATA \"${gz}\"\n") + list(APPEND inputs "${gz}") + _embed_write_if_changed("${E_OUT_DIR}/embedded.rc" "${rc}") _embed_write_if_changed("${E_OUT_DIR}/embedded_resources.h" "// Generated by cmake/embed_resources.cmake — do not edit. @@ -159,6 +184,21 @@ ${manifest}}; constexpr size_t kFileCount = sizeof(kFiles) / sizeof(kFiles[0]); } // namespace Addons::Embedded::Manifest + +namespace Texture::Builtin::Manifest { + +struct File { + const char *path; // the game path + int resourceId; // the textures' pack + int index; // its place in the pack +}; + +constexpr File kFiles[] = { +${texture_manifest}}; + +constexpr size_t kFileCount = sizeof(kFiles) / sizeof(kFiles[0]); + +} // namespace Texture::Builtin::Manifest ") add_custom_target(${E_TARGET}_embed DEPENDS ${inputs}) diff --git a/docs/API.md b/docs/API.md index 6ff7fdfd..7ba4c494 100644 --- a/docs/API.md +++ b/docs/API.md @@ -202,6 +202,7 @@ build instructions. - [Cursor](#cursor) - [`GetCursorInfo()`](#getcursorinfo) - [`CursorHasMacro()`](#cursorhasmacro) + - [Expanded cursors (`expandedCursors`)](#expanded-cursors-expandedcursors) - [DeathInfo](#deathinfo) - [`C_DeathInfo.GetSelfResurrectOptions()`](#c_deathinfogetselfresurrectoptions) @@ -4855,6 +4856,57 @@ Changes to what the cursor holds are reported by the Returns `true` if the cursor holds a macro, and `false` if it does not. +### Expanded cursors (`expandedCursors`) + +When the `expandedCursors` setting is `1` and you point at an NPC or an +object, the cursor shows what you can do with it. The default is `0`. You can +also change the setting in the ClassicAPI options, on the Interface page. + +These NPC services get new images: + +| The NPC has | The cursor | +|---|---| +| A quest that you can accept, or a quest that you did not complete | A yellow `!` | +| A quest that you can turn in | A yellow `?` | +| A repeatable quest that you can turn in | A blue `?` | +| Repairs | An anvil | +| An inn | A hearthstone | +| A stable | A horseshoe | + +When an NPC has more than one service, the cursor shows the first service +in this list: + +1. A quest +2. Repairs +3. An inn +4. Flights +5. Training +6. Resurrection at a spirit healer +7. A bank +8. Guild charters, tabards and battlegrounds +9. An auction house +10. A stable +11. Goods for sale +12. Gossip + +The other services keep their usual images. For example, a vendor that also +has gossip shows the vendor image, not the speech bubble. + +Your fishing bobber shows a fish hook, not the gear. + +When you cannot use the NPC or the object, the cursor shows the gray form of +the image. For example, this occurs when you are too far away. + +The soft-interact icon and +[`SetUnitCursorTexture`](#setunitcursortexturetexture-unit--style--includelowpriority) +show the same images as the cursor. For a quest, they show no image. + +``` +/console expandedCursors 1 +``` + +*ClassicAPI extension.* + ## DeathInfo ### `C_DeathInfo.GetSelfResurrectOptions()` @@ -24660,6 +24712,9 @@ Returns `true` when it sets the texture. When the unit has no image, it returns `false` and clears the texture. Otherwise it returns `false` and does not change the texture. +A quest cursor (see [Expanded cursors](#expanded-cursors-expandedcursors)) +has no image, also when `includeLowPriority` is `true`. + ```lua local icon = UIParent:CreateTexture(nil, "OVERLAY") icon:SetWidth(24) diff --git a/src/Common.cpp b/src/Common.cpp index 4e1ee95a..272ebec8 100644 --- a/src/Common.cpp +++ b/src/Common.cpp @@ -113,4 +113,20 @@ void EnsureParentDirs(const char *relPath) { } } +char FoldPathChar(char c) { + if (c == '/') + return '\\'; + if (c >= 'A' && c <= 'Z') + return static_cast(c + ('a' - 'A')); + return c; +} + +bool SamePath(const char *a, const char *b) { + for (; FoldPathChar(*a) == FoldPathChar(*b); ++a, ++b) { + if (*a == '\0') + return true; + } + return false; +} + } // namespace Common diff --git a/src/Common.h b/src/Common.h index 90e97b24..c3dbe131 100644 --- a/src/Common.h +++ b/src/Common.h @@ -49,4 +49,11 @@ void BoundedCopy(char *dst, const char *src, size_t n); // directory. Directories that already exist are a no-op. void EnsureParentDirs(const char *relPath); +// `c` as game paths compare it: '/' as '\', and A-Z as a-z. +char FoldPathChar(char c); + +// Whether `a` and `b` are the same game path: equal after FoldPathChar, so +// any case and either slash. +bool SamePath(const char *a, const char *b); + } // namespace Common diff --git a/src/Offsets.h b/src/Offsets.h index aeea7642..25a94b07 100644 --- a/src/Offsets.h +++ b/src/Offsets.h @@ -1977,10 +1977,36 @@ enum Offsets { VAR_CURSOR_BASE_MODE = 0x00BE2C4C, CURSOR_MODE_POINT = 1, CURSOR_MODE_CAST = 2, + CURSOR_MODE_BUY = 3, CURSOR_MODE_ATTACK = 4, + CURSOR_MODE_INTERACT = 5, + CURSOR_MODE_SPEAK = 6, + CURSOR_MODE_PICKUP = 8, + CURSOR_MODE_TAXI = 9, + CURSOR_MODE_TRAINER = 10, CURSOR_MODE_LAST_NAMED = 20, CURSOR_MODE_UNABLE_OFFSET = 20, CURSOR_MODE_CUSTOM = 0x29, + // The shown mode, the one SetCursorMode compares and stores. + VAR_CURSOR_MODE = 0x00BE2C2C, + // SendCursorImage — `void __cdecl(void)`: copies the image slot of + // VAR_CURSOR_MODE (slot n at 0x00BE2D80 + n * 0x1000, CURSOR_IMAGE_SIZE + // squared BGRA8888 texels) to the device cursor, or the dragged payload's + // picture while one is held (disassembled 0x523790-0x52380D). + FUN_SEND_CURSOR_IMAGE = 0x00523790, + // SetCustomCursor — `uint32_t __fastcall(const char *path)`, plain ret: + // shows a named BLP as mode 41. Returns 1 at once when mode 41 already + // shows `path` (SStrCmpI against VAR_CUSTOM_CURSOR_NAME). Otherwise it + // loads the file, and for a 32x32 image stores mode 41, copies the texels + // into slot 41 (VAR_CURSOR_IMAGE_CUSTOM), sends the image, copies `path` + // into VAR_CUSTOM_CURSOR_NAME (CURSOR_FILE_SIZE bytes) and returns 1; a + // file that does not load returns 0 and changes nothing (disassembled + // 0x523C40-0x523D0E). ClassifyHoveredGameObject uses it for a + // record-named cursor. + FUN_SET_CUSTOM_CURSOR = 0x00523C40, + VAR_CURSOR_IMAGE_CUSTOM = 0x00C0BD80, + VAR_CUSTOM_CURSOR_NAME = 0x00BE2C70, + CURSOR_IMAGE_SIZE = 32, // s_cursorNames: `const char *[42]`, mode -> image name (0 and 41 null; // 21..40 carry their own "Unable…" names). The loader formats // `Interface\Cursor\%s.blp` from it (0x5236C0); dumped from the exe. @@ -2000,6 +2026,16 @@ enum Offsets { // 9 / 0x19 and 0x81 after the object's IsMouseoverable answers nonzero. FUN_WORLD_FRAME_CLASSIFY_UNIT = 0x00482200, FUN_WORLD_FRAME_CLASSIFY_CORPSE = 0x00482740, + // ClassifyHoveredUnit's NPC flag chain (0x482336-0x4826D6), reached for a + // unit the player can interact with now (0x606880) with the unit in ESI, + // the player in EDI, and EBX 1 when the unit is beyond the interact reach + // (squared distance above 0xB4B32C), else 0. Its head is `mov ecx, + // [esi + 0x110]` (8B 8E 10 01 00 00), and no jump in the function lands + // inside it. CLASSIFY_UNIT_RETURN is the epilogue after the chain's first + // SetCursorMode call (`pop edi; pop esi; pop ebx; mov esp, ebp; pop ebp; + // ret 4`), with ESP as it is at the head (disassembled 0x482200-0x4826D6). + SITE_CLASSIFY_UNIT_NPC_FLAGS = 0x00482336, + CLASSIFY_UNIT_RETURN = 0x00482356, // CGGameObject_C::GetCursor — `uint32_t __thiscall(go)`: 0 when not // interactable, CURSOR_MODE_CUSTOM for a record-named cursor, else the // handler's mode (+20 when not usable). IsUsable — `bool __thiscall(go)`, @@ -2008,6 +2044,17 @@ enum Offsets { // a custom-cursor texture load for 41. FUN_GAMEOBJECT_GET_CURSOR = 0x005F8760, FUN_GAMEOBJECT_IS_USABLE = 0x005F8810, + // The handler's mode is its vtable slot 4 (+0x10), which GetCursor + // tail-jumps to with the handler in ECX (`jmp [eax+0x10]` at 0x5F878B); + // GetCursor is the slot's one reader. BaseHandlerGetCursor — `uint32_t + // __thiscall(handler)`, plain ret — is the slot in most types: 0 when slot + // 5 says not interactable; a lock's LockType cursor by name; else + // CURSOR_MODE_INTERACT when usable (slot 6), its Unable form when not + // (disassembled 0x5F3070-0x5F3123). The fishing node (type 17) handler's + // vtable is 0x80BC80, whose slot 4 holds it (benilla-re + // s_fishingNodeHandlerVtable; xref 0x80BC90). + FUN_GO_HANDLER_BASE_GET_CURSOR = 0x005F3070, + SLOT_FISHING_NODE_HANDLER_GET_CURSOR = 0x0080BC90, // Object type words (descriptor OBJECT_FIELD_TYPE) the hover dispatch // switches on, exactly (0x4828D0): unit, player, game object, corpse. OBJECT_TYPE_WORD_UNIT = 0x09, @@ -6308,6 +6355,17 @@ enum Offsets { OFF_UNIT_DESCRIPTOR_RACE_BYTE = 0x78, OFF_UNIT_DESCRIPTOR_CLASS_BYTE = 0x79, OFF_UNIT_DESCRIPTOR_SEX_BYTE = 0x7A, + // UNIT_NPC_FLAGS, read at descriptor +0x234 by ClassifyHoveredUnit's flag + // chain (0x48233C) and IsQuestGiverToTalk (0x5DF49C). + OFF_UNIT_DESCRIPTOR_NPC_FLAGS = 0x234, + // CGUnit_C::m_questStatus: the quest giver status the server last sent + // (SMSG_QUESTGIVER_STATUS), stored by SetQuestGiverStatus (0x607440) for + // a unit with NPC flag 0x2 (benilla-re objectlayer/unitnames). + OFF_CGUNIT_QUEST_STATUS = 0xCB8, + // IsQuestGiverToTalk — `uint32_t __stdcall(CGUnit_C *unit)`, ret 4: 1 for + // NPC flag 0x2 with a quest status other than 0 and 1. ClassifyHoveredUnit + // shows Speak for it (disassembled 0x5DF490-0x5DF4C4). + FUN_IS_QUEST_GIVER_TO_TALK = 0x005DF490, // The local player's UNIT_FIELD_BYTES_0 race/class/sex bytes, mirrored // into a login-time global that is populated at character-enter — @@ -9321,9 +9379,21 @@ enum Offsets { FUN_BLP_DECODE_LEVEL = 0x005A7F00, FUN_BLP_FREE_SCRATCH = 0x005A8100, FUN_BLP_RESET = 0x005A7AA0, + // Parse(this, const void *file) → nonzero for a 'BLP2' type-1 header + // (ret 4): Open's second half. It copies BLP_HEADER_SIZE bytes of `file` + // into the record and keeps `file` as the pixel source (+0x498), which + // Reset only drops (it frees the decoded chain at +0x00), so a buffer the + // caller owns decodes like the one Open reads (disassembled + // 0x5A83B0-0x5A842A). + FUN_BLP_PARSE = 0x005A83B0, + BLP_HEADER_SIZE = 0x494, BLP_RECORD_SIZE = 0x4B4, OFF_BLP_MAGIC = 0x04, OFF_BLP_VERSION = 0x08, + // byte: 1 palette, 2 DXT, 3 raw BGRA (DecodeLevel's switch, 0x5A7F6B) + OFF_BLP_COMPRESSION = 0x0C, + // byte: for DXT, 0 DXT1, 1 DXT3, 7 DXT5 (DecodeLevel takes its class + // at 0x5A7FC3) OFF_BLP_ALPHA_TYPE = 0x0E, OFF_BLP_WIDTH = 0x10, OFF_BLP_HEIGHT = 0x14, @@ -9333,6 +9403,15 @@ enum Offsets { // benilla image/convert#palette-to-bgra). GetPortraitAlphaMask decodes // to this format. GX_TEX_FORMAT_BGRA8888 = 2, + // DecodeLevel converts a DXT level to another format through the matrix + // InitConvertMatrix (0x005A4FC0) fills, by the class of the alpha type. + // It fills DXT1 and DXT3 to BGRA8888 (cells 0x00C0F840, 0x00C0F8D0) and + // leaves DXT5's (0x00C0F960) empty, so a DXT5 level gets an unwritten + // SMemAlloc buffer and DecodeLevel still returns 1 (disassembled + // 0x5A4FC0-0x5A5062 and 0x5A7FC3-0x5A8045; benilla + // image/convert#conversion-matrix). + BLP_COMPRESSION_DXT = 2, + BLP_ALPHA_TYPE_DXT5 = 7, // MPQ file enumerator — walks every mounted archive's `(listfile)` // and invokes a callback for each entry whose path begins with a diff --git a/src/addons/Embedded.cpp b/src/addons/Embedded.cpp index e702e912..949100d3 100644 --- a/src/addons/Embedded.cpp +++ b/src/addons/Embedded.cpp @@ -65,6 +65,7 @@ #include "embedded_resources.h" +#include "Common.h" #include "Game.h" #include "Offsets.h" #include "addons/Embedded.h" @@ -103,26 +104,12 @@ constexpr const char *kDevMarkerFile = ".classicapi-dev"; // paths are case-insensitive. constexpr const char *kAddOnsPrefix = "Interface\\AddOns\\"; -char NormalizeChar(char c) { - if (c == '/') return '\\'; - if (c >= 'A' && c <= 'Z') return static_cast(c + 32); - return c; -} - -bool PathEqualsCI(const char *a, const char *b) { - while (*a && *b) { - if (NormalizeChar(*a) != NormalizeChar(*b)) return false; - ++a; ++b; - } - return *a == *b; -} - // `path` with the case-insensitive `prefix` stripped, NULL when it does // not start with it. const char *StripPrefixCI(const char *path, const char *prefix) { while (*prefix) { if (*path == '\0') return nullptr; - if (NormalizeChar(*path) != NormalizeChar(*prefix)) return nullptr; + if (Common::FoldPathChar(*path) != Common::FoldPathChar(*prefix)) return nullptr; ++path; ++prefix; } return path; @@ -130,7 +117,7 @@ const char *StripPrefixCI(const char *path, const char *prefix) { int IndexOfAddon(const char *name) { for (size_t i = 0; i < kAddonCount; ++i) { - if (PathEqualsCI(name, kAddons[i])) + if (Common::SamePath(name, kAddons[i])) return static_cast(i); } return -1; @@ -144,7 +131,7 @@ int MatchAddonPath(const char *path, const char **relPath) { if (rel == nullptr) return -1; for (size_t i = 0; i < kAddonCount; ++i) { const char *rest = StripPrefixCI(rel, kAddons[i]); - if (rest != nullptr && NormalizeChar(*rest) == '\\') { + if (rest != nullptr && Common::FoldPathChar(*rest) == '\\') { *relPath = rel; return static_cast(i); } @@ -155,12 +142,10 @@ int MatchAddonPath(const char *path, const char **relPath) { // The embedded bytes for manifest path `relPath`; `data` is NULL when it // is not embedded. Embed::Bytes LookupEmbedded(const char *relPath) { - for (size_t i = 0; i < Manifest::kFileCount; ++i) { - if (PathEqualsCI(relPath, Manifest::kFiles[i].path)) - return Embed::File(Manifest::kFiles[i].resourceId, - Manifest::kFiles[i].index); - } - return {nullptr, 0}; + const Manifest::File *file = Embed::FindFile(Manifest::kFiles, relPath); + if (file == nullptr) + return {nullptr, 0}; + return Embed::File(file->resourceId, file->index); } // Engine file I/O + Storm allocator — see addons/EngineIO.h for the shapes and diff --git a/src/bindings/Inject.cpp b/src/bindings/Inject.cpp index d4c7d8a3..19c25919 100644 --- a/src/bindings/Inject.cpp +++ b/src/bindings/Inject.cpp @@ -47,6 +47,7 @@ // section's header follows. #include "Inject.h" +#include "Common.h" #include "Offsets.h" #include "embed/Resource.h" @@ -212,20 +213,6 @@ const char *FindSplicePosition(const char *content, size_t size, return FindOutsideComment(content, end, ""); } -char NormalizeChar(char c) { - if (c == '/') return '\\'; - if (c >= 'A' && c <= 'Z') return static_cast(c + 32); - return c; -} - -bool PathEqualsCI(const char *a, const char *b) { - while (*a != '\0' && *b != '\0') { - if (NormalizeChar(*a) != NormalizeChar(*b)) return false; - ++a; ++b; - } - return *a == *b; -} - using SMemAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags); using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int flags); @@ -233,7 +220,7 @@ using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize, size_t extraBytes, int flag1, int flag2, FileReadFn original) { - if (path == nullptr || !PathEqualsCI(path, kFrameXMLBindingsPath)) + if (path == nullptr || !Common::SamePath(path, kFrameXMLBindingsPath)) return false; // Pull the real file through the original reader with the caller's diff --git a/src/cursor/Extended.cpp b/src/cursor/Extended.cpp new file mode 100644 index 00000000..13c02f9b --- /dev/null +++ b/src/cursor/Extended.cpp @@ -0,0 +1,118 @@ +// This file is part of ClassicAPI. +// +// ClassicAPI is free software: you can redistribute it and/or modify it under the terms +// of the GNU General Public License as published by the Free Software Foundation, either +// version 3 of the License, or (at your option) any later version. +// +// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with +// ClassicAPI. If not, see . + +// The cursor modes later clients added, shown on 1.12's cursor, and the +// `expandedCursors` cvar that turns them on (0 by default: they change what +// 1.12 shows). +// +// The engine's cursor is a table of 42 images of 32x32, one per mode; +// SendCursorImage copies slot VAR_CURSOR_MODE to the device. SetCursorMode +// stores the mode it is given and sends, so a mode past 41 must never reach +// it. Slot 41 is how the engine shows an image outside the table: +// SetCustomCursor loads a named BLP into it, stores mode 41, sends it and +// keeps the name, and returns at once while that name is already showing. The +// game object classifier uses it for a record-named cursor. +// +// Show() calls SetCustomCursor with the mode's path, so a file of that name (a +// patch MPQ, a loose file) wins, and a repeat each frame costs one name +// compare. 1.12 has none of these files, so on a miss Show() does what +// SetCustomCursor does after its load, with the image embedded in the DLL +// (Texture::Builtin), which a texture load of the same path also gets. +// +// The files are 4.3.4's Interface\Cursor images, under 4.3.4's names. 4.3.4 +// already ships StableMaster.blp and Fishing.blp, but only as art a record +// could name. StableMaster is a mode in retail's table (Enum.Cursormode +// StablemasterCursor, listed after Ping and Enchant), not in 4.3.4's; no +// client's table has Fishing. + +#include "cursor/Extended.h" + +#include "Common.h" +#include "Game.h" +#include "cvar/Factory.h" +#include "texture/Builtin.h" + +#include + +namespace Cursor::Extended { + +namespace { + +using SetCustomCursor_t = uint32_t(__fastcall *)(const char *path); +using SendCursorImage_t = void(__cdecl *)(); + +// In mode order, the Unable forms after the others. +const char *const kPaths[] = { + "Interface\\Cursor\\RepairNPC.blp", + "Interface\\Cursor\\Innkeeper.blp", + "Interface\\Cursor\\Quest.blp", + "Interface\\Cursor\\QuestRepeatable.blp", + "Interface\\Cursor\\QuestTurnIn.blp", + "Interface\\Cursor\\StableMaster.blp", + "Interface\\Cursor\\Fishing.blp", + "Interface\\Cursor\\UnableRepairNPC.blp", + "Interface\\Cursor\\UnableInnkeeper.blp", + "Interface\\Cursor\\UnableQuest.blp", + "Interface\\Cursor\\UnableQuestRepeatable.blp", + "Interface\\Cursor\\UnableQuestTurnIn.blp", + "Interface\\Cursor\\UnableStableMaster.blp", + "Interface\\Cursor\\UnableFishing.blp", +}; +static_assert(sizeof kPaths / sizeof kPaths[0] == 2 * kUnableOffset, "one path per mode"); + +CVar::Factory::Handle g_enabled = nullptr; + +void Register() { + g_enabled = CVar::Factory::Register( + "expandedCursors", + "Show what an NPC or object offers on the cursor: anvil for repairs, ! and ? for quests, " + "hook for your bobber", + Offsets::CVAR_CATEGORY_GAME, "0"); +} + +const Game::ModuleAutoRegister _autoreg{&Register}; + +} // namespace + +bool Enabled() { return CVar::Factory::GetInt(g_enabled) != 0; } + +bool Is(uint32_t mode) { return mode >= kRepairNpc && mode < kRepairNpc + 2 * kUnableOffset; } + +bool IsUnable(uint32_t mode) { return Is(mode) && mode >= kRepairNpc + kUnableOffset; } + +bool IsQuest(uint32_t mode) { + if (!Is(mode)) + return false; + const uint32_t kind = IsUnable(mode) ? mode - kUnableOffset : mode; + return kind >= kQuest && kind <= kQuestTurnIn; +} + +const char *Path(uint32_t mode) { return kPaths[mode - kRepairNpc]; } + +void Show(uint32_t mode) { + const char *path = Path(mode); + if (reinterpret_cast(Offsets::FUN_SET_CUSTOM_CURSOR)(path) != 0) + return; + const Texture::Builtin::Image *image = Texture::Builtin::Find(path); + if (image == nullptr || image->width != Offsets::CURSOR_IMAGE_SIZE || + image->height != Offsets::CURSOR_IMAGE_SIZE) + return; + Game::Ref(Offsets::VAR_CURSOR_MODE) = Offsets::CURSOR_MODE_CUSTOM; + std::memcpy(reinterpret_cast(Offsets::VAR_CURSOR_IMAGE_CUSTOM), image->texels, + Offsets::CURSOR_IMAGE_SIZE * Offsets::CURSOR_IMAGE_SIZE * sizeof(uint32_t)); + reinterpret_cast(Offsets::FUN_SEND_CURSOR_IMAGE)(); + Common::BoundedCopy(reinterpret_cast(Offsets::VAR_CUSTOM_CURSOR_NAME), path, + Offsets::CURSOR_FILE_SIZE); +} + +} // namespace Cursor::Extended diff --git a/src/cursor/Extended.h b/src/cursor/Extended.h new file mode 100644 index 00000000..01e08ae8 --- /dev/null +++ b/src/cursor/Extended.h @@ -0,0 +1,57 @@ +// This file is part of ClassicAPI. +// +// ClassicAPI is free software: you can redistribute it and/or modify it under the terms +// of the GNU General Public License as published by the Free Software Foundation, either +// version 3 of the License, or (at your option) any later version. +// +// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with +// ClassicAPI. If not, see . + +#pragma once + +#include "Offsets.h" + +#include + +namespace Cursor::Extended { + +// The cursor modes later clients added that 1.12 has no image for, numbered +// on from the engine's last mode (CURSOR_MODE_CUSTOM): 4.3.4's RepairNPC, +// Innkeeper and quest cursors, retail's StableMaster, and the fish hook for +// the fishing bobber. As with the engine's modes, each has an Unable form, +// kUnableOffset further on. +constexpr uint32_t kRepairNpc = Offsets::CURSOR_MODE_CUSTOM + 1; +constexpr uint32_t kInnkeeper = kRepairNpc + 1; +constexpr uint32_t kQuest = kRepairNpc + 2; +constexpr uint32_t kQuestRepeatable = kRepairNpc + 3; +constexpr uint32_t kQuestTurnIn = kRepairNpc + 4; +constexpr uint32_t kStableMaster = kRepairNpc + 5; +constexpr uint32_t kFishing = kRepairNpc + 6; +constexpr uint32_t kUnableOffset = 7; + +// Whether the `expandedCursors` cvar asks for these modes. The pickers that +// give them (cursor/Npc, cursor/Fishing) keep 1.12's cursor while it is 0. +bool Enabled(); + +// Whether `mode` is one of these, in either form. +bool Is(uint32_t mode); + +// Whether `mode` is one of these in its Unable form. +bool IsUnable(uint32_t mode); + +// Whether `mode` is a quest giver's cursor (Quest, QuestRepeatable, +// QuestTurnIn), in either form. +bool IsQuest(uint32_t mode); + +// The image file of one of these modes, `Interface\Cursor\.blp`. +const char *Path(uint32_t mode); + +// Shows one of these modes as the cursor. The engine's SetCursorMode cannot, +// so its hook (cursor/Hover) sends them here. +void Show(uint32_t mode); + +} // namespace Cursor::Extended diff --git a/src/cursor/Fishing.cpp b/src/cursor/Fishing.cpp new file mode 100644 index 00000000..8e137359 --- /dev/null +++ b/src/cursor/Fishing.cpp @@ -0,0 +1,67 @@ +// This file is part of ClassicAPI. +// +// ClassicAPI is free software: you can redistribute it and/or modify it under the terms +// of the GNU General Public License as published by the Free Software Foundation, either +// version 3 of the License, or (at your option) any later version. +// +// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with +// ClassicAPI. If not, see . + +// With `expandedCursors` at 1 (Cursor::Extended::Enabled), the cursor over +// the player's fishing bobber is a fish hook in place of the Interact gear. +// +// A game object's cursor is data. GetCursor (FUN_GAMEOBJECT_GET_CURSOR) gives +// the cursor its record names, else its type handler's: BaseHandlerGetCursor +// for most types, which gives a lock's LockType cursor, else Interact. The +// record's name comes from the server, and tortoise-wow sends it empty; even +// 3.3.5's data names none for the bobber (AzerothCore's template 35591), and +// a bobber has no lock, so every client shows the gear. Later clients ship +// the hook (Interface\Cursor\Fishing.blp) only for data to name. +// +// The fishing node handler (type 17) gets its own cursor slot, the way +// 4.3.4's guild bank type overrides its cursor. The slot still runs +// BaseHandlerGetCursor, which decides everything (0 for a bobber the player +// is not channelling, Unable when not usable), and swaps only the image. The +// classifier passes the mode to SetCursorMode, whose hook (cursor/Hover) +// shows it. + +#include "Common.h" +#include "Game.h" +#include "Offsets.h" +#include "cursor/Extended.h" + +#include + +namespace Cursor::Fishing { + +namespace { + +using HandlerGetCursor_t = uint32_t(__thiscall *)(void *handler); + +uint32_t __fastcall FishingNodeCursor(void *handler, void * /*edx*/) { + const uint32_t mode = + reinterpret_cast(Offsets::FUN_GO_HANDLER_BASE_GET_CURSOR)(handler); + if (!Cursor::Extended::Enabled()) + return mode; + if (mode == Offsets::CURSOR_MODE_INTERACT) + return Cursor::Extended::kFishing; + if (mode == Offsets::CURSOR_MODE_INTERACT + Offsets::CURSOR_MODE_UNABLE_OFFSET) + return Cursor::Extended::kFishing + Cursor::Extended::kUnableOffset; + return mode; +} + +void Patch() { + Common::RedirectSlot(Offsets::SLOT_FISHING_NODE_HANDLER_GET_CURSOR, + Offsets::FUN_GO_HANDLER_BASE_GET_CURSOR, + reinterpret_cast(&FishingNodeCursor)); +} + +const Game::ModuleAutoRegister _autoreg{&Patch}; + +} // namespace + +} // namespace Cursor::Fishing diff --git a/src/cursor/Hover.cpp b/src/cursor/Hover.cpp index 7862b058..af87d348 100644 --- a/src/cursor/Hover.cpp +++ b/src/cursor/Hover.cpp @@ -27,14 +27,21 @@ // The spell-targeting and mouselook legs of 0x4828D0 are left out: they // answer for the mouse, not for the object. // +// The unit classifier and the fishing node's handler can also pick a mode of +// Cursor::Extended (cursor/Npc, cursor/Fishing). The engine's SetCursorMode +// cannot show one, so the same hook hands those to Cursor::Extended::Show, +// and while capturing records them like any other. +// // The images are the engine's own: `Interface\Cursor\.blp` from its // cursor name table, and a game object's named cursor from its record -// (FormatGameObjectCursorFile, `Unable` when not usable). +// (FormatGameObjectCursorFile, `Unable` when not usable). An extended mode's +// image is its embedded file (Cursor::Extended::Path). #include "cursor/Hover.h" #include "Game.h" #include "Offsets.h" +#include "cursor/Extended.h" #include @@ -58,6 +65,10 @@ void __fastcall SetCursorMode_h(uint32_t mode) { g_captured = mode; return; } + if (Cursor::Extended::Is(mode)) { + Cursor::Extended::Show(mode); + return; + } SetCursorMode_o(mode); } @@ -122,6 +133,17 @@ bool ImagePath(void *object, uint32_t mode, bool lowPriority, char *buf, std::si std::snprintf(buf, size, "%s", file); return true; } + if (Cursor::Extended::Is(mode)) { + // Retail shows no icon for a quest giver's cursor, not even the + // low-priority pointer (checked in game, 2026-11-03, with the soft + // interact icon and SoftTargetLowPriorityIcons 1). Blizzard_NamePlates + // shows that icon exactly when SetUnitCursorTexture returns true, so + // the refusal is the function's own. + if (Cursor::Extended::IsQuest(mode)) + return false; + std::snprintf(buf, size, "%s", Cursor::Extended::Path(mode)); + return true; + } if (mode >= Offsets::CURSOR_MODE_CUSTOM) return false; const char *name = Game::Read(Offsets::VAR_CURSOR_NAMES + 4 * mode); diff --git a/src/cursor/Hover.h b/src/cursor/Hover.h index dbbbd5fe..01d8a233 100644 --- a/src/cursor/Hover.h +++ b/src/cursor/Hover.h @@ -18,9 +18,9 @@ namespace Cursor::Hover { -// The cursor mode (Offsets::CURSOR_MODE_*) the world cursor would show with -// the mouse over `object` — a unit, player, game object or corpse — or 0 -// when it shows none. Computes it without showing it. +// The cursor mode (Offsets::CURSOR_MODE_* or Cursor::Extended) the world +// cursor would show with the mouse over `object` — a unit, player, game +// object or corpse — or 0 when it shows none. Computes it without showing it. uint32_t Mode(void *object); // For a game object's named cursor (CURSOR_MODE_CUSTOM): whether the player @@ -28,9 +28,10 @@ uint32_t Mode(void *object); bool IsUsable(void *object); // Writes the image file of `mode` over `object` into `buf`. False, with -// `buf` untouched, for no mode, and for the base (resting) mode unless -// `lowPriority` — retail's low-priority image, the plain pointer over a unit -// with nothing to do (SetUnitCursorTexture's includeLowPriority). +// `buf` untouched, for no mode, for a quest giver's cursor (retail shows no +// icon for one), and for the base (resting) mode unless `lowPriority` — +// retail's low-priority image, the plain pointer over a unit with nothing to +// do (SetUnitCursorTexture's includeLowPriority). bool ImagePath(void *object, uint32_t mode, bool lowPriority, char *buf, std::size_t size); } // namespace Cursor::Hover diff --git a/src/cursor/Npc.cpp b/src/cursor/Npc.cpp new file mode 100644 index 00000000..7940373f --- /dev/null +++ b/src/cursor/Npc.cpp @@ -0,0 +1,187 @@ +// This file is part of ClassicAPI. +// +// ClassicAPI is free software: you can redistribute it and/or modify it under the terms +// of the GNU General Public License as published by the Free Software Foundation, either +// version 3 of the License, or (at your option) any later version. +// +// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with +// ClassicAPI. If not, see . + +// With `expandedCursors` at 1 (Cursor::Extended::Enabled), the cursor over an +// NPC shows what it offers, as in later clients: an anvil for a repairer, a +// hearthstone for an innkeeper, a horseshoe for a stable master, and a quest +// giver's ! or ? (Cursor::Extended). At 0, the default, it stays 1.12's. +// +// 1.12 picks the cursor for a hovered unit in ClassifyHoveredUnit +// (FUN_WORLD_FRAME_CLASSIFY_UNIT). For a unit the player can interact with, +// its NPC flag chain takes the lowest flag bit first: gossip, quest giver, +// vendor, flight master and so on. It never tests the repair flag, and shows +// Interact for an innkeeper. 4.3.4's CursorTrackUnit first asks the quest +// giver's status (GetQuestGiverCursor), then tests the services from repair +// down, with vendor and gossip near the end. +// +// The thunk stands in for the head of 1.12's chain +// (SITE_CLASSIFY_UNIT_NPC_FLAGS). Everything the engine decides before it +// stays the engine's: mouselook, spell targeting, charm, whether the unit can +// be interacted with now, and whether it is beyond the interact reach (EBX, +// the Unable form). With the cvar at 0 the thunk does the instruction it +// replaced and the stock chain runs. The pick goes to the engine's +// SetCursorMode, whose hook (cursor/Hover) shows an extended mode and records +// every mode while the soft-interact code asks for the hover verdict. + +#include "Common.h" +#include "Game.h" +#include "Offsets.h" +#include "cursor/Extended.h" + +#include + +namespace Cursor::Npc { + +namespace { + +// UNIT_NPC_FLAGS bits, as 1.12's chain tests them (tortoise-wow +// UnitDefines.h names them). +constexpr uint32_t kGossip = 0x1; +constexpr uint32_t kVendor = 0x4; +constexpr uint32_t kFlightMaster = 0x8; +constexpr uint32_t kTrainer = 0x10; +constexpr uint32_t kSpiritHealer = 0x20; +constexpr uint32_t kSpiritGuide = 0x40; +constexpr uint32_t kInnkeeper = 0x80; +constexpr uint32_t kBanker = 0x100; +constexpr uint32_t kPetitioner = 0x200; +constexpr uint32_t kTabardDesigner = 0x400; +constexpr uint32_t kBattleMaster = 0x800; +constexpr uint32_t kAuctioneer = 0x1000; +constexpr uint32_t kStableMaster = 0x2000; +constexpr uint32_t kRepair = 0x4000; + +// Quest giver statuses (OFF_CGUNIT_QUEST_STATUS). 1.12 numbers them its own +// way (tortoise-wow QuestDef.h); its status-to-marker table (0x80C454) shows +// each one's meaning: 3 a grey ?, 4 a blue ?, 5 a yellow !, 6 and 7 a yellow +// ?. 0 none, 1 unavailable (grey !) and 2 chat (no marker) have no cursor, +// as 4.3.4's none and unavailable have none. +constexpr uint32_t kStatusIncomplete = 3; +constexpr uint32_t kStatusRewardRepeatable = 4; +constexpr uint32_t kStatusAvailable = 5; +constexpr uint32_t kStatusRewardOld = 6; +constexpr uint32_t kStatusReward = 7; + +using IsQuestGiverToTalk_t = uint32_t(__stdcall *)(void *unit); +using SetCursorMode_t = void(__fastcall *)(uint32_t mode); + +uintptr_t g_classifyReturn = Offsets::CLASSIFY_UNIT_RETURN; + +// 4.3.4's quest cursor for a status (GetQuestGiverCursor), matched by the +// status's meaning: 4.3.4 gives Quest to an available and to an incomplete +// quest, QuestRepeatable to a repeatable reward (its blue ?), and +// QuestTurnIn to a reward (its yellow ?). +uint32_t QuestMode(uint32_t status) { + switch (status) { + case kStatusIncomplete: + case kStatusAvailable: + return Cursor::Extended::kQuest; + case kStatusRewardRepeatable: + return Cursor::Extended::kQuestRepeatable; + case kStatusRewardOld: + case kStatusReward: + return Cursor::Extended::kQuestTurnIn; + default: + return 0; + } +} + +// 4.3.4's order (CursorTrackUnit) in 1.12's flag bits, or 0 for none. +// 4.3.4's guild master, guild banker and spell click tests have no 1.12 +// flag, and its flight master test also reads an icon type; for that flag +// 1.12 shows Taxi, as here. 4.3.4 also takes a cursor that the creature's +// cache record names (IconName), which 1.12's creature query does not carry. +// A stable master gets retail's StableMaster in 4.3.4's place for it, where +// 4.3.4 shows Speak. Where 4.3.4 tests gossip, this also takes the quest +// giver 1.12 would speak to (IsQuestGiverToTalk), so a status without a +// quest cursor keeps 1.12's Speak. +uint32_t ServiceMode(void *unit) { + if (const uint32_t quest = QuestMode(Game::Read(unit, Offsets::OFF_CGUNIT_QUEST_STATUS))) + return quest; + const uint32_t flags = + Game::Read(Game::Read(unit, Offsets::OFF_UNIT_DESCRIPTOR), + Offsets::OFF_UNIT_DESCRIPTOR_NPC_FLAGS); + if (flags & kRepair) + return Cursor::Extended::kRepairNpc; + if (flags & kInnkeeper) + return Cursor::Extended::kInnkeeper; + if (flags & kFlightMaster) + return Offsets::CURSOR_MODE_TAXI; + if (flags & kTrainer) + return Offsets::CURSOR_MODE_TRAINER; + if (flags & (kSpiritHealer | kSpiritGuide)) + return Offsets::CURSOR_MODE_SPEAK; + if (flags & kBanker) + return Offsets::CURSOR_MODE_BUY; + if (flags & (kPetitioner | kTabardDesigner | kBattleMaster)) + return Offsets::CURSOR_MODE_SPEAK; + if (flags & kAuctioneer) + return Offsets::CURSOR_MODE_BUY; + if (flags & kStableMaster) + return Cursor::Extended::kStableMaster; + if (flags & kVendor) + return Offsets::CURSOR_MODE_PICKUP; + if ((flags & kGossip) || + reinterpret_cast(Offsets::FUN_IS_QUEST_GIVER_TO_TALK)(unit) != 0) + return Offsets::CURSOR_MODE_SPEAK; + return 0; +} + +// True when it set the mode. `unable` is the engine's EBX: 1 beyond the +// interact reach, else 0. A unit with no service gets the base mode, where +// the stock chain ends too (ShowBaseCursor). +bool __stdcall Pick(void *unit, uint32_t unable) { + if (!Cursor::Extended::Enabled()) + return false; + uint32_t mode = ServiceMode(unit); + if (mode == 0) + mode = Game::Read(Offsets::VAR_CURSOR_BASE_MODE); + else if (unable != 0) + mode += Cursor::Extended::Is(mode) ? Cursor::Extended::kUnableOffset + : Offsets::CURSOR_MODE_UNABLE_OFFSET; + reinterpret_cast(Offsets::FUN_SET_CURSOR_MODE)(mode); + return true; +} + +static_assert(Offsets::OFF_UNIT_DESCRIPTOR == 0x110, "NpcFlagsThunk replays [esi + 0x110]"); + +// Stands in for `mov ecx, [esi + 0x110]`, the head of the chain: the unit in +// ESI, the Unable flag in EBX. When Pick set the mode, it leaves the +// classifier through the chain's epilogue; otherwise it does the instruction +// it replaced and the chain runs. +__declspec(naked) void NpcFlagsThunk() { + __asm { + push ebx + push esi + call Pick + test al, al + jnz picked + mov ecx, dword ptr [esi + 110h] + ret + picked: + add esp, 4 + jmp dword ptr [g_classifyReturn] + } +} + +void Patch() { + static const uint8_t kFlagsRead[6] = {0x8B, 0x8E, 0x10, 0x01, 0x00, 0x00}; + Common::ReplaceWithCall(Offsets::SITE_CLASSIFY_UNIT_NPC_FLAGS, kFlagsRead, sizeof kFlagsRead, + reinterpret_cast(&NpcFlagsThunk)); +} + +const Game::ModuleAutoRegister _autoreg{&Patch}; + +} // namespace + +} // namespace Cursor::Npc diff --git a/src/embed/AddOns/!!!ClassicAPI/Options/Panel.lua b/src/embed/AddOns/!!!ClassicAPI/Options/Panel.lua index 903e0265..83dd40e1 100644 --- a/src/embed/AddOns/!!!ClassicAPI/Options/Panel.lua +++ b/src/embed/AddOns/!!!ClassicAPI/Options/Panel.lua @@ -607,6 +607,17 @@ AddKeybinding(bags, "AOELOOT") local interface = NewPage(CLASSICAPI_INTERFACE, main.name) +-- The label ends with some of the cursors the setting shows. The DLL serves +-- each image at its Interface\Cursor path, and the markup stays out of the +-- translated string. +local expandedCursorIcons = "" +for _, name in ipairs({ "RepairNPC", "Innkeeper", "StableMaster", "Quest", "QuestTurnIn", "Fishing" }) do + expandedCursorIcons = expandedCursorIcons .. " |TInterface\\Cursor\\" .. name .. ":16|t" +end + +AddHeader(interface, CLASSICAPI_CURSOR) +AddCheckbox(interface, CLASSICAPI_EXPANDED_CURSORS .. expandedCursorIcons, CLASSICAPI_EXPANDED_CURSORS_TOOLTIP, CVarSetting("expandedCursors")) + AddHeader(interface, CLASSICAPI_NAMEPLATES) AddSlider(interface, CLASSICAPI_TARGET_NAMEPLATE_ALPHA, CLASSICAPI_TARGET_NAMEPLATE_ALPHA_TOOLTIP, NumberCVarSetting("nameplateSelectedAlpha"), 0, 1, 0.05) AddSlider(interface, CLASSICAPI_OTHER_NAMEPLATE_ALPHA, CLASSICAPI_OTHER_NAMEPLATE_ALPHA_TOOLTIP, NumberCVarSetting("nameplateNotSelectedAlpha"), 0, 1, 0.05) diff --git a/src/embed/AddOns/!!!ClassicAPI/locales/deDE.lua b/src/embed/AddOns/!!!ClassicAPI/locales/deDE.lua index 80dac085..e821932a 100644 --- a/src/embed/AddOns/!!!ClassicAPI/locales/deDE.lua +++ b/src/embed/AddOns/!!!ClassicAPI/locales/deDE.lua @@ -8,6 +8,9 @@ BINDING_NAME_FOCUSTARGET = "Fokus setzen"; BINDING_NAME_INTERACTMOUSEOVER = "Mit Mouseover interagieren"; BINDING_NAME_INTERACTTARGET = "Mit Ziel interagieren"; BINDING_NAME_TARGETFOCUS = "Fokus anvisieren"; +CLASSICAPI_CURSOR = "Mauszeiger"; +CLASSICAPI_EXPANDED_CURSORS = "Erweiterte Mauszeiger anzeigen"; +CLASSICAPI_EXPANDED_CURSORS_TOOLTIP = "Wenn Ihr mit der Maus auf einen NSC oder ein Objekt zeigt, stellt der Mauszeiger dar, was Ihr damit tun könnt. Zum Beispiel einen Amboss für Reparaturen, ! oder ? für Quests und einen Haken für Euren Angelschwimmer."; COMBAT_LABEL = "Kampf"; COUNTDOWN_FOR_COOLDOWNS_TEXT = "Zahlen für Abklingzeiten anzeigen"; CONFIRM_DELETE_EQUIPMENT_SET = "Seid Ihr sicher, dass Ihr das Ausrüstungsset \"%s\" löschen wollt?"; diff --git a/src/embed/AddOns/!!!ClassicAPI/locales/enUS.lua b/src/embed/AddOns/!!!ClassicAPI/locales/enUS.lua index ec035709..6f2c34b6 100644 --- a/src/embed/AddOns/!!!ClassicAPI/locales/enUS.lua +++ b/src/embed/AddOns/!!!ClassicAPI/locales/enUS.lua @@ -22,6 +22,7 @@ CLASSICAPI_BAGS_AND_ITEMS = "Bags and Items"; CLASSICAPI_CASTING_AND_ATTACKING = "Casting and Attacking"; CLASSICAPI_COMBAT_LOG_RETENTION = "Combat Log Retention"; CLASSICAPI_COMBAT_LOG_RETENTION_TOOLTIP = "How many seconds the combat log keeps entries for addons."; +CLASSICAPI_CURSOR = "Cursor"; CLASSICAPI_DIRECTION_ANY = "Any direction"; CLASSICAPI_DIRECTION_CONE = "Within 30 degrees"; CLASSICAPI_DIRECTION_FRONT = "Directly in front"; @@ -30,6 +31,8 @@ CLASSICAPI_ENEMY_DIRECTION = "Enemy Direction"; CLASSICAPI_ENEMY_ICON = "Show Enemy Icon"; CLASSICAPI_ENEMY_ICON_TOOLTIP = "Shows an icon over the enemy that action targeting picks."; CLASSICAPI_ENEMY_RANGE = "Enemy Range"; +CLASSICAPI_EXPANDED_CURSORS = "Show Expanded Cursors"; +CLASSICAPI_EXPANDED_CURSORS_TOOLTIP = "When you point at an NPC or an object, the cursor shows what you can do with it. For example, an anvil for repairs, ! or ? for quests, and a hook for your fishing bobber."; CLASSICAPI_FRIEND_DIRECTION = "Friend Direction"; CLASSICAPI_FRIEND_ICON = "Show Friend Icon"; CLASSICAPI_FRIEND_ICON_TOOLTIP = "Shows an icon over the friendly unit that friendly action targeting picks."; diff --git a/src/embed/AddOns/!!!ClassicAPI/locales/esES.lua b/src/embed/AddOns/!!!ClassicAPI/locales/esES.lua index 2fc7503c..b247d7fd 100644 --- a/src/embed/AddOns/!!!ClassicAPI/locales/esES.lua +++ b/src/embed/AddOns/!!!ClassicAPI/locales/esES.lua @@ -8,6 +8,9 @@ BINDING_NAME_FOCUSTARGET = "Establecer objetivo"; BINDING_NAME_INTERACTMOUSEOVER = "Interactuar al pasar el ratón"; BINDING_NAME_INTERACTTARGET = "Interactuar con objetivo"; BINDING_NAME_TARGETFOCUS = "Seleccionar enfocado"; +CLASSICAPI_CURSOR = "Cursor"; +CLASSICAPI_EXPANDED_CURSORS = "Mostrar cursores ampliados"; +CLASSICAPI_EXPANDED_CURSORS_TOOLTIP = "Al pasar el ratón sobre un PNJ o un objeto, el cursor muestra lo que puedes hacer con él. Por ejemplo, un yunque para las reparaciones, ! o ? para las misiones y un anzuelo para tu corcho de pesca."; COMBAT_LABEL = "Combate"; COUNTDOWN_FOR_COOLDOWNS_TEXT = "Mostrar tiempos de reutilización en cifras"; CONFIRM_DELETE_EQUIPMENT_SET = "¿Seguro que quieres eliminar el equipamiento %s?"; diff --git a/src/embed/AddOns/!!!ClassicAPI/locales/frFR.lua b/src/embed/AddOns/!!!ClassicAPI/locales/frFR.lua index 4dc10274..5cffee1d 100644 --- a/src/embed/AddOns/!!!ClassicAPI/locales/frFR.lua +++ b/src/embed/AddOns/!!!ClassicAPI/locales/frFR.lua @@ -8,6 +8,9 @@ BINDING_NAME_FOCUSTARGET = "Définir cible"; BINDING_NAME_INTERACTMOUSEOVER = "Interagir par survol de la souris"; BINDING_NAME_INTERACTTARGET = "Interagir avec la cible"; BINDING_NAME_TARGETFOCUS = "Reprendre cible"; +CLASSICAPI_CURSOR = "Curseur"; +CLASSICAPI_EXPANDED_CURSORS = "Afficher les curseurs étendus"; +CLASSICAPI_EXPANDED_CURSORS_TOOLTIP = "Quand vous survolez un PNJ ou un objet, le curseur indique ce que vous pouvez en faire. Par exemple, une enclume pour les réparations, ! ou ? pour les quêtes et un hameçon pour votre flotteur de pêche."; COMBAT_LABEL = "Combat"; COUNTDOWN_FOR_COOLDOWNS_TEXT = "Temps de recharge en chiffres"; CONFIRM_DELETE_EQUIPMENT_SET = "Voulez-vous vraiment supprimer l'ensemble d'équipement %s ?"; diff --git a/src/embed/AddOns/!!!ClassicAPI/locales/koKR.lua b/src/embed/AddOns/!!!ClassicAPI/locales/koKR.lua index 47381ee6..cdc66e1d 100644 --- a/src/embed/AddOns/!!!ClassicAPI/locales/koKR.lua +++ b/src/embed/AddOns/!!!ClassicAPI/locales/koKR.lua @@ -13,3 +13,6 @@ FOURTH_NUMBER = "조"; KEY_BINDING_NAME_AND_KEY = "%s (%s)"; KEY_BINDING_TOOLTIP = "<지정을 해제하려면 오른쪽 클릭>"; SETTINGS_BIND_KEY_TO_COMMAND_OR_CANCEL = "\"%s\" 단축키 지정 또는 %s 눌러 취소"; +CLASSICAPI_CURSOR = "커서"; +CLASSICAPI_EXPANDED_CURSORS = "확장 커서 표시"; +CLASSICAPI_EXPANDED_CURSORS_TOOLTIP = "NPC나 물체를 가리키면 커서가 할 수 있는 행동을 보여줍니다. 예를 들어 수리는 모루, 퀘스트는 ! 또는 ?, 낚시찌는 낚싯바늘로 표시됩니다."; diff --git a/src/embed/AddOns/!!!ClassicAPI/locales/ruRU.lua b/src/embed/AddOns/!!!ClassicAPI/locales/ruRU.lua index f0822472..7ebe55d4 100644 --- a/src/embed/AddOns/!!!ClassicAPI/locales/ruRU.lua +++ b/src/embed/AddOns/!!!ClassicAPI/locales/ruRU.lua @@ -8,6 +8,9 @@ BINDING_NAME_FOCUSTARGET = "Запомненная цель"; BINDING_NAME_INTERACTMOUSEOVER = "Взаимодействовать указателем мыши"; BINDING_NAME_INTERACTTARGET = "Взаимодействовать с целью"; BINDING_NAME_TARGETFOCUS = "Восстановить цель"; +CLASSICAPI_CURSOR = "Курсор"; +CLASSICAPI_EXPANDED_CURSORS = "Показывать расширенные курсоры"; +CLASSICAPI_EXPANDED_CURSORS_TOOLTIP = "При наведении на NPC или объект курсор показывает, что с ним можно сделать. Например, наковальню для ремонта, ! или ? для заданий и крючок для вашего поплавка."; COMBAT_LABEL = "Бой"; COUNTDOWN_FOR_COOLDOWNS_TEXT = "Отсчет времени восстановления"; CONFIRM_DELETE_EQUIPMENT_SET = "Вы действительно хотите удалить комплект экипировки \"%s\"?"; diff --git a/src/embed/AddOns/!!!ClassicAPI/locales/zhCN.lua b/src/embed/AddOns/!!!ClassicAPI/locales/zhCN.lua index 2c18ef4f..8c7d5f2a 100644 --- a/src/embed/AddOns/!!!ClassicAPI/locales/zhCN.lua +++ b/src/embed/AddOns/!!!ClassicAPI/locales/zhCN.lua @@ -24,6 +24,7 @@ CLASSICAPI_BAGS_AND_ITEMS = "背包与物品"; CLASSICAPI_CASTING_AND_ATTACKING = "施法与攻击"; CLASSICAPI_COMBAT_LOG_RETENTION = "战斗记录保留时间"; CLASSICAPI_COMBAT_LOG_RETENTION_TOOLTIP = "战斗记录为插件保留条目的秒数。"; +CLASSICAPI_CURSOR = "鼠标指针"; CLASSICAPI_DIRECTION_ANY = "任意方向"; CLASSICAPI_DIRECTION_CONE = "30度范围内"; CLASSICAPI_DIRECTION_FRONT = "正前方"; @@ -32,6 +33,8 @@ CLASSICAPI_ENEMY_DIRECTION = "敌人方向"; CLASSICAPI_ENEMY_ICON = "显示敌人图标"; CLASSICAPI_ENEMY_ICON_TOOLTIP = "在动作瞄准选中的敌人上方显示图标。"; CLASSICAPI_ENEMY_RANGE = "敌人范围"; +CLASSICAPI_EXPANDED_CURSORS = "显示扩展鼠标指针"; +CLASSICAPI_EXPANDED_CURSORS_TOOLTIP = "指向NPC或物体时,鼠标指针会显示你可以对其进行的操作。例如,铁砧表示修理,! 或 ? 表示任务,鱼钩表示你的鱼漂。"; CLASSICAPI_FRIEND_DIRECTION = "友方方向"; CLASSICAPI_FRIEND_ICON = "显示友方图标"; CLASSICAPI_FRIEND_ICON_TOOLTIP = "在友方动作瞄准选中的友方单位上方显示图标。"; diff --git a/src/embed/AddOns/!!!ClassicAPI/locales/zhTW.lua b/src/embed/AddOns/!!!ClassicAPI/locales/zhTW.lua index 3f791128..e84baff8 100644 --- a/src/embed/AddOns/!!!ClassicAPI/locales/zhTW.lua +++ b/src/embed/AddOns/!!!ClassicAPI/locales/zhTW.lua @@ -8,6 +8,9 @@ BINDING_NAME_FOCUSTARGET = "鎖定目標"; BINDING_NAME_INTERACTMOUSEOVER = "與滑鼠指定的目標互動"; BINDING_NAME_INTERACTTARGET = "與目標互動"; BINDING_NAME_TARGETFOCUS = "選定鎖定目標"; +CLASSICAPI_CURSOR = "游標"; +CLASSICAPI_EXPANDED_CURSORS = "顯示擴充游標"; +CLASSICAPI_EXPANDED_CURSORS_TOOLTIP = "指向NPC或物件時,游標會顯示你可以對其進行的操作。例如,鐵砧表示修理,! 或 ? 表示任務,魚鉤表示你的魚漂。"; COMBAT_LABEL = "戰鬥"; COUNTDOWN_FOR_COOLDOWNS_TEXT = "顯示冷卻時間"; CONFIRM_DELETE_EQUIPMENT_SET = "你確定要刪除裝備設定:%s嗎?"; diff --git a/src/embed/Resource.h b/src/embed/Resource.h index 74e1df5d..3b15cd90 100644 --- a/src/embed/Resource.h +++ b/src/embed/Resource.h @@ -13,6 +13,8 @@ #pragma once +#include "Common.h" + #include namespace Embed { @@ -30,4 +32,15 @@ struct Bytes { // resource is missing, does not inflate or split, or has no file `index`. Bytes File(int id, size_t index); +// The entry of `files`, a manifest in the generated `embedded_resources.h`, +// whose path is the game path `path` (Common::SamePath); null when none is. +template +const Entry *FindFile(const Entry (&files)[N], const char *path) { + for (const Entry &file : files) { + if (Common::SamePath(path, file.path)) + return &file; + } + return nullptr; +} + } // namespace Embed diff --git a/src/embed/Textures/Interface/Cursor/Fishing.blp b/src/embed/Textures/Interface/Cursor/Fishing.blp new file mode 100644 index 00000000..09aaa769 Binary files /dev/null and b/src/embed/Textures/Interface/Cursor/Fishing.blp differ diff --git a/src/embed/Textures/Interface/Cursor/Innkeeper.blp b/src/embed/Textures/Interface/Cursor/Innkeeper.blp new file mode 100644 index 00000000..a4149412 Binary files /dev/null and b/src/embed/Textures/Interface/Cursor/Innkeeper.blp differ diff --git a/src/embed/Textures/Interface/Cursor/Quest.blp b/src/embed/Textures/Interface/Cursor/Quest.blp new file mode 100644 index 00000000..1fd1f8d1 Binary files /dev/null and b/src/embed/Textures/Interface/Cursor/Quest.blp differ diff --git a/src/embed/Textures/Interface/Cursor/QuestRepeatable.blp b/src/embed/Textures/Interface/Cursor/QuestRepeatable.blp new file mode 100644 index 00000000..40b52a13 Binary files /dev/null and b/src/embed/Textures/Interface/Cursor/QuestRepeatable.blp differ diff --git a/src/embed/Textures/Interface/Cursor/QuestTurnIn.blp b/src/embed/Textures/Interface/Cursor/QuestTurnIn.blp new file mode 100644 index 00000000..a6af5448 Binary files /dev/null and b/src/embed/Textures/Interface/Cursor/QuestTurnIn.blp differ diff --git a/src/embed/Textures/Interface/Cursor/RepairNPC.blp b/src/embed/Textures/Interface/Cursor/RepairNPC.blp new file mode 100644 index 00000000..01780fcf Binary files /dev/null and b/src/embed/Textures/Interface/Cursor/RepairNPC.blp differ diff --git a/src/embed/Textures/Interface/Cursor/StableMaster.blp b/src/embed/Textures/Interface/Cursor/StableMaster.blp new file mode 100644 index 00000000..bf1d0f9f Binary files /dev/null and b/src/embed/Textures/Interface/Cursor/StableMaster.blp differ diff --git a/src/embed/Textures/Interface/Cursor/UnableFishing.blp b/src/embed/Textures/Interface/Cursor/UnableFishing.blp new file mode 100644 index 00000000..41891f7f Binary files /dev/null and b/src/embed/Textures/Interface/Cursor/UnableFishing.blp differ diff --git a/src/embed/Textures/Interface/Cursor/UnableInnkeeper.blp b/src/embed/Textures/Interface/Cursor/UnableInnkeeper.blp new file mode 100644 index 00000000..5a830087 Binary files /dev/null and b/src/embed/Textures/Interface/Cursor/UnableInnkeeper.blp differ diff --git a/src/embed/Textures/Interface/Cursor/UnableQuest.blp b/src/embed/Textures/Interface/Cursor/UnableQuest.blp new file mode 100644 index 00000000..0ce7ecd3 Binary files /dev/null and b/src/embed/Textures/Interface/Cursor/UnableQuest.blp differ diff --git a/src/embed/Textures/Interface/Cursor/UnableQuestRepeatable.blp b/src/embed/Textures/Interface/Cursor/UnableQuestRepeatable.blp new file mode 100644 index 00000000..0999a22b Binary files /dev/null and b/src/embed/Textures/Interface/Cursor/UnableQuestRepeatable.blp differ diff --git a/src/embed/Textures/Interface/Cursor/UnableQuestTurnIn.blp b/src/embed/Textures/Interface/Cursor/UnableQuestTurnIn.blp new file mode 100644 index 00000000..5104cdd9 Binary files /dev/null and b/src/embed/Textures/Interface/Cursor/UnableQuestTurnIn.blp differ diff --git a/src/embed/Textures/Interface/Cursor/UnableRepairNPC.blp b/src/embed/Textures/Interface/Cursor/UnableRepairNPC.blp new file mode 100644 index 00000000..9cb1183b Binary files /dev/null and b/src/embed/Textures/Interface/Cursor/UnableRepairNPC.blp differ diff --git a/src/embed/Textures/Interface/Cursor/UnableStableMaster.blp b/src/embed/Textures/Interface/Cursor/UnableStableMaster.blp new file mode 100644 index 00000000..26c76fc3 Binary files /dev/null and b/src/embed/Textures/Interface/Cursor/UnableStableMaster.blp differ diff --git a/src/map/Overlays.cpp b/src/map/Overlays.cpp index 1e5b5d78..0eed8334 100644 --- a/src/map/Overlays.cpp +++ b/src/map/Overlays.cpp @@ -111,6 +111,7 @@ #include "dbc/Lookup.h" #include "map/Area.h" #include "map/Overlays.h" +#include "texture/BlpReader.h" #include "unit/Identity.h" #include @@ -136,59 +137,7 @@ int NextPot(int v) { return p; } -// The engine's BLP reader, driven exactly as GetPortraitAlphaMask drives it -// (see the FUN_BLP_OPEN block in Offsets.h). Open in the constructor, decode -// on demand, free and reset in the destructor. Only one reader may be live -// at a time: the engine keeps the file in one module-static buffer. -class BlpReader { -public: - explicit BlpReader(const char *path) { - std::memset(rec_, 0, sizeof(rec_)); - Put(Offsets::OFF_BLP_MAGIC, 0x32504C42); // 'BLP2' - Put(Offsets::OFF_BLP_VERSION, 1); - rec_[Offsets::OFF_BLP_ALPHA_TYPE] = 2; - Put(Offsets::OFF_BLP_INIT_4A4, 100); - open_ = reinterpret_cast(Offsets::FUN_BLP_OPEN)(rec_, path, 1) != 0; - } - ~BlpReader() { - if (decoded_) - reinterpret_cast(Offsets::FUN_BLP_FREE_SCRATCH)(rec_, 0); - reinterpret_cast(Offsets::FUN_BLP_RESET)(rec_); - } - BlpReader(const BlpReader &) = delete; - BlpReader &operator=(const BlpReader &) = delete; - - bool IsOpen() const { return open_; } - uint32_t Width() const { return Game::Read(rec_, Offsets::OFF_BLP_WIDTH); } - uint32_t Height() const { return Game::Read(rec_, Offsets::OFF_BLP_HEIGHT); } - - // Level 0 as BGRA8888 (alpha in byte 3), valid until destruction; null - // on failure. - const uint8_t *DecodeBgra() { - if (!open_) - return nullptr; - void *out = nullptr; - uint32_t size = 0; - decoded_ = true; - if (reinterpret_cast(Offsets::FUN_BLP_DECODE_LEVEL)( - rec_, Offsets::GX_TEX_FORMAT_BGRA8888, 0, &out, &size) == 0) - return nullptr; - return static_cast(out); - } - -private: - using Open_t = int(__thiscall *)(void *rec, const char *path, int flag); - using DecodeLevel_t = int(__thiscall *)(void *rec, uint32_t fmt, uint32_t level, - void **out, uint32_t *size); - using FreeScratch_t = void(__thiscall *)(void *rec, uint32_t level); - using Reset_t = void(__thiscall *)(void *rec); - - void Put(uintptr_t off, uint32_t v) { std::memcpy(rec_ + off, &v, sizeof(v)); } - - uint8_t rec_[Offsets::BLP_RECORD_SIZE]; - bool open_ = false; - bool decoded_ = false; -}; +using Texture::BlpReader; // Where one overlay's art lives and where it sits on the zone canvas. struct OverlaySource { diff --git a/src/sound/Mute.cpp b/src/sound/Mute.cpp index 70509f0f..9466a6b3 100644 --- a/src/sound/Mute.cpp +++ b/src/sound/Mute.cpp @@ -44,6 +44,7 @@ // Muted entries stay in the list, flagged. Dropping them would make a sound // vanish from the one view you would use to un-mute it. +#include "Common.h" #include "Game.h" #include "Offsets.h" #include "time/Clock.h" @@ -80,18 +81,13 @@ uint32_t g_seq = 0; // is one `empty()` test on the hook path. std::unordered_set g_muted; -// Lowercase, and fold '/' to '\', so a caller may pass either separator and -// any casing — the engine's own path compares are case-insensitive. +// Fold each character as Common::SamePath compares it, so a caller may pass +// either separator and any casing — the engine's own path compares are +// case-insensitive. void Normalize(const char *path, std::string &out) { out.clear(); - for (const char *p = path; *p != '\0'; ++p) { - char c = *p; - if (c == '/') - c = '\\'; - else if (c >= 'A' && c <= 'Z') - c = static_cast(c + ('a' - 'A')); - out.push_back(c); - } + for (const char *p = path; *p != '\0'; ++p) + out.push_back(Common::FoldPathChar(*p)); } bool IsMuted(const char *path) { @@ -102,22 +98,6 @@ bool IsMuted(const char *path) { return g_muted.find(key) != g_muted.end(); } -// Case-insensitive equality, tolerant of separator style — the same rule -// Normalize applies, without building a string. -bool SamePath(const char *a, const char *b) { - for (;; ++a, ++b) { - char ca = *a, cb = *b; - if (ca == '/') ca = '\\'; - if (cb == '/') cb = '\\'; - if (ca >= 'A' && ca <= 'Z') ca = static_cast(ca + ('a' - 'A')); - if (cb >= 'A' && cb <= 'Z') cb = static_cast(cb + ('a' - 'A')); - if (ca != cb) - return false; - if (ca == '\0') - return true; - } -} - // Note a path as played. Re-playing a path refreshes its existing slot // rather than taking a new one, so a looping footstep cannot push the rest // of the window out — the list holds the last N DISTINCT sounds, which is @@ -127,7 +107,7 @@ void Record(const char *path, bool muted) { int oldest = 0; for (int i = 0; i < kRecentSlots; ++i) { Recent &r = g_recent[i]; - if (r.seq != 0 && SamePath(r.path, path)) { + if (r.seq != 0 && Common::SamePath(r.path, path)) { r.seq = ++g_seq; r.timeMs = now; r.muted = muted; diff --git a/src/target/SoftInteract.cpp b/src/target/SoftInteract.cpp index f3cb2fc4..5a17cd8d 100644 --- a/src/target/SoftInteract.cpp +++ b/src/target/SoftInteract.cpp @@ -42,6 +42,7 @@ #include "Game.h" #include "Offsets.h" +#include "cursor/Extended.h" #include "cursor/Hover.h" #include "cvar/Factory.h" #include "event/Custom.h" @@ -88,6 +89,8 @@ Reach ReachOf(void *object) { return Reach::None; if (mode == Offsets::CURSOR_MODE_CUSTOM) return Cursor::Hover::IsUsable(object) ? Reach::Now : Reach::Out; + if (Cursor::Extended::Is(mode)) + return Cursor::Extended::IsUnable(mode) ? Reach::Out : Reach::Now; const bool unable = mode > Offsets::CURSOR_MODE_LAST_NAMED; const uint32_t kind = unable ? mode - Offsets::CURSOR_MODE_UNABLE_OFFSET : mode; if (kind == 0 || kind > Offsets::CURSOR_MODE_LAST_NAMED || kind == Offsets::CURSOR_MODE_POINT || diff --git a/src/texture/BlpReader.cpp b/src/texture/BlpReader.cpp new file mode 100644 index 00000000..1830d3f3 --- /dev/null +++ b/src/texture/BlpReader.cpp @@ -0,0 +1,82 @@ +// This file is part of ClassicAPI. +// +// ClassicAPI is free software: you can redistribute it and/or modify it under the terms +// of the GNU General Public License as published by the Free Software Foundation, either +// version 3 of the License, or (at your option) any later version. +// +// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with +// ClassicAPI. If not, see . + +#include "texture/BlpReader.h" + +#include "Game.h" + +#include + +namespace Texture { + +namespace { + +using Open_t = int(__thiscall *)(void *rec, const char *path, int flag); +using Parse_t = int(__thiscall *)(void *rec, const void *file); +using DecodeLevel_t = int(__thiscall *)(void *rec, uint32_t fmt, uint32_t level, void **out, + uint32_t *size); +using FreeScratch_t = void(__thiscall *)(void *rec, uint32_t level); +using Reset_t = void(__thiscall *)(void *rec); + +} // namespace + +BlpReader::BlpReader(const char *path) { + Init(); + open_ = reinterpret_cast(Offsets::FUN_BLP_OPEN)(rec_, path, 1) != 0; +} + +BlpReader::BlpReader(const void *file, std::size_t size) { + Init(); + // The parse copies a whole header out of the file. + if (file != nullptr && size >= Offsets::BLP_HEADER_SIZE) + open_ = reinterpret_cast(Offsets::FUN_BLP_PARSE)(rec_, file) != 0; +} + +BlpReader::~BlpReader() { + if (decoded_) + reinterpret_cast(Offsets::FUN_BLP_FREE_SCRATCH)(rec_, 0); + reinterpret_cast(Offsets::FUN_BLP_RESET)(rec_); +} + +uint32_t BlpReader::Width() const { return Game::Read(rec_, Offsets::OFF_BLP_WIDTH); } + +uint32_t BlpReader::Height() const { return Game::Read(rec_, Offsets::OFF_BLP_HEIGHT); } + +const uint8_t *BlpReader::DecodeBgra() { + if (!open_) + return nullptr; + // The engine has no DXT5 converter: DecodeLevel would report success + // with the buffer unwritten (BLP_ALPHA_TYPE_DXT5 in Offsets.h). + if (rec_[Offsets::OFF_BLP_COMPRESSION] == Offsets::BLP_COMPRESSION_DXT && + rec_[Offsets::OFF_BLP_ALPHA_TYPE] == Offsets::BLP_ALPHA_TYPE_DXT5) + return nullptr; + void *out = nullptr; + uint32_t size = 0; + decoded_ = true; + if (reinterpret_cast(Offsets::FUN_BLP_DECODE_LEVEL)( + rec_, Offsets::GX_TEX_FORMAT_BGRA8888, 0, &out, &size) == 0) + return nullptr; + return static_cast(out); +} + +void BlpReader::Init() { + std::memset(rec_, 0, sizeof(rec_)); + Put(Offsets::OFF_BLP_MAGIC, 0x32504C42); // 'BLP2' + Put(Offsets::OFF_BLP_VERSION, 1); + rec_[Offsets::OFF_BLP_ALPHA_TYPE] = 2; + Put(Offsets::OFF_BLP_INIT_4A4, 100); +} + +void BlpReader::Put(uintptr_t off, uint32_t v) { std::memcpy(rec_ + off, &v, sizeof(v)); } + +} // namespace Texture diff --git a/src/texture/BlpReader.h b/src/texture/BlpReader.h new file mode 100644 index 00000000..57d144ff --- /dev/null +++ b/src/texture/BlpReader.h @@ -0,0 +1,56 @@ +// This file is part of ClassicAPI. +// +// ClassicAPI is free software: you can redistribute it and/or modify it under the terms +// of the GNU General Public License as published by the Free Software Foundation, either +// version 3 of the License, or (at your option) any later version. +// +// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with +// ClassicAPI. If not, see . + +#pragma once + +#include "Offsets.h" + +#include +#include + +namespace Texture { + +// The engine's BLP reader, driven exactly as GetPortraitAlphaMask drives it +// (see the FUN_BLP_OPEN block in Offsets.h). Open or parse in the +// constructor, decode on demand, free and reset in the destructor. +class BlpReader { +public: + // Reads the file at `path` (patch MPQs honoured). Only one reader made + // this way may be live at a time: the engine keeps the file in one + // module-static buffer. + explicit BlpReader(const char *path); + // Parses `size` bytes of a BLP file that stay valid and unchanged for + // the reader's life: the open without the read (FUN_BLP_PARSE). + BlpReader(const void *file, std::size_t size); + ~BlpReader(); + BlpReader(const BlpReader &) = delete; + BlpReader &operator=(const BlpReader &) = delete; + + bool IsOpen() const { return open_; } + uint32_t Width() const; + uint32_t Height() const; + + // Level 0 as BGRA8888 (alpha in byte 3), valid until destruction; null + // on failure, and for a DXT5 file, which the engine cannot convert. + const uint8_t *DecodeBgra(); + +private: + void Init(); + void Put(uintptr_t off, uint32_t v); + + uint8_t rec_[Offsets::BLP_RECORD_SIZE]; + bool open_ = false; + bool decoded_ = false; +}; + +} // namespace Texture diff --git a/src/texture/Builtin.cpp b/src/texture/Builtin.cpp index bc5ab8ff..02aa6df7 100644 --- a/src/texture/Builtin.cpp +++ b/src/texture/Builtin.cpp @@ -20,12 +20,28 @@ // texture hash and inserts what it returns under the name and flags, so the // engine's own cache deduplicates and reference-counts the result like any // file's. A real file of the same name (a patch MPQ, a loose file) still wins. +// +// The texels come from two places: +// - interface\cooldown\edge.blp is built here from its decoded texels; +// - the files under src/embed/Textures sit at their game paths +// (Texture::Builtin::Manifest), each the later client's own BLP. One +// decodes the first time a load or Find asks for it, through the engine's +// BLP reader on the DLL's copy (Texture::BlpReader), and its texels stay. +#include "texture/Builtin.h" + +#include "Common.h" #include "Game.h" #include "Offsets.h" +#include "embed/Resource.h" +#include "embedded_resources.h" +#include "texture/BlpReader.h" #include "texture/Load.h" +#include #include +#include +#include namespace Texture::Builtin { @@ -53,14 +69,24 @@ struct EdgeTexels { }; const EdgeTexels g_edge; -// `path` names `file` (the loader's path, extension included), either slash, any case. -bool IsPath(const char *path, const char *file) { - for (; *file != '\0'; ++file, ++path) { - const char c = *path == '/' ? '\\' : *path; - if (c == '\0' || (c | 0x20) != (*file | 0x20)) - return false; - } - return *path == '\0'; +// One embedded file, decoded at most once. Like the edge texels, the texels +// outlive every handle made from them. +struct Embedded { + bool tried = false; + std::vector texels; + Image image = {}; +}; +Embedded g_embedded[Manifest::kFileCount]; + +void Decode(const Manifest::File &file, Embedded &out) { + const Embed::Bytes bytes = Embed::File(file.resourceId, static_cast(file.index)); + BlpReader blp(bytes.data, bytes.size); + const uint8_t *bgra = blp.DecodeBgra(); + if (bgra == nullptr) + return; + out.texels.resize(size_t{blp.Width()} * blp.Height()); + std::memcpy(out.texels.data(), bgra, out.texels.size() * sizeof(uint32_t)); + out.image = {blp.Width(), blp.Height(), out.texels.data()}; } using CreateFromBlp_t = void *(__fastcall *)(const char *path, uint32_t fileFlag, uint32_t flags, @@ -69,9 +95,13 @@ CreateFromBlp_t CreateFromBlp_o = nullptr; void *__fastcall CreateFromBlp_h(const char *path, uint32_t fileFlag, uint32_t flags, void *log) { void *handle = CreateFromBlp_o(path, fileFlag, flags, log); - if (handle == nullptr && path != nullptr && IsPath(path, "interface\\cooldown\\edge.blp")) - handle = CreateFromPixels(path, kEdgeSize, kEdgeSize, g_edge.texels, flags); - return handle; + if (handle != nullptr || path == nullptr) + return handle; + if (Common::SamePath(path, "interface\\cooldown\\edge.blp")) + return CreateFromPixels(path, kEdgeSize, kEdgeSize, g_edge.texels, flags); + if (const Image *image = Find(path)) + return CreateFromPixels(path, image->width, image->height, image->texels, flags); + return nullptr; } const Game::HookAutoRegister _createFromBlpHook{Offsets::FUN_TEXTURE_CREATE_FROM_BLP, @@ -80,4 +110,18 @@ const Game::HookAutoRegister _createFromBlpHook{Offsets::FUN_TEXTURE_CREATE_FROM } // namespace +const Image *Find(const char *path) { + if (path == nullptr) + return nullptr; + const Manifest::File *file = Embed::FindFile(Manifest::kFiles, path); + if (file == nullptr) + return nullptr; + Embedded &embedded = g_embedded[file - Manifest::kFiles]; + if (!embedded.tried) { + embedded.tried = true; + Decode(*file, embedded); + } + return embedded.texels.empty() ? nullptr : &embedded.image; +} + } // namespace Texture::Builtin diff --git a/src/texture/Builtin.h b/src/texture/Builtin.h new file mode 100644 index 00000000..a8e9be4d --- /dev/null +++ b/src/texture/Builtin.h @@ -0,0 +1,33 @@ +// This file is part of ClassicAPI. +// +// ClassicAPI is free software: you can redistribute it and/or modify it under the terms +// of the GNU General Public License as published by the Free Software Foundation, either +// version 3 of the License, or (at your option) any later version. +// +// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with +// ClassicAPI. If not, see . + +#pragma once + +#include + +namespace Texture::Builtin { + +// An embedded image: level 0 as 0xAARRGGBB texels (BGRA8888 in memory), row +// by row, kept for the life of the DLL. +struct Image { + uint32_t width; + uint32_t height; + const uint32_t *texels; +}; + +// The embedded BLP at the game path `path` (either slash, any case), decoded +// on the first call; null when none is embedded there or it does not decode. +// A texture load of the path that finds no file gets the same image. +const Image *Find(const char *path); + +} // namespace Texture::Builtin