// − buttons on the character panel give back single invested points (request // of 2026-10-16). // // The panel (cUI_StatisticsChar, part of the inventory) shows the attributes // or the skills (Fertigkeiten, panel +0x164 bit 0x10 set: attributes) of the // player hero with a + button per row while points are free. The hero keeps // (in cCreatureArts, hero +0x3a8, measured 2026-10-16): // +0x1c[6] attribute points invested (the shown values add class and level) // +0x23 free attribute points (index 7 of the same array) // +0x24[8] skill ids by row, +0x2c[8] their bought levels, +0x34[8] item bonus // +0x42 free skill points, +0x44 new skills still to choose // A − button is shown left of the value on each row with points invested. // Attributes: 0x5a03a0 (index, delta) moves one point back to the free ones, as // the + does (0x580d50). Skills above level 1: 0x582360 (row, -1, spend) gives // back a skill point. Level 1 (the level a skill is chosen with): the skill is // removed and a new skill can be chosen again (+0x44), with the game's own // choice. Both go through the game's functions, which also update its // protected copy of these fields (0x182cddc) and recalculate the hero. // // The buttons are the panel's second array of 8 buttons (+0x8ac), which the // game constructs and destroys but never lays out or uses; they show the + // icon with its vertical arm covered (no minus image exists). See // docs/respec.md. #include #include #include #include "game/engine/arts.h" #include "game/engine/objects.h" #include "game/engine/skills.h" #include "game/ui/draw.h" #include "game/ui/events.h" #include "game/ui/text.h" #include "mods/hud.h" #include "re/hook.h" #include "re/log.h" namespace mods::respec { namespace { // cUI_StatisticsChar. constexpr size_t kPanelFlags = 0x164; constexpr uint32_t kPanelShowsAttributes = 0x10; constexpr size_t kPanelChoosingSkill = 0xf84; // u8: picking a new skill 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 = game::kAttributeCount; // cUI_Button (vtable 0x895308). constexpr size_t kControlFlags = 0x10; constexpr uint32_t kControlVisible = 1; constexpr size_t kControlRect = 0x24; // x, y (int32), width, height (int16) constexpr size_t kControlParent = 0x50; // Images (render 0x72af90): u0, u1, v0, v1 floats and a texture id; the // normal one, and the one shown while pressed (flags bit 0x10). constexpr size_t kButtonImage = 0x64; constexpr size_t kButtonTexture = 0x84; constexpr size_t kButtonPressedImage = 0x54; constexpr size_t kButtonPressedTexture = 0x80; constexpr int kControlRender = 5; // vf14 (device) constexpr int kControlHitTest = 7; // vf1c (x, y): inside and visible parts // Image descriptors (static, 0x54 bytes): texture at +0x28, u0, v0, u1, v1 at // +0x2c..+0x38 (the panel copies them in the order u0, u1, v0, v1, 0x6ac120). // The + of the rows: 0x9ed2d0, pressed 0x9ed324 (16x16 in the UI atlas). The // game registers UI_BUTT_MINUS_UP/DW names but has no minus image in its UI // atlases (all drawn once and looked at, 2026-10-16), so the − is the + with // the vertical arm of its cross covered by two patches in the disc's grey. constexpr uint32_t kPlusImage = 0x009ed2d0; constexpr uint32_t kPlusPressedImage = 0x009ed324; constexpr size_t kImageTexture = 0x28; constexpr size_t kImageUvOrder[4] = {0x2c, 0x34, 0x30, 0x38}; // The cross's vertical arm in the 16x16 icon (measured in a screenshot, in // icon pixels): x 6.5..8.8, above the bar y 4.0..6.9, below it y 9.0..11.5 // (the bar: y 6.9..8.9). The disc around it averages grey 55. struct Patch { float x0, y0, x1, y1; }; constexpr Patch kArmPatches[] = {{6.3f, 3.8f, 9.0f, 6.95f}, {6.3f, 8.95f, 9.0f, 11.8f}}; constexpr uint32_t kDiscGrey = 0xff37373a; 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, 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); using RecalculateFn = void(__fastcall*)(void* arts, void*, int, int); // as the + calls it: (0, 1) const auto AddAttribute = reinterpret_cast(0x005a03a0); const auto AddSkillLevels = reinterpret_cast(0x00582360); const auto Recalculate = reinterpret_cast(0x00579560); uint8_t* Button(uint8_t* panel, size_t array, int row) { return panel + array + row * kButtonSize; } bool ShowsAttributes(uint8_t* panel) { return (*reinterpret_cast(panel + kPanelFlags) & kPanelShowsAttributes) != 0; } uint8_t* HeroArts() { auto* hero = static_cast(game::GetPlayerHero()); return hero ? hero + game::kCreatureArts : nullptr; } // Whether `row` has a point the player can take back. bool CanRevert(uint8_t* panel, uint8_t* arts, int row) { if (panel[kPanelChoosingSkill]) { return false; } if (ShowsAttributes(panel)) { return row < kAttributeRows && arts[kAttributes + row] > 0; } return arts[kSkillIds + row] != 0 && arts[kSkillLevels + row] > 0; } void CopyImage(uint8_t* button, size_t uvs, size_t texture, uint32_t image) { const auto* descriptor = reinterpret_cast(image); *reinterpret_cast(button + texture) = *reinterpret_cast(descriptor + kImageTexture); for (int i = 0; i < 4; ++i) { *reinterpret_cast(button + uvs + 4 * i) = *reinterpret_cast(descriptor + kImageUvOrder[i]); } } bool Visible(uint8_t* control) { return (*reinterpret_cast(control + kControlFlags) & kControlVisible) != 0; } // Places the − of each row like its + (the panel lays the + out once per // font), shows it where a point can be taken back. void Update(uint8_t* panel, uint8_t* arts) { for (int row = 0; row < kRows; ++row) { uint8_t* plus = Button(panel, kPlusButtons, row); uint8_t* minus = Button(panel, kMinusButtons, row); if (*reinterpret_cast(minus + kControlParent) != panel) { *reinterpret_cast(minus + kControlParent) = panel; CopyImage(minus, kButtonImage, kButtonTexture, kPlusImage); CopyImage(minus, kButtonPressedImage, kButtonPressedTexture, kPlusPressedImage); } std::memcpy(minus + kControlRect, plus + kControlRect, 12); *reinterpret_cast(minus + kControlRect) = kMinusX; auto& flags = *reinterpret_cast(minus + kControlFlags); // The panel renders while hidden too (inventory closed). const bool shown = arts && Visible(panel) && CanRevert(panel, arts, row); flags = shown ? flags | kControlVisible : flags & ~kControlVisible; } } void Revert(uint8_t* panel, uint8_t* arts, int row) { if (ShowsAttributes(panel)) { AddAttribute(arts, nullptr, row, -1); AddAttribute(arts, nullptr, kFreeAttributesIndex, 1); Recalculate(arts, nullptr, 0, 1); re::Log("respec: attribute %d back to %u, %u free", row, arts[kAttributes + row], arts[kAttributes + kFreeAttributesIndex]); return; } if (arts[kSkillLevels + row] > 1) { AddSkillLevels(arts, nullptr, row, -1, 1); re::Log("respec: skill %x back to level %u", arts[kSkillIds + row], arts[kSkillLevels + row]); return; } // Level 1: the skill goes, a new one can be chosen in its place. const uint8_t skill = arts[kSkillIds + row]; arts[kSkillIds + row] = 0; arts[kSkillLevels + row] = 0; arts[kSkillBonus + row] = 0; ++*reinterpret_cast(arts + kSkillChoices); AddSkillLevels(arts, nullptr, row, 0, 1); // updates the protected copy, recalculates re::Log("respec: skill %x removed, %u to choose", skill, *reinterpret_cast(arts + kSkillChoices)); } // Turns the + that `minus` drew into a −. void CoverArm(void* device, uint8_t* minus) { const game::UiRect rect = game::ControlScreenRect(minus); const float sx = rect.w / kIconSize; const float sy = rect.h / kIconSize; for (const Patch& patch : kArmPatches) { game::FillRect(device, rect.x + patch.x0 * sx, rect.y + patch.y0 * sy, rect.x + patch.x1 * sx, rect.y + patch.y1 * sy, kDiscGrey); } } struct RenderCall { uint8_t* panel; void* device; int (__fastcall* original)(void*, void*, void*); }; int RenderBody(void* context) { auto* call = static_cast(context); const int result = call->original(call->panel, nullptr, call->device); Update(call->panel, HeroArts()); for (int row = 0; row < kRows; ++row) { uint8_t* minus = Button(call->panel, kMinusButtons, row); if (Visible(minus)) { using RenderFn = int(__fastcall*)(void*, void*, void*); reinterpret_cast((*reinterpret_cast(minus))[kControlRender])(minus, nullptr, call->device); CoverArm(call->device, minus); } } return result; } struct EventCall { uint8_t* panel; void* event; int (__fastcall* original)(void*, void*, void*); }; int EventBody(void* context) { auto* call = static_cast(context); uint8_t* arts = HeroArts(); if (arts && Visible(call->panel) && game::MouseEventKind(call->event) == game::MouseEvent::ButtonDown && game::EventField(call->event, game::kMouseButton) == game::kLeftButton) { const int x = game::EventField(call->event, game::kMouseButtonX); const int y = game::EventField(call->event, game::kMouseButtonY); for (int row = 0; row < kRows; ++row) { uint8_t* minus = Button(call->panel, kMinusButtons, row); using HitTestFn = char(__fastcall*)(void*, void*, int, int); if (Visible(minus) && CanRevert(call->panel, arts, row) && reinterpret_cast((*reinterpret_cast(minus))[kControlHitTest])(minus, nullptr, x, y)) { Revert(call->panel, arts, row); Update(call->panel, arts); return 1; } } } return call->original(call->panel, nullptr, call->event); } } // namespace } // namespace mods::respec namespace game { // cUI_StatisticsChar vf10 (event) and vf14 (render); centred like the other // windows (mods/hud.cpp). int __fastcall UiStatisticsChar_HandleEvent(void* self, void* edx, void* event); RE_HOOK(0x006a5dd0, UiStatisticsChar_HandleEvent); int __fastcall UiStatisticsChar_HandleEvent(void* self, void*, void* event) { mods::respec::EventCall call{static_cast(self), event, UiStatisticsChar_HandleEvent_original}; return mods::hud::HandleCentred(event, &mods::respec::EventBody, &call); } int __fastcall UiStatisticsChar_Render(void* self, void* edx, void* device); RE_HOOK(0x006a9be0, UiStatisticsChar_Render); int __fastcall UiStatisticsChar_Render(void* self, void*, void* device) { mods::respec::RenderCall call{static_cast(self), device, UiStatisticsChar_Render_original}; return mods::hud::RenderCentred(self, &mods::respec::RenderBody, &call); } } // namespace game