From 3e0c40184b504989833cf297c2e4255e65c13955 Mon Sep 17 00:00:00 2001 From: Brues <5278969+brues-code@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:04:02 -0600 Subject: [PATCH] feat(cursor): add expanded cursors for NPCs and the fishing bobber MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the expandedCursors cvar (default 0; AddOn Options > ClassicAPI > Interface > Cursor). At 1 the hover cursor shows what an NPC offers: an anvil for a repairer, a hearthstone for an innkeeper, a horseshoe for a stable master, and a quest giver's ! or ? (a blue ? for a repeatable reward). The player's fishing bobber shows a fish hook in place of the Interact gear. Each has a gray form beyond reach or when not usable. NPCs: ClassifyHoveredUnit (0x00482200) reaches its NPC flag chain at 0x00482336 for a unit the player can interact with, the unit in ESI and the beyond-reach flag in EBX. The chain's `mov ecx, [esi+0x110]` becomes a call to a thunk. At 0 it replays the instruction and the stock chain runs. At 1 it picks from the quest giver status first (CGUnit_C +0xCB8, read by the meaning of the client's status-to-marker table at 0x0080C454), then the NPC flags in service order: repair, innkeeper, flight master, trainer, spirit healer, banker, registrar/tabard/ battlemaster, auctioneer, stable master, vendor, then gossip or a quest giver IsQuestGiverToTalk (0x005DF490) accepts. It leaves through the chain's own epilogue (0x00482356). Bobber: the fishing node handler's cursor slot (vtable 0x0080BC80, slot 4; GetCursor 0x005F8760 is its one reader) points at a wrapper that runs BaseHandlerGetCursor (0x005F3070) and maps Interact/UnableInteract to the hook. The engine still decides whether the bobber is interactable and usable. Showing a mode past 41: SetCursorMode indexes a 42-slot image table, so its existing hook now sends the new modes to Cursor::Extended::Show. It calls SetCustomCursor (0x00523C40) with the mode's path, so a real file wins, and otherwise does what that function does after its load: copy the 32x32 image into slot 41 (0x00C0BD80), store mode 41, send it (SendCursorImage 0x00523790) and keep the name. The hook also records the new modes while Hover::Mode computes, so the soft-interact pick, its icon and SetUnitCursorTexture follow; a quest cursor has no icon image. Images: the 14 BLPs ship inside the DLL (src/embed/Textures, a new TEXTURES_ROOT pack). Texture::Builtin decodes each once with the engine's BLP reader from memory: BlpParse (0x005A83B0) is BlpOpen without the file read, and BlpReset never frees the file buffer. The reader moves to Texture::BlpReader, shared with the map overlays. Texture loads of the same paths, such as the option label's inline icons, get the same images. The reader refuses a DXT5 file. BlpDecodeLevel (0x005A7F00) converts a DXT level through the matrix InitConvertMatrix (0x005A4FC0) fills, and that matrix has DXT1 and DXT3 converters to BGRA8888 but none for DXT5: BlpDecodeLevel would return 1 with its SMemAlloc buffer unwritten. No shipped cursor is DXT5; the check keeps a later embedded DXT5 file, or a DXT5 map overlay, from being read as heap memory. Game paths now compare through one rule, Common::SamePath (any case, either slash), and the embedded manifests through Embed::FindFile. The embedded addons, the bindings splice and sound muting each had their own copy of the rule; they and Texture::Builtin now share it. The option's header, label and tooltip are translated for every client locale, in Blizzard's own terms for each (NSC, PNJ, 物体, 物件, ...). Release Win32 build is clean. In game, only the soft-interact quest icon was checked so far. --- .gitattributes | 1 + CMakeLists.txt | 16 +- README.md | 1 + cmake/embed_resources.cmake | 64 ++++-- docs/API.md | 55 ++++++ src/Common.cpp | 16 ++ src/Common.h | 7 + src/Offsets.h | 79 ++++++++ src/addons/Embedded.cpp | 31 +-- src/bindings/Inject.cpp | 17 +- src/cursor/Extended.cpp | 118 +++++++++++ src/cursor/Extended.h | 57 ++++++ src/cursor/Fishing.cpp | 67 +++++++ src/cursor/Hover.cpp | 24 ++- src/cursor/Hover.h | 13 +- src/cursor/Npc.cpp | 187 ++++++++++++++++++ .../AddOns/!!!ClassicAPI/Options/Panel.lua | 11 ++ .../AddOns/!!!ClassicAPI/locales/deDE.lua | 3 + .../AddOns/!!!ClassicAPI/locales/enUS.lua | 3 + .../AddOns/!!!ClassicAPI/locales/esES.lua | 3 + .../AddOns/!!!ClassicAPI/locales/frFR.lua | 3 + .../AddOns/!!!ClassicAPI/locales/koKR.lua | 3 + .../AddOns/!!!ClassicAPI/locales/ruRU.lua | 3 + .../AddOns/!!!ClassicAPI/locales/zhCN.lua | 3 + .../AddOns/!!!ClassicAPI/locales/zhTW.lua | 3 + src/embed/Resource.h | 13 ++ .../Textures/Interface/Cursor/Fishing.blp | Bin 0 -> 3902 bytes .../Textures/Interface/Cursor/Innkeeper.blp | Bin 0 -> 3902 bytes src/embed/Textures/Interface/Cursor/Quest.blp | Bin 0 -> 3902 bytes .../Interface/Cursor/QuestRepeatable.blp | Bin 0 -> 3902 bytes .../Textures/Interface/Cursor/QuestTurnIn.blp | Bin 0 -> 3902 bytes .../Textures/Interface/Cursor/RepairNPC.blp | Bin 0 -> 3902 bytes .../Interface/Cursor/StableMaster.blp | Bin 0 -> 3902 bytes .../Interface/Cursor/UnableFishing.blp | Bin 0 -> 3902 bytes .../Interface/Cursor/UnableInnkeeper.blp | Bin 0 -> 3902 bytes .../Textures/Interface/Cursor/UnableQuest.blp | Bin 0 -> 3902 bytes .../Cursor/UnableQuestRepeatable.blp | Bin 0 -> 3902 bytes .../Interface/Cursor/UnableQuestTurnIn.blp | Bin 0 -> 3902 bytes .../Interface/Cursor/UnableRepairNPC.blp | Bin 0 -> 3902 bytes .../Interface/Cursor/UnableStableMaster.blp | Bin 0 -> 3902 bytes src/map/Overlays.cpp | 55 +----- src/sound/Mute.cpp | 34 +--- src/target/SoftInteract.cpp | 3 + src/texture/BlpReader.cpp | 82 ++++++++ src/texture/BlpReader.h | 56 ++++++ src/texture/Builtin.cpp | 66 +++++-- src/texture/Builtin.h | 33 ++++ 47 files changed, 975 insertions(+), 155 deletions(-) create mode 100644 src/cursor/Extended.cpp create mode 100644 src/cursor/Extended.h create mode 100644 src/cursor/Fishing.cpp create mode 100644 src/cursor/Npc.cpp create mode 100644 src/embed/Textures/Interface/Cursor/Fishing.blp create mode 100644 src/embed/Textures/Interface/Cursor/Innkeeper.blp create mode 100644 src/embed/Textures/Interface/Cursor/Quest.blp create mode 100644 src/embed/Textures/Interface/Cursor/QuestRepeatable.blp create mode 100644 src/embed/Textures/Interface/Cursor/QuestTurnIn.blp create mode 100644 src/embed/Textures/Interface/Cursor/RepairNPC.blp create mode 100644 src/embed/Textures/Interface/Cursor/StableMaster.blp create mode 100644 src/embed/Textures/Interface/Cursor/UnableFishing.blp create mode 100644 src/embed/Textures/Interface/Cursor/UnableInnkeeper.blp create mode 100644 src/embed/Textures/Interface/Cursor/UnableQuest.blp create mode 100644 src/embed/Textures/Interface/Cursor/UnableQuestRepeatable.blp create mode 100644 src/embed/Textures/Interface/Cursor/UnableQuestTurnIn.blp create mode 100644 src/embed/Textures/Interface/Cursor/UnableRepairNPC.blp create mode 100644 src/embed/Textures/Interface/Cursor/UnableStableMaster.blp create mode 100644 src/texture/BlpReader.cpp create mode 100644 src/texture/BlpReader.h create mode 100644 src/texture/Builtin.h 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 0000000000000000000000000000000000000000..09aaa7698457a98574e37f6eb4d7d3203d0629ac GIT binary patch literal 3902 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 aQAba3cu(tS%dlZ^-{M84#7O3NZvO=!*G{bf literal 0 HcmV?d00001 diff --git a/src/embed/Textures/Interface/Cursor/Quest.blp b/src/embed/Textures/Interface/Cursor/Quest.blp new file mode 100644 index 0000000000000000000000000000000000000000..1fd1f8d14925bcd81ba63607e6b11a4df6c0ef4b GIT binary patch literal 3902 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 literal 0 HcmV?d00001 diff --git a/src/embed/Textures/Interface/Cursor/UnableFishing.blp b/src/embed/Textures/Interface/Cursor/UnableFishing.blp new file mode 100644 index 0000000000000000000000000000000000000000..41891f7ff6661f1a30cf85bdd1329db8a4a41576 GIT binary patch literal 3902 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 literal 0 HcmV?d00001 diff --git a/src/embed/Textures/Interface/Cursor/UnableQuestTurnIn.blp b/src/embed/Textures/Interface/Cursor/UnableQuestTurnIn.blp new file mode 100644 index 0000000000000000000000000000000000000000..5104cdd9fbb318e58b58627731cad830da824a42 GIT binary patch literal 3902 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 @@ -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