Self / ally arts cast on the hero when pointed at an enemy
Arts with launch type 2 (buffs, auras, heals) are only valid on allies or without a target; with an enemy under the cursor the game dropped the order. Orders of a player hero for such arts lose a non-ally target, which casts them on the hero. The launch type comes from the art definition tables (game/engine/arts.h, tools/arttable.mjs). Also: skill keys check the relocated taskbar slots (all 7), Ghidra data labels (g_ names in names.txt). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
dbc632750b
commit
30c93fc87e
9 files changed
+187
-10
No files matched your search
@@ -40,6 +40,7 @@ add_library(sacred_re SHARED
|
|||||||
src/mods/skillslots.cpp
|
src/mods/skillslots.cpp
|
||||||
src/mods/inventory_save.cpp
|
src/mods/inventory_save.cpp
|
||||||
src/mods/skillbar.cpp
|
src/mods/skillbar.cpp
|
||||||
|
src/mods/arttarget.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(sacred_re PRIVATE src)
|
target_include_directories(sacred_re PRIVATE src)
|
||||||
target_compile_options(sacred_re PRIVATE ${SACRED_RE_CXX_FLAGS})
|
target_compile_options(sacred_re PRIVATE ${SACRED_RE_CXX_FLAGS})
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#pragma once
|
||||||
|
// Combat arts: the definition tables and how they are targeted.
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
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<Fn>(0x004d2150)(id);
|
||||||
|
}
|
||||||
|
inline const uint8_t* FindCombatMoveDef(uint16_t id) {
|
||||||
|
using Fn = const uint8_t*(__cdecl*)(uint32_t);
|
||||||
|
return reinterpret_cast<Fn>(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<LaunchType>(spell[0x3c]);
|
||||||
|
}
|
||||||
|
if (const uint8_t* move = FindCombatMoveDef(id)) {
|
||||||
|
return static_cast<LaunchType>(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<Fn>(0x00548f20)(creature, nullptr, id) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace game
|
||||||
@@ -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 <cstdint>
|
||||||
|
|
||||||
|
#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<uint32_t*>(static_cast<uint8_t*>(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<uint32_t*>(static_cast<uint8_t*>(creature) + 0xc);
|
||||||
|
if (target == 0 || casterId >= kPlayerIds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ArtLaunchType(static_cast<uint16_t>(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
|
||||||
@@ -83,6 +83,13 @@ void SelectWeaponSet(unsigned slot) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool HasSkillSlot(const void* taskbar, unsigned slot) {
|
||||||
|
const auto* p = static_cast<const uint8_t*>(taskbar);
|
||||||
|
constexpr size_t kSlotCount = 0x15c; // u16: slots shown, from SetSlotCount
|
||||||
|
return slot < kSlots && slot < *reinterpret_cast<const uint16_t*>(p + kSlotCount) &&
|
||||||
|
reinterpret_cast<void* const*>(p + kSkillSlots)[slot] != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
void OnTaskbar(void* taskbar) {
|
void OnTaskbar(void* taskbar) {
|
||||||
g_taskbar = static_cast<uint8_t*>(taskbar);
|
g_taskbar = static_cast<uint8_t*>(taskbar);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,7 @@ namespace mods::skillbar {
|
|||||||
void SelectSkillSlot(unsigned slot);
|
void SelectSkillSlot(unsigned slot);
|
||||||
void SelectWeaponSet(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
|
} // namespace mods::skillbar
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
|
|
||||||
#include "mods/skillkeys.h"
|
#include "mods/skillkeys.h"
|
||||||
|
|
||||||
|
#include "mods/skillbar.h"
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
|
|
||||||
@@ -21,11 +23,6 @@
|
|||||||
namespace mods::skillkeys {
|
namespace mods::skillkeys {
|
||||||
namespace {
|
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).
|
// The game window, if it has the focus (it does while a skill key is used).
|
||||||
HWND GameWindow() {
|
HWND GameWindow() {
|
||||||
HWND window = GetForegroundWindow();
|
HWND window = GetForegroundWindow();
|
||||||
@@ -84,9 +81,7 @@ void OnSlotSelected(void* taskbar, unsigned slot) {
|
|||||||
}
|
}
|
||||||
const int key = g_state.pendingKey;
|
const int key = g_state.pendingKey;
|
||||||
g_state.pendingKey = 0;
|
g_state.pendingKey = 0;
|
||||||
const auto* bar = static_cast<const uint8_t*>(taskbar);
|
if (!mods::skillbar::HasSkillSlot(taskbar, slot)) {
|
||||||
if (slot >= kSlots || slot >= *reinterpret_cast<const uint16_t*>(bar + kTaskbarSlotCount) ||
|
|
||||||
!reinterpret_cast<void* const*>(bar + kTaskbarSkillSlots)[slot]) {
|
|
||||||
return; // empty slot: nothing was selected
|
return; // empty slot: nothing was selected
|
||||||
}
|
}
|
||||||
HWND window = GameWindow();
|
HWND window = GameWindow();
|
||||||
@@ -118,3 +113,4 @@ void __fastcall UiTaskbar_SelectSkillSlot(void* self, void* edx, unsigned slot,
|
|||||||
}
|
}
|
||||||
|
|
||||||
} // namespace game
|
} // namespace game
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Dump sacred.exe's combat-art definition tables (see docs/skill-slots.md).
|
||||||
|
// node tools/arttable.mjs <exe> [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(" ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
//
|
//
|
||||||
// names.txt: one "0xADDRESS Name" per line, "Class::Method" puts the function
|
// names.txt: one "0xADDRESS Name" per line, "Class::Method" puts the function
|
||||||
// into a class namespace; "#" starts a comment. Functions are created where
|
// 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
|
// @category sacred_re
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -33,6 +34,7 @@ public class Rename extends GhidraScript {
|
|||||||
}
|
}
|
||||||
SymbolTable symbols = currentProgram.getSymbolTable();
|
SymbolTable symbols = currentProgram.getSymbolTable();
|
||||||
int renamed = 0;
|
int renamed = 0;
|
||||||
|
int labelled = 0;
|
||||||
for (String raw : Files.readAllLines(Paths.get(args[0]), StandardCharsets.UTF_8)) {
|
for (String raw : Files.readAllLines(Paths.get(args[0]), StandardCharsets.UTF_8)) {
|
||||||
String line = raw.replaceAll("#.*", "").trim();
|
String line = raw.replaceAll("#.*", "").trim();
|
||||||
if (line.isEmpty()) {
|
if (line.isEmpty()) {
|
||||||
@@ -44,6 +46,11 @@ public class Rename extends GhidraScript {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Address address = toAddr(parts[0]);
|
Address address = toAddr(parts[0]);
|
||||||
|
if (parts[1].startsWith("g_")) {
|
||||||
|
createLabel(address, parts[1], true, SourceType.USER_DEFINED);
|
||||||
|
labelled++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
Function function = getFunctionAt(address);
|
Function function = getFunctionAt(address);
|
||||||
if (function == null) {
|
if (function == null) {
|
||||||
new DisassembleCommand(address, null, true).applyTo(currentProgram, monitor);
|
new DisassembleCommand(address, null, true).applyTo(currentProgram, monitor);
|
||||||
@@ -69,6 +76,6 @@ public class Rename extends GhidraScript {
|
|||||||
function.setName(name, SourceType.USER_DEFINED);
|
function.setName(name, SourceType.USER_DEFINED);
|
||||||
renamed++;
|
renamed++;
|
||||||
}
|
}
|
||||||
println("renamed " + renamed + " functions");
|
println("renamed " + renamed + " functions, labelled " + labelled + " data addresses");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,3 +119,25 @@
|
|||||||
0x006e31e0 cUI_Taskbar2::RenderArtSlots
|
0x006e31e0 cUI_Taskbar2::RenderArtSlots
|
||||||
0x006e4310 cUI_Taskbar2::UpdateTooltips
|
0x006e4310 cUI_Taskbar2::UpdateTooltips
|
||||||
0x0081c020 Options::CombineSlots # COMBINE_SLOTS: weapon set also selects the art slot
|
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
|
||||||
Reference in new issue
Block a user