Character panel: minus buttons take back attribute and skill points

- respec: the panel's unused second button array becomes − buttons between
  name and value; attributes give back invested points, skills give back
  skill points, a level-1 skill is removed and can be chosen anew; all
  through the game's own point functions (protected copy stays in sync)
- the − is the + icon with its vertical arm covered (no minus image exists)
- hud: RenderCentred / HandleCentred for mods hooking centred windows;
  mouse event helpers shared in game/ui/events.h

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-16 01:44:20 +02:00
1 parent c6c5530709
commit 037fc73158
7 files changed
+413 -14

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
#pragma once
// UI events (cEvent subclasses handed to the windows' vf10).
#include <cstdint>
namespace game {
// Mouse events, by their vf04 category (positions in render pixels):
// 1 cEvent_mouseMove: previous x, y at +4 / +8, current at +0xc / +0x10
// 2 cEvent_mouseButton_down,
// 3 cEvent_mouseButton_up: button at +4 (0: left), x, y at +8 / +0xc
enum class MouseEvent { None = 0, Move = 1, ButtonDown = 2, ButtonUp = 3 };
inline MouseEvent MouseEventKind(void* event) {
using CategoryFn = int(__fastcall*)(void* self, void* edx);
auto category = reinterpret_cast<CategoryFn>((*static_cast<void***>(event))[1]);
const int kind = category(event, nullptr);
return kind >= 1 && kind <= 3 ? static_cast<MouseEvent>(kind) : MouseEvent::None;
}
constexpr size_t kMouseButton = 0x4;
constexpr size_t kMouseButtonX = 0x8;
constexpr size_t kMouseButtonY = 0xc;
constexpr int kLeftButton = 0;
inline int32_t EventField(void* event, size_t offset) {
return *reinterpret_cast<int32_t*>(static_cast<uint8_t*>(event) + offset);
}
} // namespace game
+19 -14
View File
@@ -29,6 +29,7 @@
#include "game/addresses.h"
#include "game/render/dx_driver7.h"
#include "game/ui/events.h"
#include "mods/widescreen.h"
#include "re/config.h"
#include "re/hook.h"
@@ -262,22 +263,17 @@ private:
CursorOffset cursor_;
};
// Mouse events, by vf04 category (positions in render pixels):
// 1 cEvent_mouseMove: previous x, y at +4 / +8, current at +0xc / +0x10
// 2 cEvent_mouseButton_down,
// 3 cEvent_mouseButton_up: x, y at +8 / +0xc
// Stores the offsets of the x fields; returns their count (0: no mouse event).
// Stores the offsets of a mouse event's x fields (game/ui/events.h); returns
// their count (0: no mouse event).
int MouseEventXOffsets(void* event, int (&offsets)[2]) {
using CategoryFn = int(__fastcall*)(void* self, void* edx);
auto category = reinterpret_cast<CategoryFn>((*static_cast<void***>(event))[1]);
switch (category(event, nullptr)) {
case 1:
switch (game::MouseEventKind(event)) {
case game::MouseEvent::Move:
offsets[0] = 0x4;
offsets[1] = 0xc;
return 2;
case 2:
case 3:
offsets[0] = 0x8;
case game::MouseEvent::ButtonDown:
case game::MouseEvent::ButtonUp:
offsets[0] = game::kMouseButtonX;
return 1;
default:
return 0;
@@ -574,8 +570,7 @@ HUD_EVENT(0x00726880, UiWindow_HandleEvent, Anchor::Center) // cUI_Window2 (sha
HUD_RENDER(0x00727040, UiWindow_Render, Anchor::Center) // cUI_Window2 (shared)
HUD_EVENT(0x006a4140, UiBlacksmith_HandleEvent, Anchor::Center)
HUD_RENDER3(0x006a3160, UiBlacksmith_Render, Anchor::Center)
HUD_EVENT(0x006a5dd0, UiStatisticsChar_HandleEvent, Anchor::Center)
HUD_RENDER(0x006a9be0, UiStatisticsChar_Render, Anchor::Center)
// cUI_StatisticsChar (0x6a5dd0 / 0x6a9be0): hooked by mods/respec.cpp.
HUD_EVENT(0x006acc70, UiChest_HandleEvent, Anchor::Center)
HUD_RENDER(0x006ac8b0, UiChest_Render, Anchor::Center)
HUD_EVENT(0x006adc70, UiConsole_HandleEvent, Anchor::Center)
@@ -688,6 +683,16 @@ int VisibleDialogTooltips(void** out, int max) {
return VisibleDialogTooltipsImpl(out, max);
}
int RenderCentred(void* window, int (*body)(void* context), void* context) {
DrawOffset offset(window, OffsetFor(Anchor::Center));
return body(context);
}
int HandleCentred(void* event, int (*body)(void* context), void* context) {
EventOffset offset(event, OffsetFor(Anchor::Center));
return body(context);
}
} // namespace mods::hud
namespace game {
+6
View File
@@ -17,4 +17,10 @@ void* CursorFor(void* cursor);
// visible while no moved window was under the cursor) and are visible now.
int VisibleDialogTooltips(void** out, int max);
// For mods that hook a centred window themselves: run body(context) as the
// HUD layer runs the window's render method (drawing shifted, the cursor seen
// shifted) or event handler (mouse position shifted by the window's offset).
int RenderCentred(void* window, int (*body)(void* context), void* context);
int HandleCentred(void* event, int (*body)(void* context), void* context);
} // namespace mods::hud
+290
View File
@@ -0,0 +1,290 @@
// − 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 <cstdint>
#include <cstring>
#include <windows.h>
#include "game/engine/arts.h"
#include "game/engine/objects.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 = 6;
// 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).
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;
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<AddAttributeFn>(0x005a03a0);
const auto AddSkillLevels = reinterpret_cast<AddSkillLevelsFn>(0x00582360);
const auto Recalculate = reinterpret_cast<RecalculateFn>(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<uint32_t*>(panel + kPanelFlags) & kPanelShowsAttributes) != 0;
}
uint8_t* HeroArts() {
auto* hero = static_cast<uint8_t*>(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<const uint8_t*>(image);
*reinterpret_cast<uint32_t*>(button + texture) = *reinterpret_cast<const uint32_t*>(descriptor + kImageTexture);
for (int i = 0; i < 4; ++i) {
*reinterpret_cast<uint32_t*>(button + uvs + 4 * i) =
*reinterpret_cast<const uint32_t*>(descriptor + kImageUvOrder[i]);
}
}
bool Visible(uint8_t* control) {
return (*reinterpret_cast<uint32_t*>(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<uint8_t**>(minus + kControlParent) != panel) {
*reinterpret_cast<uint8_t**>(minus + kControlParent) = panel;
CopyImage(minus, kButtonImage, kButtonTexture, kPlusImage);
CopyImage(minus, kButtonPressedImage, kButtonPressedTexture, kPlusPressedImage);
}
std::memcpy(minus + kControlRect, plus + kControlRect, 12);
*reinterpret_cast<int*>(minus + kControlRect) = kMinusX;
auto& flags = *reinterpret_cast<uint32_t*>(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<uint16_t*>(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<uint16_t*>(arts + kSkillChoices));
}
// IDirect3DDevice7 as the UI's render methods get it (their vf14 argument).
constexpr int kDeviceDrawPrimitive = 25;
constexpr int kDeviceSetTexture = 35;
constexpr DWORD kTriangleStrip = 5;
constexpr DWORD kFvfScreenColourTexture = 0x1c4; // XYZRHW | DIFFUSE | SPECULAR | TEX1
struct Vertex {
float x, y, z, rhw;
uint32_t diffuse, specular;
float u, v;
};
// 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;
void** vtable = *static_cast<void***>(device);
using SetTextureFn = HRESULT(__stdcall*)(void*, DWORD, void*);
using DrawPrimitiveFn = HRESULT(__stdcall*)(void*, DWORD, DWORD, void*, DWORD, DWORD);
reinterpret_cast<SetTextureFn>(vtable[kDeviceSetTexture])(device, 0, nullptr);
for (const Patch& patch : kArmPatches) {
const float x0 = rect.x + patch.x0 * sx;
const float x1 = rect.x + patch.x1 * sx;
const float y0 = rect.y + patch.y0 * sy;
const float y1 = rect.y + patch.y1 * sy;
Vertex quad[4] = {{x0, y0, 0, 1, kDiscGrey, 0, 0, 0},
{x1, y0, 0, 1, kDiscGrey, 0, 1, 0},
{x0, y1, 0, 1, kDiscGrey, 0, 0, 1},
{x1, y1, 0, 1, kDiscGrey, 0, 1, 1}};
reinterpret_cast<DrawPrimitiveFn>(vtable[kDeviceDrawPrimitive])(device, kTriangleStrip,
kFvfScreenColourTexture, quad, 4, 0);
}
}
struct RenderCall {
uint8_t* panel;
void* device;
int (__fastcall* original)(void*, void*, void*);
};
int RenderBody(void* context) {
auto* call = static_cast<RenderCall*>(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<RenderFn>((*reinterpret_cast<void***>(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<EventCall*>(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<HitTestFn>((*reinterpret_cast<void***>(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<uint8_t*>(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<uint8_t*>(self), device, UiStatisticsChar_Render_original};
return mods::hud::RenderCentred(self, &mods::respec::RenderBody, &call);
}
} // namespace game