diff --git a/CMakeLists.txt b/CMakeLists.txt index 54d551d..ce229a2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,7 @@ add_library(sacred_re SHARED src/mods/skillslots.cpp src/mods/inventory_save.cpp src/mods/skillbar.cpp + src/mods/arttarget.cpp ) target_include_directories(sacred_re PRIVATE src) target_compile_options(sacred_re PRIVATE ${SACRED_RE_CXX_FLAGS}) diff --git a/src/game/engine/arts.h b/src/game/engine/arts.h new file mode 100644 index 0000000..59b5a56 --- /dev/null +++ b/src/game/engine/arts.h @@ -0,0 +1,48 @@ +#pragma once +// Combat arts: the definition tables and how they are targeted. + +#include + +namespace game { + +// How an art is launched (byte +0x3c of a g_spellDefs entry, +0x4c of a +// g_combatMoveDefs entry; cCreature::IsValidArtTarget 0x51d680 switches on it). +enum class LaunchType : uint8_t { + Target0 = 0, // aimed (e.g. Feuerball) + Enemy = 1, // aimed at an enemy (attacks, Blitzschlag) + SelfOrAlly = 2, // buffs, auras, heals: an ally, or the caster without a target + Ground = 3, // a point / area, summons + Corpse = 4, // a dead ally (resurrection) + AroundSelf = 5, // centred on the caster + Unknown = 0xff, +}; + +// SpellDef::Find (0x4d2150) / CombatMoveDef::Find (0x4d1930): the definition +// entry of art `id` (spells: small ids, combat moves: 0x3e8+), or null. +inline const uint8_t* FindSpellDef(uint16_t id) { + using Fn = const uint8_t*(__cdecl*)(uint32_t); + return reinterpret_cast(0x004d2150)(id); +} +inline const uint8_t* FindCombatMoveDef(uint16_t id) { + using Fn = const uint8_t*(__cdecl*)(uint32_t); + return reinterpret_cast(0x004d1930)(id); +} + +// The launch type of art `id`, looked up as cEngine::UpdateCursorPick does. +inline LaunchType ArtLaunchType(uint16_t id) { + if (const uint8_t* spell = FindSpellDef(id)) { + return static_cast(spell[0x3c]); + } + if (const uint8_t* move = FindCombatMoveDef(id)) { + return static_cast(move[0x4c]); + } + return LaunchType::Unknown; +} + +// cCreature::IsAlly (0x548f20): whether object `id` is an ally of `creature`. +inline bool IsAlly(void* creature, uint32_t id) { + using Fn = char(__fastcall*)(void*, void*, uint32_t); + return reinterpret_cast(0x00548f20)(creature, nullptr, id) != 0; +} + +} // namespace game diff --git a/src/mods/arttarget.cpp b/src/mods/arttarget.cpp new file mode 100644 index 0000000..aed2caf --- /dev/null +++ b/src/mods/arttarget.cpp @@ -0,0 +1,56 @@ +// Self / ally arts cast on the caster when pointed at an enemy. +// +// Using an art sends the hero a cEvent_creature 0x105 with the object under +// the cursor as target (+0x10, 0 for the ground or the hero itself) and the +// art id (+0x30). Arts with launch type SelfOrAlly (buffs, auras, heals) are +// only valid on allies or without a target (cCreature::IsValidArtTarget, +// 0x51d680); with an enemy under the cursor the game drops the order and +// nothing happens. For a player hero such an order loses its target instead, +// which casts the art on the hero, as a click on the hero does. + +#include + +#include "game/engine/arts.h" +#include "re/hook.h" + +namespace game { +namespace { + +constexpr uint32_t kOrderUseArt = 0x105; +constexpr uint32_t kPlayerIds = 0x11; // object ids below this are player heroes + +// cEvent_creature fields used here. +constexpr size_t kOrderCommand = 0x4; +constexpr size_t kOrderTarget = 0x10; // object id +constexpr size_t kOrderArt = 0x30; // art id (cCreature::ResolveArtId) + +uint32_t& Field(void* event, size_t offset) { + return *reinterpret_cast(static_cast(event) + offset); +} + +void RetargetSelfArt(void* creature, void* event) { + if (!creature || !event || Field(event, kOrderCommand) != kOrderUseArt) { + return; + } + const uint32_t target = Field(event, kOrderTarget); + const uint32_t casterId = *reinterpret_cast(static_cast(creature) + 0xc); + if (target == 0 || casterId >= kPlayerIds) { + return; + } + if (ArtLaunchType(static_cast(Field(event, kOrderArt))) == LaunchType::SelfOrAlly && + !IsAlly(creature, target)) { + Field(event, kOrderTarget) = 0; + } +} + +} // namespace + +// cEngine::SendCreatureOrder (0x6170d0): orders for the controlled creature. +void __fastcall Engine_SendCreatureOrder(void* self, void* edx, void* creature, void* event); +RE_HOOK(0x006170d0, Engine_SendCreatureOrder); +void __fastcall Engine_SendCreatureOrder(void* self, void* edx, void* creature, void* event) { + RetargetSelfArt(creature, event); + Engine_SendCreatureOrder_original(self, edx, creature, event); +} + +} // namespace game diff --git a/src/mods/skillbar.cpp b/src/mods/skillbar.cpp index 808a082..79c5c1a 100644 --- a/src/mods/skillbar.cpp +++ b/src/mods/skillbar.cpp @@ -83,6 +83,13 @@ void SelectWeaponSet(unsigned slot) { } } +bool HasSkillSlot(const void* taskbar, unsigned slot) { + const auto* p = static_cast(taskbar); + constexpr size_t kSlotCount = 0x15c; // u16: slots shown, from SetSlotCount + return slot < kSlots && slot < *reinterpret_cast(p + kSlotCount) && + reinterpret_cast(p + kSkillSlots)[slot] != nullptr; +} + void OnTaskbar(void* taskbar) { g_taskbar = static_cast(taskbar); } diff --git a/src/mods/skillbar.h b/src/mods/skillbar.h index 2b42c19..6208310 100644 --- a/src/mods/skillbar.h +++ b/src/mods/skillbar.h @@ -7,4 +7,7 @@ namespace mods::skillbar { void SelectSkillSlot(unsigned slot); void SelectWeaponSet(unsigned slot); +// Whether the taskbar shows combat-art slot `slot` (unlocked, control exists). +bool HasSkillSlot(const void* taskbar, unsigned slot); + } // namespace mods::skillbar diff --git a/src/mods/skillkeys.cpp b/src/mods/skillkeys.cpp index 61eb707..cbfb7fb 100644 --- a/src/mods/skillkeys.cpp +++ b/src/mods/skillkeys.cpp @@ -11,6 +11,8 @@ #include "mods/skillkeys.h" +#include "mods/skillbar.h" + #include #include @@ -21,11 +23,6 @@ namespace mods::skillkeys { namespace { -// cUI_Taskbar2 fields. -constexpr size_t kTaskbarSlotCount = 0x15c; // uint16: combat-art slots the hero has -constexpr size_t kTaskbarSkillSlots = 0x178; // 5 slot controls (null: none) -constexpr unsigned kSlots = 5; - // The game window, if it has the focus (it does while a skill key is used). HWND GameWindow() { HWND window = GetForegroundWindow(); @@ -84,9 +81,7 @@ void OnSlotSelected(void* taskbar, unsigned slot) { } const int key = g_state.pendingKey; g_state.pendingKey = 0; - const auto* bar = static_cast(taskbar); - if (slot >= kSlots || slot >= *reinterpret_cast(bar + kTaskbarSlotCount) || - !reinterpret_cast(bar + kTaskbarSkillSlots)[slot]) { + if (!mods::skillbar::HasSkillSlot(taskbar, slot)) { return; // empty slot: nothing was selected } HWND window = GameWindow(); @@ -118,3 +113,4 @@ void __fastcall UiTaskbar_SelectSkillSlot(void* self, void* edx, unsigned slot, } } // namespace game + diff --git a/tools/arttable.mjs b/tools/arttable.mjs new file mode 100644 index 0000000..4693a24 --- /dev/null +++ b/tools/arttable.mjs @@ -0,0 +1,37 @@ +// Dump sacred.exe's combat-art definition tables (see docs/skill-slots.md). +// node tools/arttable.mjs [id...] (ids hex, e.g. 3f7; none: all) +import { readFileSync } from "node:fs"; + +const [exePath, ...ids] = process.argv.slice(2); +const file = readFileSync(exePath); +const pe = file.readUInt32LE(0x3c); +const sections = file.readUInt16LE(pe + 6); +const optional = file.readUInt16LE(pe + 20); +const imageBase = file.readUInt32LE(pe + 52); +const table = pe + 24 + optional; +function offset(va) { + for (let i = 0; i < sections; i++) { + const s = table + i * 40; + const vsize = file.readUInt32LE(s + 8), vaddr = file.readUInt32LE(s + 12); + const raw = file.readUInt32LE(s + 20), rawSize = file.readUInt32LE(s + 16); + const rva = va - imageBase; + if (rva >= vaddr && rva < vaddr + vsize) return rva - vaddr < rawSize ? rva - vaddr + raw : -1; + } + return -1; +} +const tables = [ + { name: "A", base: 0x94ddb0, stride: 0x78, end: 0x950a38 }, + { name: "B", base: 0x950a38, stride: 0x94, end: 0x953344 }, +]; +const wanted = new Set(ids.map((t) => parseInt(t, 16))); +for (const t of tables) { + for (let va = t.base; va < t.end; va += t.stride) { + const o = offset(va); + if (o < 0) { console.log(t.name, va.toString(16), "(no file data: filled at runtime)"); break; } + const id = file.readUInt16LE(o); + if (wanted.size && !wanted.has(id)) continue; + const words = []; + for (let k = 0; k < t.stride; k += 4) words.push(file.readUInt32LE(o + k).toString(16).padStart(8, "0")); + console.log(`${t.name} ${va.toString(16)} id ${id.toString(16)}: ${words.join(" ")}`); + } +} diff --git a/tools/ghidra/Rename.java b/tools/ghidra/Rename.java index 5ce6a7e..f10bdc2 100644 --- a/tools/ghidra/Rename.java +++ b/tools/ghidra/Rename.java @@ -7,7 +7,8 @@ // // names.txt: one "0xADDRESS Name" per line, "Class::Method" puts the function // into a class namespace; "#" starts a comment. Functions are created where -// none starts at the address yet. +// none starts at the address yet. Names starting with "g_" label data +// (globals, tables) instead of functions. // @category sacred_re import java.nio.charset.StandardCharsets; @@ -33,6 +34,7 @@ public class Rename extends GhidraScript { } SymbolTable symbols = currentProgram.getSymbolTable(); int renamed = 0; + int labelled = 0; for (String raw : Files.readAllLines(Paths.get(args[0]), StandardCharsets.UTF_8)) { String line = raw.replaceAll("#.*", "").trim(); if (line.isEmpty()) { @@ -44,6 +46,11 @@ public class Rename extends GhidraScript { continue; } Address address = toAddr(parts[0]); + if (parts[1].startsWith("g_")) { + createLabel(address, parts[1], true, SourceType.USER_DEFINED); + labelled++; + continue; + } Function function = getFunctionAt(address); if (function == null) { new DisassembleCommand(address, null, true).applyTo(currentProgram, monitor); @@ -69,6 +76,6 @@ public class Rename extends GhidraScript { function.setName(name, SourceType.USER_DEFINED); renamed++; } - println("renamed " + renamed + " functions"); + println("renamed " + renamed + " functions, labelled " + labelled + " data addresses"); } } diff --git a/tools/names.txt b/tools/names.txt index 56856f8..d394d45 100644 --- a/tools/names.txt +++ b/tools/names.txt @@ -119,3 +119,25 @@ 0x006e31e0 cUI_Taskbar2::RenderArtSlots 0x006e4310 cUI_Taskbar2::UpdateTooltips 0x0081c020 Options::CombineSlots # COMBINE_SLOTS: weapon set also selects the art slot +# combat arts +0x0052e400 cCreature::ReceiveOrder # vf18; order 0x105 (use art) at 0x52fc58 +0x006170d0 cEngine::SendCreatureOrder +0x005521e0 cCreature::ResolveArtId # art variant for the creature class (+0x10) +0x00580cb0 cArtCooldowns::IsReady # (art): cooldown <= 0.01 +0x00548ef0 cCreature::IsAlive +0x004d1930 CombatMoveDef::Find # (id) -> g_combatMoveDefs entry +0x004d2150 SpellDef::Find # (id) -> g_spellDefs entry +0x004d1a20 CombatMoveDef::UsableMounted # flags +0x74 +0x004d23e0 SpellDef::UsableMounted # flags +0x90 +0x0094ddb0 g_combatMoveDefs # 95 x 0x78: id +0, icon name +0xc, target type +0x4c +0x00950a38 g_spellDefs # 71 x 0x94: id +0, icon name +0xc, target type u16 +0x3c +0x0095503c g_slotCountByLevel # (level, slots) u16 pairs +0x009ed058 g_taskbarSlotColors +0x00ad23c8 g_inventoryManager +0x00ad3bc0 g_objectManager +0x0051d680 cCreature::IsValidArtTarget # (pick, launch type): can the art be used on the picked object +0x0051d670 NormalizeArtId +0x00548f20 cCreature::IsAlly # (object id) +0x00548e70 cCreature::IsEnemy # (object id) +0x00611f10 cEngine::UpdateCursorPick # cursor target for the selected art +0x00548060 cCreature::ChooseAiArt # monsters: art per creature class