From 075f9e2c8a7845f3dcb4290b0f61ada7042ebd71 Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Sun, 18 Oct 2026 00:43:03 +0200 Subject: [PATCH] Combos: cooldown labels on the skill bar, steps follow the arts' current levels Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 1 + docs/combos.md | 37 ++++++++++++++++ src/game/engine/arts.h | 53 +++++++++++++++++++++++ src/mods/combos.cpp | 95 ++++++++++++++++++++++++++++++++++++++++++ src/mods/skillbar.cpp | 11 +++-- tools/names.txt | 6 +++ 6 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 docs/combos.md create mode 100644 src/mods/combos.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ebfec3c..e4f961d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -55,6 +55,7 @@ add_library(sacred_re SHARED src/mods/autoattack.cpp src/mods/aurarecast.cpp src/mods/artregen.cpp + src/mods/combos.cpp src/mods/aurahits.cpp src/mods/chainlightning.cpp src/mods/escape.cpp diff --git a/docs/combos.md b/docs/combos.md new file mode 100644 index 0000000..57e5b6b --- /dev/null +++ b/docs/combos.md @@ -0,0 +1,37 @@ +# Combos + +## Request (2026-10-18) + +Combos don't show their cooldown like other skills. Combos should reference +the skills they are built from and always use those skills' current levels. + +## Measured / read + +- Combos live in a vector of 0x26-byte entries at cCreatureArts +0x10e / + +0x112, at most 4 (`cCreatureArts::FindCombo` 0x5819a0). Layout in + `src/game/engine/arts.h`: id (0x200..0x204), 4 step arts, 4 step levels, + total, charge rate, charge. +- `cCreatureArts::SetCombo` (0x581290, thiscall `(index, arts[4], levels[4], + id)`, ret 0x10) fills an entry and computes the total from the levels; the + charge starts at 0. The Kombomeister calls it when a combo is made and the + levels are never updated afterwards. +- The charge counts up to the total at the charge rate (recomputed while + charging); ready when charge >= total (`IsComboReady` 0x581920). +- A taskbar slot of type 4 holds combo 0x200 + index. +- Logged 2026-10-18: a combo made with an art at level 3 stores 3; after the + art rose to 4 the mod re-set it to 4 and the total followed. + +## Mod + +- `skillbar.cpp`: combo slots get the seconds label like other arts, + `game::GetComboCooldown` = (total - charge) / rate. +- `combos.cpp`: every engine tick, the hero's combos are compared with the + arts' current levels (cooldown entry level + bonus levels). Out of date → + `SetCombo` again with the current levels; the charge keeps its fraction of + the total. Skipped while a combo is executing. + +## Open + +- Whether the Kombomeister stores base or base + item bonus levels is not + measured (no art with bonus levels in the test). The mod uses base + bonus; + if the cast also adds the bonus it would count twice. diff --git a/src/game/engine/arts.h b/src/game/engine/arts.h index fad83a6..8cad1e4 100644 --- a/src/game/engine/arts.h +++ b/src/game/engine/arts.h @@ -156,6 +156,59 @@ inline ArtCooldown GetArtCooldown(void* creature, uint16_t art) { return {remaining, Float(entry, kCooldownTotal), perSecond > 0.0f ? remaining / perSecond : 0.0f}; } +// Combos (made at the Kombomeister): a vector of 0x26-byte entries at +0x10e / +// +0x112 of cCreatureArts, at most 4 (cCreatureArts::FindCombo 0x5819a0): +// +0 u16 combo id (0x200..0x204, definitions FindComboDef 0x4d2970) +// +2 u32[4] art of each step (0: empty) +// +0x12 u16[4] level of each step's art, stored when the combo was made +// +0x1a float total (sum of the arts' regeneration at those levels) +// +0x1e float charge rate, recomputed each update while charging: +// 1 + average of the arts' regeneration speed % / 100 +// +0x22 float charge: counts up to the total (ready at total, +// cCreatureArts::IsComboReady 0x581920); using the combo +// lowers it (cCreatureArts::AddGroupRegen, combo branch) +// cCreatureArts::SetCombo (0x581290, (index, arts[4], levels[4], id), ret +// 0x10) fills an entry and computes the total; the charge starts at 0. +// A taskbar slot of type 4 holds combo 0x200 + index. +constexpr size_t kCombosBegin = 0x10e; +constexpr size_t kCombosEnd = 0x112; +constexpr size_t kComboSize = 0x26; +constexpr size_t kComboId = 0x0; +constexpr size_t kComboArts = 0x2; +constexpr size_t kComboLevels = 0x12; +constexpr size_t kComboTotal = 0x1a; +constexpr size_t kComboRate = 0x1e; +constexpr size_t kComboCharge = 0x22; +constexpr unsigned kComboSteps = 4; +constexpr uint32_t kComboRefBase = 0x200; +constexpr uint32_t kArtSlotTypeCombo = 4; + +// Entry `index` of the combo vector of cCreatureArts `arts`, or null. +inline uint8_t* ComboEntry(uint8_t* arts, unsigned index) { + auto* begin = *reinterpret_cast(arts + kCombosBegin); + auto* end = *reinterpret_cast(arts + kCombosEnd); + if (!begin || index >= static_cast(end - begin) / kComboSize) { + return nullptr; + } + return begin + index * kComboSize; +} + +// The cooldown of the combo in a taskbar slot (reference 0x200 + index). +inline ArtCooldown GetComboCooldown(void* creature, uint32_t slotRef) { + auto* arts = static_cast(creature) + kCreatureArts; + uint8_t* entry = slotRef >= kComboRefBase ? ComboEntry(arts, slotRef - kComboRefBase) : nullptr; + if (!entry) { + return {}; + } + const float total = Float(entry, kComboTotal); + const float remaining = total - Float(entry, kComboCharge); + if (remaining <= 0.0f) { + return {0.0f, total, 0.0f}; + } + const float rate = Float(entry, kComboRate); + return {remaining, total, rate > 0.0f ? remaining / rate : 0.0f}; +} + // 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); diff --git a/src/mods/combos.cpp b/src/mods/combos.cpp new file mode 100644 index 0000000..5e5999d --- /dev/null +++ b/src/mods/combos.cpp @@ -0,0 +1,95 @@ +// Combos use the current levels of the arts they are made of (request of +// 2026-10-18). +// +// A combo (cCreatureArts +0x10e, layout in game/engine/arts.h) keeps the arts +// of its steps and a copy of their levels from when it was made at the +// Kombomeister; its regeneration total and the steps follow those levels, so +// raising an art later does not change the combo. Every engine tick the +// hero's combos are compared with the arts' current levels (cooldown entry +// level + bonus levels); a combo whose levels are out of date is set again +// with the game's own cCreatureArts::SetCombo (0x581290), which recomputes the +// total. The charge keeps its fraction of the total. + +#include +#include + +#include "game/engine/arts.h" +#include "game/engine/events.h" +#include "game/engine/objects.h" +#include "re/log.h" + +namespace game { + +// cCreatureArts::SetCombo (0x581290, ret 0x10). +using SetComboFn = void(__fastcall*)(void* arts, void* edx, uint32_t index, const uint32_t* stepArts, + const uint16_t* levels, uint32_t id); + +} // namespace game + +namespace mods::combos { +namespace { + +using namespace game; // combo and cooldown layout (arts.h) + +// The art's level now (cooldown entry level + bonus levels), 0 if unknown. +uint16_t CurrentLevel(uint8_t* arts, uint32_t art) { + uint8_t* entry = art ? FindCooldownEntry(arts, static_cast(art)) : nullptr; + return entry ? static_cast(entry[kCooldownLevel] + entry[kCooldownBonusLevels]) : 0; +} + +void SyncCombos(uint8_t* hero) { + uint8_t* arts = hero + kCreatureArts; + if (hero[kCreatureComboStep] != 0 || arts[kArtsComboRunning] != 0) { + return; // not while a combo is being executed + } + for (unsigned index = 0;; ++index) { + uint8_t* combo = ComboEntry(arts, index); + if (!combo) { + return; + } + const uint16_t id = *reinterpret_cast(combo + kComboId); + if (id == 0) { + continue; + } + uint32_t stepArts[kComboSteps]; + uint16_t levels[kComboSteps]; + std::memcpy(stepArts, combo + kComboArts, sizeof(stepArts)); + std::memcpy(levels, combo + kComboLevels, sizeof(levels)); + bool changed = false; + for (unsigned step = 0; step < kComboSteps; ++step) { + const uint16_t level = CurrentLevel(arts, stepArts[step]); + if (level != 0 && level != levels[step]) { // unknown arts keep their level + levels[step] = level; + changed = true; + } + } + if (!changed) { + continue; + } + const float oldTotal = Float(combo, kComboTotal); + const float fraction = oldTotal > 0.0f ? Float(combo, kComboCharge) / oldTotal : 1.0f; + reinterpret_cast(0x00581290)(arts, nullptr, index, stepArts, levels, id); + combo = ComboEntry(arts, index); // SetCombo does not reallocate, but stay safe + if (combo) { + Float(combo, kComboCharge) = fraction * Float(combo, kComboTotal); + re::Log("combos: combo %u set to the current levels %u/%u/%u/%u", index, levels[0], levels[1], levels[2], + levels[3]); + } + } +} + +void OnTick(uint8_t*) { + if (auto* hero = static_cast(GetPlayerHero())) { + SyncCombos(hero); + } +} + +struct Registration { + Registration() { + OnEngineTick(&OnTick); + } +} g_registration; + +} // namespace + +} // namespace mods::combos diff --git a/src/mods/skillbar.cpp b/src/mods/skillbar.cpp index c42a7ed..740ae6f 100644 --- a/src/mods/skillbar.cpp +++ b/src/mods/skillbar.cpp @@ -20,6 +20,7 @@ // // Recharging arts show their remaining cooldown (seconds, one decimal) on the // slot, drawn with the game's font after RenderArtSlots. +// Combos too, from their charge (game::GetComboCooldown). // // Also: 7 entries in the slot-count-by-level table (6 at level 45, 7 at 65), // and cCreature::AssignArtToSlot accepts slots up to 6. @@ -184,7 +185,8 @@ void UpdateLabelRects(uint8_t* const* skills) { // The remaining cooldown in seconds (at the hero's current regeneration // rate, game::GetArtCooldown), to one decimal, centred on each art slot that -// is recharging (combat moves and spells: slot types 2 / 3). +// is recharging (combat moves and spells: slot types 2 / 3; combos: 4, +// game::GetComboCooldown). void RenderCooldowns(void* taskbar, void* renderer) { auto* p = static_cast(taskbar); uint8_t* inventory = skillslots::Inventory(*reinterpret_cast(p + kInventoryIndex)); @@ -203,11 +205,12 @@ void RenderCooldowns(void* taskbar, void* renderer) { for (unsigned slot = 0; slot < kSlots && slot < shown; ++slot) { const uint32_t type = getType(inventory, nullptr, slot); const uint32_t ref = getRef(inventory, nullptr, slot); - if (!skills[slot] || (type != 2 && type != 3) || !ref) { + if (!skills[slot] || (type != 2 && type != 3 && type != game::kArtSlotTypeCombo) || !ref) { continue; } - const uint16_t art = game::ResolveArtId(hero, ref); - const game::ArtCooldown cooldown = game::GetArtCooldown(hero, art); + const game::ArtCooldown cooldown = type == game::kArtSlotTypeCombo + ? game::GetComboCooldown(hero, ref) + : game::GetArtCooldown(hero, game::ResolveArtId(hero, ref)); if (cooldown.remaining <= game::kArtReadyCooldown) { continue; // ready: no label, the game's icon shows it } diff --git a/tools/names.txt b/tools/names.txt index 47d2823..dcc66ae 100644 --- a/tools/names.txt +++ b/tools/names.txt @@ -252,3 +252,9 @@ 0x00553080 cEvent_UI::cEvent_UI # (type, target, sender, a..f), ret 0x24 0x00428700 cItemTypeTable::NameText # (type), ret 4: text id of the type's name (+0x20) 0x0054a8f0 cCreature::UseItem # (id), ret 4: event 0x10 to the item, removes it if used up +0x00581290 cCreatureArts::SetCombo # (index, arts[4], levels[4], id), ret 0x10: fills a combo, computes its total +0x005818e0 cCreatureArts::ComboChargeFraction # (index) -> charge / total, max 1 +0x00581920 cCreatureArts::IsComboReady # (index): total <= charge +0x005819a0 cCreatureArts::FindCombo # (id) -> index, 4 if none +0x00581410 cCreatureArts::ComboTooltip # (index, text, mode) +0x004d2970 FindComboDef # (0x200..0x204) -> 0x30-byte entry at 0x953348