Combos: cooldown labels on the skill bar, steps follow the arts' current levels
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5a0db24b83
commit
075f9e2c8a
6 files changed
+199
-4
No files matched your search
@@ -55,6 +55,7 @@ add_library(sacred_re SHARED
|
|||||||
src/mods/autoattack.cpp
|
src/mods/autoattack.cpp
|
||||||
src/mods/aurarecast.cpp
|
src/mods/aurarecast.cpp
|
||||||
src/mods/artregen.cpp
|
src/mods/artregen.cpp
|
||||||
|
src/mods/combos.cpp
|
||||||
src/mods/aurahits.cpp
|
src/mods/aurahits.cpp
|
||||||
src/mods/chainlightning.cpp
|
src/mods/chainlightning.cpp
|
||||||
src/mods/escape.cpp
|
src/mods/escape.cpp
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -156,6 +156,59 @@ inline ArtCooldown GetArtCooldown(void* creature, uint16_t art) {
|
|||||||
return {remaining, Float(entry, kCooldownTotal), perSecond > 0.0f ? remaining / perSecond : 0.0f};
|
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<uint8_t**>(arts + kCombosBegin);
|
||||||
|
auto* end = *reinterpret_cast<uint8_t**>(arts + kCombosEnd);
|
||||||
|
if (!begin || index >= static_cast<unsigned>(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<uint8_t*>(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`.
|
// cCreature::IsAlly (0x548f20): whether object `id` is an ally of `creature`.
|
||||||
inline bool IsAlly(void* creature, uint32_t id) {
|
inline bool IsAlly(void* creature, uint32_t id) {
|
||||||
using Fn = char(__fastcall*)(void*, void*, uint32_t);
|
using Fn = char(__fastcall*)(void*, void*, uint32_t);
|
||||||
|
|||||||
@@ -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 <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#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<uint16_t>(art)) : nullptr;
|
||||||
|
return entry ? static_cast<uint16_t>(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<uint16_t*>(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<SetComboFn>(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<uint8_t*>(GetPlayerHero())) {
|
||||||
|
SyncCombos(hero);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Registration {
|
||||||
|
Registration() {
|
||||||
|
OnEngineTick(&OnTick);
|
||||||
|
}
|
||||||
|
} g_registration;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
} // namespace mods::combos
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
//
|
//
|
||||||
// Recharging arts show their remaining cooldown (seconds, one decimal) on the
|
// Recharging arts show their remaining cooldown (seconds, one decimal) on the
|
||||||
// slot, drawn with the game's font after RenderArtSlots.
|
// 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),
|
// 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.
|
// 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
|
// The remaining cooldown in seconds (at the hero's current regeneration
|
||||||
// rate, game::GetArtCooldown), to one decimal, centred on each art slot that
|
// 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) {
|
void RenderCooldowns(void* taskbar, void* renderer) {
|
||||||
auto* p = static_cast<uint8_t*>(taskbar);
|
auto* p = static_cast<uint8_t*>(taskbar);
|
||||||
uint8_t* inventory = skillslots::Inventory(*reinterpret_cast<const uint16_t*>(p + kInventoryIndex));
|
uint8_t* inventory = skillslots::Inventory(*reinterpret_cast<const uint16_t*>(p + kInventoryIndex));
|
||||||
@@ -203,11 +205,12 @@ void RenderCooldowns(void* taskbar, void* renderer) {
|
|||||||
for (unsigned slot = 0; slot < kSlots && slot < shown; ++slot) {
|
for (unsigned slot = 0; slot < kSlots && slot < shown; ++slot) {
|
||||||
const uint32_t type = getType(inventory, nullptr, slot);
|
const uint32_t type = getType(inventory, nullptr, slot);
|
||||||
const uint32_t ref = getRef(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;
|
continue;
|
||||||
}
|
}
|
||||||
const uint16_t art = game::ResolveArtId(hero, ref);
|
const game::ArtCooldown cooldown = type == game::kArtSlotTypeCombo
|
||||||
const game::ArtCooldown cooldown = game::GetArtCooldown(hero, art);
|
? game::GetComboCooldown(hero, ref)
|
||||||
|
: game::GetArtCooldown(hero, game::ResolveArtId(hero, ref));
|
||||||
if (cooldown.remaining <= game::kArtReadyCooldown) {
|
if (cooldown.remaining <= game::kArtReadyCooldown) {
|
||||||
continue; // ready: no label, the game's icon shows it
|
continue; // ready: no label, the game's icon shows it
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,3 +252,9 @@
|
|||||||
0x00553080 cEvent_UI::cEvent_UI # (type, target, sender, a..f), ret 0x24
|
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)
|
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
|
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
|
||||||
Reference in new issue
Block a user