Art tooltips: "Verbessert durch" lists what improves each art

Arts are objects (game::Art) built from the game's definitions: name,
launch type, damage source, regeneration group and the skills' art lists.
The tooltip gets one line per effect of a skill or attribute, sorted by
size; unlearned class skills are greyed out at 0%. Weapon arts list only
the damage types the held weapon deals and the held weapon's skill.
Tooltip line wrap ignores colour codes; art tooltips wrap wider.
Blitzschlag's tooltip rewrite becomes a tooltip filter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-18 23:04:22 +02:00
1 parent 5d80a33e9b
commit c875d7d6b9
12 files changed
+933 -46

No files matched your search

+339
View File
@@ -0,0 +1,339 @@
#include "game/engine/art.h"
#include <algorithm>
#include <cmath>
#include <map>
#include <memory>
#include "game/engine/orders.h"
#include "game/engine/skills.h"
#include "game/ui/texts.h"
namespace game {
namespace {
// The game's per-skill art lists (cCreatureArts::ApplySkill 0x565f30 passes
// them to ApplySkillToArts 0x565c80): the regeneration time of each listed art
// is divided by 1 + curve / 100; skills with the speed flag also add curve
// FEK..SP to the execution speed +0x3e of the selected art. Ballistik raises
// the damage of its traps too (ArtTooltip's damage factor for them).
struct LinkedStat {
ArtStat stat;
uint32_t curve;
};
struct SkillArts {
uint8_t skill;
std::vector<LinkedStat> stats;
std::vector<uint16_t> arts;
};
const std::vector<SkillArts>& SkillArtLists() {
using S = ArtStat;
auto school = [](uint32_t curve) {
return std::vector<LinkedStat>{{S::RegenerationTime, curve}, {S::ExecutionSpeed, kCurveExecutionSpeed}};
};
static const std::vector<SkillArts> lists = {
{kHimmelsmagie, school(kCurveHimmelsmagie), {0x0d, 0x0f, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x3fb}},
{kFeuermagie, school(kCurveFeuermagie), {0x05, 0x0b, 0x17, 0x18}},
{kWassermagie, school(kCurveWassermagie), {0x1b, 0x04, 0x22, 0x23}},
{kErdmagie, school(kCurveErdmagie), {0x02, 0x10, 0x19, 0x1a}},
{kLuftmagie, school(kCurveLuftmagie), {0x01, 0x1c, 0x1d, 0x21}},
{kMondmagie, school(kCurveMondmagie), {0x03, 0x07, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a}},
{kVampirismus,
{{S::RegenerationTime, kCurveKonzentration}},
{0x3ff, 0x401, 0x403, 0x405, 0x40e, 0x40f, 0x410, 0x411, 0x408, 0x2b, 0x406, 0x407, 0x409, 0x40a, 0x40b,
0x40c, 0x40d}},
{kKonzentration,
{{S::RegenerationTime, kCurveKonzentration}},
{0x3e8, 0x3e9, 0x3ea, 0x3eb, 0x3ec, 0x3ed, 0x3ee, 0x3ef, 0x3f0, 0x3f1, 0x3f2, 0x3f3, 0x3f4, 0x3f5, 0x3f6,
0x3f7, 0x3f8, 0x3f9, 0x3fa, 0x3fc, 0x421, 0x422, 0x423, 0x424, 0x425, 0x426, 0x427, 0x428, 0x429, 0x42a,
0x42b, 0x42c, 0x42d, 0x42e, 0x42f, 0x430, 0x431, 0x432, 0x433, 0x434, 0x41e, 0x3fe, 0x400, 0x402, 0x404,
0x412, 0x413, 0x414, 0x415, 0x416, 0x443, 0x446, 0x444, 0x449, 0x44e, 0x44a, 0x44d, 0x44b, 0x44f, 0x44c,
0x436, 0x437, 0x438, 0x43a, 0x43b, 0x43c, 0x441}},
{kBallistik,
{{S::RegenerationTime, kCurveBallistik},
{S::ExecutionSpeed, kCurveExecutionSpeed},
{S::Damage, kCurveBallistik}},
{0x417, 0x418, 0x419, 0x41a, 0x41b, 0x41c, 0x41d}},
{kFallenkunst, {{S::RegenerationTime, kCurveKonzentration}}, {0x417, 0x418, 0x419, 0x41a, 0x41b, 0x41c, 0x41d}},
{kBlutrausch, school(kCurveBlutrausch), {0x3fe, 0x2b, 0x408, 0x406, 0x407, 0x409, 0x40a, 0x40b, 0x40c, 0x40d}},
{kWaffentechnik, school(kCurveWaffentechnik), {0x43f, 0x43e, 0x43d, 0x37, 0x38, 0x39, 0x3a, 0x3b}},
{kZwergenkunde,
{{S::RegenerationTime, kCurveKonzentration}},
{0x43f, 0x43e, 0x43d, 0x37, 0x38, 0x39, 0x3a, 0x3b}},
{kHoellenmacht, {{S::RegenerationTime, kCurveHoellenmacht}}, {0x3c, 0x3d, 0x3e, 0x3f, 0x40, 0x41, 0x42, 0x43}},
};
return lists;
}
// Arts the game treats unlike their definitions say: Energieblitze counts down
// at the magic rate (+0xee) although it is a combat move (0x562998,
// ArtTooltip); 0x3fa and 0x38 take the weapon's damage factors although they
// have spell values (ArtTooltip).
constexpr uint16_t kMagicRegenerationMoves[] = {kArtEnergieblitze};
constexpr uint16_t kWeaponDamageSpells[] = {0x3fa, 0x38};
template <size_t N>
bool Contains(const uint16_t (&ids)[N], uint16_t id) {
return std::find(std::begin(ids), std::end(ids), id) != std::end(ids);
}
// cCreature weapon checks (plain thiscall, as ApplySkill calls them).
bool CreatureCheck(void* creature, uint32_t address) {
using Fn = bool(__fastcall*)(void*, void*);
return reinterpret_cast<Fn>(address)(creature, nullptr);
}
bool HoldsTwoWeapons(void* creature) {
return CreatureCheck(creature, 0x0054a2e0); // weapon class 8
}
constexpr size_t kCreatureSecondHand = 0x1d8;
constexpr int kWeaponClassBareHands = 0;
constexpr int kWeaponClassBlade = 10;
// The weapon skills: attack value (+0xe6 += AW curve %) and attack speed
// (+0x3c += SP curve, the next curve) while the matching weapon is held
// (ApplySkill cases 3..7, 0xd, 0x18).
struct WeaponSkill {
uint8_t skill;
uint32_t curve;
bool (*applies)(void* creature);
};
const WeaponSkill kWeaponSkills[] = {
{kStangenwaffen, kCurveStangenwaffenValue,
[](void* c) { return CreatureCheck(c, 0x0054a3e0) && !HoldsTwoWeapons(c); }},
{kSchwertkunde, kCurveSchwertkundeValue,
[](void* c) { return CreatureCheck(c, 0x0054a220) && !HoldsTwoWeapons(c); }},
{kAxtkampf, kCurveAxtkampfValue, [](void* c) { return CreatureCheck(c, 0x0054a2f0) && !HoldsTwoWeapons(c); }},
{kZweiWaffenKampf, kCurveZweiWaffenValue,
[](void* c) {
return HoldsTwoWeapons(c) ||
(WeaponClass(c) == kWeaponClassBareHands &&
*reinterpret_cast<const uint32_t*>(static_cast<uint8_t*>(c) + kCreatureSecondHand) != 0);
}},
{kFernkampf, kCurveFernkampfValue, [](void* c) { return HoldsRangedWeapon(c); }},
// The game also counts a blade in the second hand; not covered here.
{kKlingenkampf, kCurveKlingenkampfValue, [](void* c) { return WeaponClass(c) == kWeaponClassBlade; }},
{kFaustkampf, kCurveFaustkampfValue, [](void* c) { return WeaponClass(c) == kWeaponClassBareHands; }},
};
// The held weapon's damage per type (physical, fire, magic, poison):
// cCreatureArts +0x76 + 4 t max, +0x86 + 4 t min (floats; the damage range
// functions 0x563e90 .. 0x564160 multiply them by +0x96 + +0xa6 + 4 t).
constexpr size_t kArtsWeaponDamageMax = 0x76;
constexpr size_t kArtsWeaponDamageMin = 0x86;
bool WeaponDealsDamage(const uint8_t* arts, int damageType) {
auto value = [&](size_t offset) { return *reinterpret_cast<const float*>(arts + offset + 4 * damageType); };
return value(kArtsWeaponDamageMax) > 0.0f || value(kArtsWeaponDamageMin) > 0.0f;
}
// Recalculate (0x579560) gives creature type 8 (Zwerg) the combat rate for
// magic arts too.
constexpr size_t kCreatureType = 0x10;
constexpr uint32_t kTypeZwerg = 8;
// The attributes' shares of the damage factors (Recalculate), in percent:
// (base * F / 10 + invested * w [+ item points]) / BalChar*D with F = (156 -
// BalStatOff) * level / 156 + BalStatOff + 9.
double AttributeDamage(const uint8_t* arts, int attribute, int damageType, double investedWeight, bool items) {
const float off = BalanceValue(kBalStatOff);
const double f = (156.0 - off) * HeroLevel(arts) / 156.0 + off + 9.0;
double value = AttributeBase(arts, attribute) * f * 0.1 + investedWeight * arts[kArtsInvested + attribute];
if (items) {
value += AttributeFromItems(arts, attribute);
}
return 100.0 * value / BalanceValue(kBalCharDamage[damageType]);
}
double RegenerationTimePercent(float bonus) {
return -100.0 * (1.0 - 1.0 / (1.0 + bonus / 100.0));
}
bool Offensive(LaunchType launch) {
return launch != LaunchType::SelfOrAlly && launch != LaunchType::Corpse && launch != LaunchType::Unknown;
}
// Damage values of a spell definition: 4 types x (base, per level), s16 at +0x42.
bool HasDamageValues(const uint8_t* spell) {
const auto* values = reinterpret_cast<const int16_t*>(spell + 0x42);
return std::any_of(values, values + 8, [](int16_t v) { return v != 0; });
}
const wchar_t* StatName(ArtStat stat) {
switch (stat) {
case ArtStat::Damage:
return L"Schaden";
case ArtStat::FireDamage:
return L"Feuerschaden";
case ArtStat::MagicDamage:
return L"Magieschaden";
case ArtStat::PoisonDamage:
return L"Giftschaden";
case ArtStat::AttackValue:
return L"Angriffswert";
case ArtStat::AttackSpeed:
return L"Angriffstempo";
case ArtStat::ExecutionSpeed:
return L"Ausführungstempo";
case ArtStat::RegenerationTime:
return L"Regenerationszeit";
}
return L"";
}
// Tooltip colours (ARGB, "\c" codes as the game's tooltips use them).
constexpr const wchar_t* kHeaderColour = L"\\cFFFFFF80";
constexpr const wchar_t* kTextColour = L"\\cFFFFFFFF";
constexpr const wchar_t* kInactiveColour = L"\\cFFAFAFAF";
std::wstring ImproverName(const Improver& by) {
return by.kind == Improver::Skill ? SkillName(by.id) : AttributeName(by.id);
}
std::map<uint16_t, std::vector<Art::TooltipFilter>>& Filters() {
static std::map<uint16_t, std::vector<Art::TooltipFilter>> filters;
return filters;
}
} // namespace
Art::Art(uint16_t id) : id_(id) {
const uint8_t* spell = FindSpellDef(id);
const uint8_t* def = spell ? spell : FindCombatMoveDef(id);
nameText_ = def ? *reinterpret_cast<const uint32_t*>(def + 4) : 0; // SpellDef / CombatMoveDef +4 (0x4d24d0, 0x4d1b40)
launch_ = ArtLaunchType(id);
const uint8_t* values = ArtSpellDef(id);
spellValues_ = values && !Contains(kWeaponDamageSpells, id);
magicRegeneration_ = spell || Contains(kMagicRegenerationMoves, id);
damage_ = Offensive(launch_) && (!values || HasDamageValues(values));
for (const SkillArts& list : SkillArtLists()) {
if (std::find(list.arts.begin(), list.arts.end(), id) == list.arts.end()) {
continue;
}
for (const LinkedStat& linked : list.stats) {
if (linked.stat != ArtStat::Damage || damage_) {
links_.push_back({list.skill, linked.curve, linked.stat});
}
}
}
}
std::wstring Art::Name() const {
return nameText_ ? GameText(nameText_) : std::wstring();
}
std::vector<Improvement> Art::Improvements(void* creature) const {
auto* arts = static_cast<uint8_t*>(creature) + kCreatureArts;
const uint8_t* classSkills = ClassSkills(arts);
std::vector<Improvement> out;
auto add = [&](Improver by, ArtStat stat, double percent) {
out.push_back({by, stat, static_cast<float>(percent)});
};
auto skill = [&](uint8_t id, uint32_t curve, ArtStat stat) {
const unsigned level = SkillLevel(arts, id);
const bool ofClass =
level > 0 || (classSkills && std::find(classSkills, classSkills + kClassSkills, id) != classSkills + kClassSkills);
if (!ofClass) {
return;
}
const float value = CurveValue(curve, level);
add({Improver::Skill, id}, stat, stat == ArtStat::RegenerationTime ? RegenerationTimePercent(value) : value);
};
// A faster regeneration rate (+x %) as the time it saves.
auto rate = [&](Improver by, double percent) {
add(by, ArtStat::RegenerationTime, RegenerationTimePercent(static_cast<float>(percent)));
};
auto attribute = [&](uint8_t id, ArtStat stat, double percent) { add({Improver::Attribute, id}, stat, percent); };
if (damage_ && spellValues_) {
// Spell values times +0x96 + +0xb6: Magiekunde, Mentale Regeneration.
skill(kMagiekunde, kCurveMagiekunde, ArtStat::Damage);
attribute(kMentaleRegeneration, ArtStat::Damage, AttributeDamage(arts, kMentaleRegeneration, 2, 1.0, false));
} else if (damage_) {
// Weapon damage times +0x96 + +0xa6: Waffenkunde for all types; the
// attributes for the types the held weapon deals.
skill(kWaffenkunde, kCurveWaffenkunde, ArtStat::Damage);
if (WeaponDealsDamage(arts, 0)) {
const uint8_t physical = HoldsRangedWeapon(creature) ? kGeschick : kStaerke;
attribute(physical, ArtStat::Damage, AttributeDamage(arts, physical, 0, 1.0, true));
}
if (WeaponDealsDamage(arts, 1)) {
attribute(kStaerke, ArtStat::FireDamage, AttributeDamage(arts, kStaerke, 1, 2.0, true));
attribute(kGeschick, ArtStat::FireDamage, AttributeDamage(arts, kGeschick, 1, 2.0, true));
}
if (WeaponDealsDamage(arts, 2)) {
attribute(kMentaleRegeneration, ArtStat::MagicDamage,
AttributeDamage(arts, kMentaleRegeneration, 2, 1.0, true));
}
if (WeaponDealsDamage(arts, 3)) {
attribute(kWiderstand, ArtStat::PoisonDamage, AttributeDamage(arts, kWiderstand, 3, 2.0, true));
attribute(kCharisma, ArtStat::PoisonDamage, AttributeDamage(arts, kCharisma, 3, 2.0, true));
}
// Only the skills of the weapon in hand.
for (const WeaponSkill& weapon : kWeaponSkills) {
if (weapon.applies(creature)) {
skill(weapon.skill, weapon.curve, ArtStat::AttackValue);
skill(weapon.skill, weapon.curve + kCurveSpeedOffset, ArtStat::AttackSpeed);
}
}
skill(kWendigkeit, kCurveWendigkeitValue, ArtStat::AttackValue);
}
for (const SkillLink& link : links_) {
skill(link.skill, link.curve, link.stat);
}
// Rates: +0xee = (1 + Meditation %) * (1 + Mentale Regeneration / 100),
// +0xf2 = 1 + Physische Regeneration / 100.
const bool zwerg = *reinterpret_cast<const uint32_t*>(static_cast<uint8_t*>(creature) + kCreatureType) == kTypeZwerg;
if (magicRegeneration_ && !zwerg) {
skill(kMeditation, kCurveMeditation, ArtStat::RegenerationTime); // a rate, divides the time too
rate({Improver::Attribute, kMentaleRegeneration}, AttributeValue(arts, kMentaleRegeneration));
} else {
rate({Improver::Attribute, kPhysischeRegeneration}, AttributeValue(arts, kPhysischeRegeneration));
}
return out;
}
std::wstring Art::Tooltip(void* creature, std::wstring gameText) const {
const auto filters = Filters().find(id_);
if (filters != Filters().end()) {
for (TooltipFilter filter : filters->second) {
filter(*this, creature, gameText);
}
}
std::vector<Improvement> improvements = Improvements(creature);
if (improvements.empty()) {
return gameText;
}
// One line per effect, biggest first; an improver with several effects
// appears once per effect.
std::stable_sort(improvements.begin(), improvements.end(), [](const Improvement& a, const Improvement& b) {
return std::fabs(a.percent) > std::fabs(b.percent);
});
std::wstring text = gameText + L"\n\n" + kHeaderColour + kImprovedByHeader;
for (const Improvement& i : improvements) {
// Greyed out while it changes nothing (not learned).
const long value = std::lround(i.percent);
text += L"\n";
text += value != 0 ? kTextColour : kInactiveColour;
text += ImproverName(i.by) + L" -> " + StatName(i.stat);
text += (value < 0 ? L" " : L" +") + std::to_wstring(value) + L"%";
}
return text + kTextColour;
}
const Art* FindArt(uint16_t id) {
static std::map<uint16_t, std::unique_ptr<Art>> arts;
auto it = arts.find(id);
if (it == arts.end()) {
std::unique_ptr<Art> art;
if (FindSpellDef(id) || FindCombatMoveDef(id)) {
art.reset(new Art(id));
}
it = arts.emplace(id, std::move(art)).first;
}
return it->second.get();
}
void AddTooltipFilter(uint16_t id, Art::TooltipFilter filter) {
Filters()[id].push_back(filter);
}
} // namespace game
+91
View File
@@ -0,0 +1,91 @@
#pragma once
// Combat arts as objects: name, what improves them and by how much, and their
// tooltip (docs/art-tooltip.md).
#include <cstdint>
#include <string>
#include <vector>
#include "game/engine/arts.h"
namespace game {
// The heading of the block Art::Tooltip adds; also marks a text as an art tooltip.
constexpr const wchar_t* kImprovedByHeader = L"Verbessert durch:";
// What about an art can be improved.
enum class ArtStat : uint8_t {
Damage, // all damage of a spell; the physical part of a weapon hit
FireDamage,
MagicDamage,
PoisonDamage,
AttackValue,
AttackSpeed,
ExecutionSpeed, // cast / execution speed while the art is selected (+0x3e)
RegenerationTime, // also faster regeneration rates, as the shorter time
};
// A skill or attribute that improves an art.
struct Improver {
enum Kind : uint8_t { Skill, Attribute } kind;
uint8_t id; // game::Skill / game::Attribute
bool operator==(const Improver& other) const { return kind == other.kind && id == other.id; }
};
// One stat an improver changes, in percent (regeneration time: negative); 0
// for a skill of the hero's class that it has not learned.
struct Improvement {
Improver by;
ArtStat stat;
float percent;
};
class Art {
public:
// Changes a mod makes to an art's tooltip text (the game's description and
// values), e.g. values the mod changed.
using TooltipFilter = void (*)(const Art& art, void* creature, std::wstring& text);
uint16_t Id() const { return id_; }
std::wstring Name() const;
LaunchType Launch() const { return launch_; }
bool DealsDamage() const { return damage_; }
// What improves the art for `creature`: the attributes and the skills of
// its class that act on this art, with their current effect.
std::vector<Improvement> Improvements(void* creature) const;
// The full tooltip: `gameText` (cCreatureArts::ArtTooltip's) with the
// mods' filters applied, then kImprovedByHeader with one line per effect
// of a skill or attribute, biggest first (greyed out while it changes
// nothing).
std::wstring Tooltip(void* creature, std::wstring gameText) const;
private:
friend const Art* FindArt(uint16_t id);
explicit Art(uint16_t id);
// A skill that improves this art beyond the general rules (the game's
// per-skill art lists): its curve and the stat.
struct SkillLink {
uint8_t skill;
uint32_t curve;
ArtStat stat;
};
uint16_t id_;
uint32_t nameText_ = 0;
LaunchType launch_ = LaunchType::Unknown;
bool spellValues_ = false; // damage from spell values (+0xb6 factors), else the weapon's (+0xa6)
bool magicRegeneration_ = false; // counts down at the magic rate (+0xee)
bool damage_ = false;
std::vector<SkillLink> links_;
};
// The art `id`, or null if the game has no definition for it.
const Art* FindArt(uint16_t id);
// Registers a mod's tooltip change for art `id`.
void AddTooltipFilter(uint16_t id, Art::TooltipFilter filter);
} // namespace game
+24 -3
View File
@@ -40,6 +40,22 @@ inline LaunchType ArtLaunchType(uint16_t id) {
return LaunchType::Unknown;
}
// The spell definition an art's damage values come from: its own, else the
// spell its combat move names (0x4d1c50 on the CombatMoveDef, plain ret), as
// cCreatureArts::ArtTooltip looks it up. Null for pure combat moves.
inline const uint8_t* ArtSpellDef(uint16_t id) {
if (const uint8_t* spell = FindSpellDef(id)) {
return spell;
}
const uint8_t* move = FindCombatMoveDef(id);
if (!move) {
return nullptr;
}
using LinkFn = uint16_t(__fastcall*)(const void*, void*);
const uint16_t linked = reinterpret_cast<LinkFn>(0x004d1c50)(move, nullptr);
return linked ? FindSpellDef(linked) : nullptr;
}
// cCreature::ResolveArtId (0x5521e0, (ref, 0) as the taskbar calls it): the
// art id of an art slot's reference (cInventoryEntry::GetArtSlotRef) for this
// creature.
@@ -137,6 +153,13 @@ inline uint8_t* FindCooldownEntry(uint8_t* arts, uint16_t art) {
return begin + index * kCooldownSize;
}
// Whether cooldown entry `entry` counts down at the magic rate (+0xee): magic
// group, or Energieblitze.
inline bool EntryAtMagicRate(const uint8_t* entry) {
return *reinterpret_cast<const uint32_t*>(entry + kCooldownGroup) == kRegenGroupMagic ||
*reinterpret_cast<const uint16_t*>(entry + kCooldownArt) == kArtEnergieblitze;
}
struct ArtCooldown {
float remaining = 0.0f; // in the game's units
float total = 0.0f;
@@ -148,10 +171,8 @@ inline ArtCooldown GetArtCooldown(void* creature, uint16_t art) {
if (!entry) {
return {};
}
const bool magic = *reinterpret_cast<uint32_t*>(entry + kCooldownGroup) == kRegenGroupMagic ||
*reinterpret_cast<uint16_t*>(entry + kCooldownArt) == kArtEnergieblitze;
const float perSecond =
Float(entry, kCooldownSpeed) * Float(arts, magic ? kArtsRegenRateMagic : kArtsRegenRateCombat);
Float(entry, kCooldownSpeed) * Float(arts, EntryAtMagicRate(entry) ? kArtsRegenRateMagic : kArtsRegenRateCombat);
const float remaining = Float(entry, kCooldownRemaining);
return {remaining, Float(entry, kCooldownTotal), perSecond > 0.0f ? remaining / perSecond : 0.0f};
}
+191
View File
@@ -0,0 +1,191 @@
#pragma once
// A creature's attributes and skills (Fertigkeiten) in cCreatureArts (creature
// +0x3a8). See docs/art-tooltip.md for what each of them improves.
#include <cstddef>
#include <cstdint>
#include <string>
#include "game/ui/texts.h"
namespace game {
// Attributes, in the character panel's order.
enum Attribute : uint8_t {
kStaerke = 0,
kWiderstand = 1,
kGeschick = 2,
kPhysischeRegeneration = 3,
kMentaleRegeneration = 4,
kCharisma = 5,
};
constexpr int kAttributeCount = 6;
// The attribute's bare name: the texts with key hashes 0x440470a.. in
// global.res ("Stärke" .. "Charisma"), looked up by hash as GetTextByKey does
// (bit 31). The UI_ATTRIB_* key texts also explain the attribute; their first
// line is the fallback.
constexpr uint32_t kAttributeNameHash = 0x0440470a;
inline std::wstring AttributeName(uint8_t attribute) {
static constexpr const char* kTextKeys[kAttributeCount] = {
"UI_ATTRIB_STRENGTH", "UI_ATTRIB_RESISTANCE", "UI_ATTRIB_GESCHICK",
"UI_ATTRIB_PHYREG", "UI_ATTRIB_MAGREG", "UI_ATTRIB_CHARISMA",
};
if (attribute >= kAttributeCount) {
return {};
}
std::wstring name = GameText(0x80000000 | (kAttributeNameHash + attribute));
if (name.empty()) {
name = GameTextByKey(kTextKeys[attribute]);
name = name.substr(0, name.find_first_of(L"\n:"));
}
return name;
}
// cCreatureArts fields. cCreatureArts::Recalculate (0x579560) sets each shown
// attribute to invested + base + base * (level - 1) / 10, then adds items.
constexpr size_t kArtsAttributes = 0x10; // u16[6]: shown values
constexpr size_t kArtsInvested = 0x1c; // u8[6]: attribute points invested
constexpr int kFreeAttributesIndex = 7; // u8 +0x23: free points
constexpr size_t kArtsSkillIds = 0x24; // u8[8]: skill id by row (0: empty)
constexpr size_t kArtsSkillLevels = 0x2c; // u8[8]: bought levels
constexpr size_t kArtsSkillBonus = 0x34; // u8[8]: levels from items
constexpr size_t kArtsSkillChoices = 0x44; // u16: new skills still to choose
constexpr size_t kArtsCreatureType = 0x8; // u32: the creature (hero class) type
constexpr size_t kArtsAttributeBase = 0x4a; // u16[6]: the class's base values
constexpr size_t kArtsLevel = 0x56; // u16: hero level
constexpr int kSkillRows = 8;
inline uint16_t AttributeValue(const uint8_t* arts, int attribute) {
return reinterpret_cast<const uint16_t*>(arts + kArtsAttributes)[attribute];
}
inline uint16_t AttributeBase(const uint8_t* arts, int attribute) {
return reinterpret_cast<const uint16_t*>(arts + kArtsAttributeBase)[attribute];
}
inline uint16_t HeroLevel(const uint8_t* arts) {
return *reinterpret_cast<const uint16_t*>(arts + kArtsLevel);
}
// The part of an attribute's shown value that comes from items.
inline int AttributeFromItems(const uint8_t* arts, int attribute) {
const int base = AttributeBase(arts, attribute);
const int grown = arts[kArtsInvested + attribute] + base + base * (HeroLevel(arts) - 1) / 10;
return AttributeValue(arts, attribute) - grown;
}
// Skill ids (cCreatureArts::ApplySkill 0x565f30 switches on them).
enum Skill : uint8_t {
kHimmelsmagie = 0x01,
kWaffenkunde = 0x02,
kStangenwaffen = 0x03,
kSchwertkunde = 0x04,
kAxtkampf = 0x05,
kZweiWaffenKampf = 0x06,
kFernkampf = 0x07,
kWendigkeit = 0x08,
kMeditation = 0x0c,
kKlingenkampf = 0x0d,
kMagiekunde = 0x0e,
kFeuermagie = 0x0f,
kWassermagie = 0x10,
kErdmagie = 0x11,
kLuftmagie = 0x12,
kMondmagie = 0x13,
kVampirismus = 0x14,
kFaustkampf = 0x18,
kKonzentration = 0x19,
kBallistik = 0x1a,
kFallenkunst = 0x1b,
kBlutrausch = 0x1c,
kWaffentechnik = 0x1d,
kZwergenkunde = 0x1f,
kHoellenmacht = 0x20,
};
// The level of skill `skill` (bought + from items, as ApplySkill uses it), 0
// if the creature has not learned it.
inline unsigned SkillLevel(const uint8_t* arts, uint8_t skill) {
for (int row = 0; row < kSkillRows; ++row) {
if (arts[kArtsSkillIds + row] == skill) {
return arts[kArtsSkillLevels + row] + arts[kArtsSkillBonus + row];
}
}
return 0;
}
// The name the character panel shows (game text 0x24b7 + id, 0x6abc8d).
inline std::wstring SkillName(uint8_t skill) {
return GameText(0x24b7 + skill);
}
// The skills of the creature's class: 16 ids at +0x16 of its type record
// (0x43ac50 on the type table *0xaa9564, (type), ret 4; read by
// cCreatureArts::ChoosableSkills 0x580f30). Null without a record.
constexpr int kClassSkills = 16;
inline const uint8_t* ClassSkills(const uint8_t* arts) {
using Fn = const uint8_t*(__fastcall*)(void*, void*, uint32_t);
void* table = *reinterpret_cast<void**>(0x00aa9564);
const uint8_t* record =
table ? reinterpret_cast<Fn>(0x0043ac50)(table, nullptr, *reinterpret_cast<const uint32_t*>(arts + kArtsCreatureType))
: nullptr;
return record ? record + 0x16 : nullptr;
}
// A skill curve of the balance settings (globals parsed from "key=value"
// around 0x5eb4xx: "<k>off" start, "<k>_s" half, "<k>_w" limit): 0 below level
// 1, else start + 2 (limit - start) (1 - 1 / ((level - 1) / half + 1)), in
// percent (DiminishingCurve 0x565c30).
struct SkillCurve {
float start;
float half;
float limit;
};
inline float CurveValue(uint32_t curveAddress, unsigned level) {
const auto& curve = *reinterpret_cast<const SkillCurve*>(curveAddress);
if (level < 1) {
return 0.0f;
}
const float t = 1.0f - 1.0f / ((static_cast<float>(level) - 1.0f) / curve.half + 1.0f);
return 2.0f * (curve.limit - curve.start) * t + curve.start;
}
constexpr uint32_t kCurveWaffenkunde = 0x00ad2450; // WK
constexpr uint32_t kCurveMagiekunde = 0x00ad245c; // MK
constexpr uint32_t kCurveFaustkampfValue = 0x00ad2468; // FK..AW, then ..SP
constexpr uint32_t kCurveSchwertkundeValue = 0x00ad2480; // SK
constexpr uint32_t kCurveAxtkampfValue = 0x00ad2498; // AK
constexpr uint32_t kCurveKlingenkampfValue = 0x00ad24b0; // KK
constexpr uint32_t kCurveStangenwaffenValue = 0x00ad24c8; // STK
constexpr uint32_t kCurveZweiWaffenValue = 0x00ad24e0; // BK
constexpr uint32_t kCurveFernkampfValue = 0x00ad24f8; // FEK
constexpr uint32_t kCurveExecutionSpeed = 0x00ad2504; // FEK..SP: also the magic schools' speed bonus
constexpr uint32_t kCurveHimmelsmagie = 0x00ad2510; // HM
constexpr uint32_t kCurveFeuermagie = 0x00ad251c; // FM
constexpr uint32_t kCurveErdmagie = 0x00ad2528; // EM
constexpr uint32_t kCurveWassermagie = 0x00ad2534; // WM
constexpr uint32_t kCurveLuftmagie = 0x00ad2540; // LM
constexpr uint32_t kCurveMondmagie = 0x00ad254c; // MM
constexpr uint32_t kCurveBallistik = 0x00ad2558; // BA
constexpr uint32_t kCurveBlutrausch = 0x00ad2564; // BL
constexpr uint32_t kCurveKonzentration = 0x00ad2570; // KO
constexpr uint32_t kCurveMeditation = 0x00ad257c; // Med
constexpr uint32_t kCurveWendigkeitValue = 0x00ad2588; // W..AW
constexpr uint32_t kCurveHoellenmacht = 0x00ad368c; // HOM
constexpr uint32_t kCurveWaffentechnik = 0x00ad3698; // WT
// Weapon skills have two curves in a row: attack value (AW), then speed (SP).
constexpr uint32_t kCurveSpeedOffset = 0xc;
// Balance settings for the attributes' share of the damage factors
// (Recalculate): BalStatOff and the divisors BalCharPD / FD / MD / GD.
constexpr uint32_t kBalStatOff = 0x00ad242c;
constexpr uint32_t kBalCharDamage[4] = {0x00ad2430, 0x00ad2434, 0x00ad2438, 0x00ad243c};
inline float BalanceValue(uint32_t address) {
return *reinterpret_cast<const float*>(address);
}
// cCreature::HoldsRangedWeapon (0x54a110, plain ret): Recalculate takes
// Geschick instead of Stärke for the physical damage then.
inline bool HoldsRangedWeapon(void* creature) {
using Fn = bool(__fastcall*)(void*, void*);
return reinterpret_cast<Fn>(0x0054a110)(creature, nullptr);
}
} // namespace game
+23
View File
@@ -126,6 +126,29 @@ std::wstring GameText(uint32_t id) {
: std::wstring();
}
std::wstring GameTextByKey(const char* key) {
// The key's hash (0x80e010, cdecl), looked up "by key" as GetTextByKey does.
using HashFn = uint32_t(__cdecl*)(const char*);
const uint32_t hash = reinterpret_cast<HashFn>(0x0080e010)(key);
return hash ? GameText(hash | 0x80000000) : std::wstring();
}
std::wstring WideStringText(const void* string) {
const auto* s = static_cast<const GameString*>(string);
return s && s->begin && s->end > s->begin ? std::wstring(s->begin, s->end) : std::wstring();
}
void SetWideString(void* string, const std::wstring& value) {
auto* s = static_cast<GameString*>(string);
if (s->begin) {
s->end = s->begin;
*const_cast<wchar_t*>(s->begin) = L'\0';
}
// The game's wide string append (0x592ef0, thiscall (first, last, unused), ret 0xc).
using AppendFn = void*(__fastcall*)(void*, void*, const wchar_t*, const wchar_t*, uint32_t);
reinterpret_cast<AppendFn>(0x00592ef0)(string, nullptr, value.data(), value.data() + value.size(), 0);
}
const void* __fastcall TextManager_GetText(void* self, void* edx, uint32_t id) {
if (id >= kFirstOwnText && id < 0x80000000) {
std::lock_guard guard(g_lock);
+9
View File
@@ -23,4 +23,13 @@ void ReplaceInTexts(const std::wstring& from, const std::wstring& to);
// The game's text `id` without our changes (empty if there is none).
std::wstring GameText(uint32_t id);
// The game's text under key `key` (TextManager::GetTextByKey 0x672fd0), e.g.
// "UI_ATTRIB_STRENGTH"; empty if there is none.
std::wstring GameTextByKey(const char* key);
// A game wide string ({begin, end, capacity end}, e.g. a tooltip's text) as
// text, and replaced by `value` (grown with the game's allocator).
std::wstring WideStringText(const void* string);
void SetWideString(void* string, const std::wstring& value);
} // namespace game
+110
View File
@@ -0,0 +1,110 @@
// Art tooltips name what improves the art (request of 2026-10-18: "edit ALL
// skill tooltips, to show which attributes and abilities improve them in
// which way"; then: arts as objects with their effects, name and tooltip,
// lines like "Damage improved by [Axe Lore, Strength, ...]").
//
// cCreatureArts::ArtTooltip (0x589270, thiscall on creature +0x3a8, (art,
// text, flag), ret 0xc) writes the game's description and values into a wide
// string; game::Art::Tooltip (game/engine/art.cpp) applies the mods' changes
// and adds "Verbessert durch:" with one line per skill or attribute acting on
// the art, one line per effect sorted by size, e.g. "Stärke -> Schaden +12%"
// (unlearned skills greyed out with 0%). Combos (0x200..0x204) get no text
// from the game. Art tooltips wrap at a wider line (below).
#include <cstdint>
#include <cstring>
#include <string>
#include <string_view>
#include "game/engine/art.h"
#include "game/engine/arts.h"
#include "game/ui/texts.h"
#include "re/hook.h"
#include "re/log.h"
#include "re/patch.h"
namespace mods::arttooltip {
namespace {
void __fastcall Arts_Tooltip(void* self, void* edx, uint32_t art, void* text, uint32_t flag);
RE_HOOK(0x00589270, Arts_Tooltip);
void __fastcall Arts_Tooltip(void* self, void* edx, uint32_t art, void* text, uint32_t flag) {
Arts_Tooltip_original(self, edx, art, text, flag);
const std::wstring gameText = game::WideStringText(text);
const game::Art* object = game::FindArt(static_cast<uint16_t>(art));
if (gameText.empty() || !object) {
return;
}
void* creature = static_cast<uint8_t*>(self) - game::kCreatureArts;
game::SetWideString(text, object->Tooltip(creature, gameText));
}
// Line wrap. With flag 0x80 (+0x154) cUI_Tooltip::FormatText (0x6e6630,
// thiscall (char), ret 4; called by SetText 0x6e6ae0) copies the text
// (+0x115c begin, +0x1160 end) and turns the first space after 45 characters
// of a line into a line break (the check `cmp ecx, 5Ah` on the line's bytes at
// 0x6e687d); the width then follows the longest line. Colour codes counted as
// characters there. Now they don't, and art tooltips wrap later, so the
// "Verbessert durch" lines stay whole.
constexpr int kGameWrapChars = 45;
constexpr int kArtWrapChars = 56;
constexpr size_t kTooltipText = 0x115c;
constexpr size_t kColourCodeLength = 10; // "\cAARRGGBB"
int g_wrapChars = kGameWrapChars;
void __fastcall Tooltip_FormatText(void* self, void* edx, char flag);
RE_HOOK(0x006e6630, Tooltip_FormatText);
void __fastcall Tooltip_FormatText(void* self, void* edx, char flag) {
auto* const* text = reinterpret_cast<const wchar_t* const*>(static_cast<uint8_t*>(self) + kTooltipText);
const bool art = text[0] && std::wstring_view(text[0], text[1] - text[0]).find(game::kImprovedByHeader) !=
std::wstring_view::npos;
g_wrapChars = art ? kArtWrapChars : kGameWrapChars;
Tooltip_FormatText_original(self, edx, flag);
g_wrapChars = kGameWrapChars;
}
// Characters of the line [start, current) beyond the wrap width, colour codes not counted.
int __cdecl WrapExcess(const wchar_t* start, const wchar_t* current) {
int chars = 0;
for (const wchar_t* c = start; c < current; ++c, ++chars) {
if (c[0] == L'\\' && c[1] == L'c') {
c += kColourCodeLength - 1;
--chars;
}
}
return chars - g_wrapChars;
}
// Replaces `mov ecx, edi; sub ecx, esi; and ecx, -2; cmp ecx, 5Ah` (line start
// in esi, current character in edi, the character in ax); the `jle` after it
// keeps the line going.
constexpr uint32_t kWrapCheck = 0x006e6876;
__declspec(naked) void WrapCheckThunk() {
__asm {
push eax
push edx
push edi
push esi
call WrapExcess
add esp, 8
mov ecx, eax
pop edx
pop eax
cmp ecx, 0
ret
}
}
void Startup() {
const int32_t rel = static_cast<int32_t>(reinterpret_cast<uintptr_t>(&WrapCheckThunk) - (kWrapCheck + 5));
uint8_t to[10] = {0xe8, 0, 0, 0, 0, 0x90, 0x90, 0x90, 0x90, 0x90};
std::memcpy(to + 1, &rel, 4);
const uint8_t from[10] = {0x8b, 0xcf, 0x2b, 0xce, 0x83, 0xe1, 0xfe, 0x83, 0xf9, 0x5a};
const bool ok = re::ReplaceCode(kWrapCheck, from, to, sizeof(to));
re::Log("arttooltip: tooltip wrap ignores colour codes, art tooltips %d characters wide%s", kArtWrapChars,
ok ? "" : " (patch FAILED)");
}
RE_STARTUP(ArtTooltipStartup, Startup);
} // namespace
} // namespace mods::arttooltip
+8 -35
View File
@@ -34,6 +34,7 @@
#include <windows.h>
#include "game/engine/art.h"
#include "game/engine/arts.h"
#include "game/engine/fx.h"
#include "game/engine/objects.h"
@@ -373,8 +374,7 @@ uint32_t __fastcall NextTarget(void* manager, void*, const int32_t* position, ui
// The tooltip: the game's "Anzahl: X" line (its hit count) becomes the mod's
// two numbers, hits per chain and chains, in both the current level's section
// and the next level's (skill book). cCreatureArts::ArtTooltip (0x589270,
// thiscall on creature +0x3a8, (art, text, flag), ret 0xc) builds the text
// into a wide string {begin, end, capacity}; sections start with the level
// mods/arttooltip.cpp, game::Art::Tooltip) builds the text; sections start with the level
// line, values are "Name: value" lines separated by '\n' (measured for art
// 0x21 at level 5: "...|Aktuelle Stufe: 5|Regeneration: 3.5s|Schaden: ...|
// Anzahl: 7", with flag 1 also "Nächste erlernte Stufe: 6" and its values).
@@ -386,22 +386,6 @@ constexpr const wchar_t* kCurrentLevelLine = L"Aktuelle Stufe:";
constexpr const wchar_t* kNextLevelLine = L"Nächste erlernte Stufe:";
constexpr const wchar_t* kCountLine = L"Anzahl:";
// The game's wide string append (0x592ef0, thiscall (first, last, unused),
// ret 0xc); grows the buffer with the game's allocator.
void AppendText(void* text, const wchar_t* first, const wchar_t* last) {
using Fn = void*(__fastcall*)(void*, void*, const wchar_t*, const wchar_t*, uint32_t);
reinterpret_cast<Fn>(0x00592ef0)(text, nullptr, first, last, 0);
}
void SetText(void* text, const std::wstring& value) {
auto** string = static_cast<wchar_t**>(text);
if (string[0]) {
string[1] = string[0];
*string[0] = L'\0';
}
AppendText(text, value.data(), value.data() + value.size());
}
std::wstring Rewrite(const std::wstring& text, unsigned currentLevel) {
std::wstring out;
unsigned level = currentLevel;
@@ -431,24 +415,13 @@ std::wstring Rewrite(const std::wstring& text, unsigned currentLevel) {
return out;
}
void __fastcall Arts_Tooltip(void* self, void* edx, uint32_t art, void* text, uint32_t flag);
RE_HOOK(0x00589270, Arts_Tooltip);
void __fastcall Arts_Tooltip(void* self, void* edx, uint32_t art, void* text, uint32_t flag) {
Arts_Tooltip_original(self, edx, art, text, flag);
if (!text || static_cast<uint16_t>(art) != kArtBlitzschlag) {
return;
}
auto* const* string = static_cast<wchar_t* const*>(text);
if (!string[0] || string[1] <= string[0]) {
return;
}
void* creature = static_cast<uint8_t*>(self) - game::kCreatureArts;
const std::wstring before(string[0], string[1]);
const std::wstring after = Rewrite(before, ArtLevel(creature, kArtBlitzschlag));
if (after != before) {
SetText(text, after);
}
void RewriteTooltip(const game::Art& art, void* creature, std::wstring& text) {
text = Rewrite(text, ArtLevel(creature, art.Id()));
}
struct TooltipRegistration {
TooltipRegistration() { game::AddTooltipFilter(kArtBlitzschlag, &RewriteTooltip); }
} g_tooltipRegistration;
} // namespace
} // namespace mods::chainlightning
+9 -8
View File
@@ -29,6 +29,7 @@
#include "game/engine/arts.h"
#include "game/engine/objects.h"
#include "game/engine/skills.h"
#include "game/ui/events.h"
#include "game/ui/text.h"
#include "mods/hud.h"
@@ -46,7 +47,7 @@ constexpr size_t kPlusButtons = 0x2cc;
constexpr size_t kMinusButtons = 0x8ac; // unused by the game
constexpr size_t kButtonSize = 0xbc;
constexpr int kRows = 8;
constexpr int kAttributeRows = 6;
constexpr int kAttributeRows = game::kAttributeCount;
// cUI_Button (vtable 0x895308).
constexpr size_t kControlFlags = 0x10;
@@ -85,13 +86,13 @@ constexpr float kIconSize = 16.0f;
// The − sits left of the value column (values end at the + at x 232).
constexpr int kMinusX = 184;
// cCreatureArts fields (see the top).
constexpr size_t kAttributes = 0x1c;
constexpr int kFreeAttributesIndex = 7;
constexpr size_t kSkillIds = 0x24;
constexpr size_t kSkillLevels = 0x2c;
constexpr size_t kSkillBonus = 0x34;
constexpr size_t kSkillChoices = 0x44;
// cCreatureArts fields (see the top, game/engine/skills.h).
using game::kFreeAttributesIndex;
constexpr size_t kAttributes = game::kArtsInvested;
constexpr size_t kSkillIds = game::kArtsSkillIds;
constexpr size_t kSkillLevels = game::kArtsSkillLevels;
constexpr size_t kSkillBonus = game::kArtsSkillBonus;
constexpr size_t kSkillChoices = game::kArtsSkillChoices;
using AddAttributeFn = void(__fastcall*)(void* arts, void*, int index, int delta);
using AddSkillLevelsFn = void(__fastcall*)(void* arts, void*, int row, int delta, int spendPoints);