Shared chest tabs, rune stacks and rune master, Q pickup, Kettenblitz tooltip and range
- Shared chest: tabs "Truhe"/"Gemeinsam" as a column left of the grid (game tab sheet), grids placed every tick, persistent across heroes - Inventory tab texts capitalized (RegisterText / CapitalizeText) - Combat-art runes stack like potions (itemstack, renamed from potionstack) - Rune master: any rune for any number of runes, price by rune count (4 runes = 1,000, x10 per missing rune), bought rune goes into the backpack, Buy disabled without runes, gold or room, last rune remembered per hero (saved), the chosen rune's name below its icon, dropping a stack gives up one rune - Keys: the old key handling no longer takes over after the save screen (text field focus only counts while its dialog is shown) - Esc crash: tooltips forgotten when the game destroys them - Pick up (Q) sends the pickup order directly; search reach doubled - Kettenblitz: tooltip shows hits per chain, chains, cast and jump range; cast range 900 (longer ray segments), jumps half of that Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3d9d864c25
commit
6ca96d54b4
29 files changed
+2006
-171
No files matched your search
+128
-9
@@ -11,7 +11,7 @@
|
||||
//
|
||||
// This mod keeps all of that and decides:
|
||||
// - hits per chain N = 3 + level / 5: the first bolt's [14];
|
||||
// - the next target: the nearest enemy within Blitzschlag's cast range (see
|
||||
// - the next target: the nearest enemy within half Blitzschlag's cast range (see
|
||||
// below; the game's own jump search used about 316) that this
|
||||
// chain has not hit yet; else another enemy; else the current target again
|
||||
// (a lone enemy takes all N hits);
|
||||
@@ -23,6 +23,7 @@
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <windows.h>
|
||||
@@ -31,6 +32,7 @@
|
||||
#include "game/engine/fx.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
#include "re/patch.h"
|
||||
|
||||
@@ -47,13 +49,27 @@ constexpr uint32_t kFindJumpTarget = 0x006005d0; // cObjectManager::FindJumpTar
|
||||
// Blitzschlag's cast range: the art code (cCreature art switch 0x5222c0,
|
||||
// case 0x21) lays the ray from the caster towards the target in segments of
|
||||
// 30, at most 20, each on passable ground (0x636b60); only if the target is
|
||||
// reached is the bolt created on it. A jump may go as far as a cast. One place,
|
||||
// so it can become dynamic (levels, attributes) later.
|
||||
constexpr double kRaySegmentLength = 30.0;
|
||||
// reached is the bolt created on it. A jump may go as far as a cast.
|
||||
// The segment count is bound by the creature's array of segment ids
|
||||
// (+0x263, 20 entries), so the range grows by longer segments (request of
|
||||
// 2026-10-17: range +50 %, 600 -> 900): the ray's step and the segment length
|
||||
// are patched in the art code, where the game uses its shared 30.0 constant.
|
||||
constexpr float kRaySegmentLengthOriginal = 30.0f;
|
||||
constexpr float kRaySegmentLength = 45.0f;
|
||||
constexpr int kRaySegments = 20;
|
||||
double CastRange() {
|
||||
return kRaySegmentLength * kRaySegments;
|
||||
return static_cast<double>(kRaySegmentLength) * kRaySegments;
|
||||
}
|
||||
// A jump between enemies reaches half as far as the cast (request of
|
||||
// 2026-10-17: 900 felt too far for jumps).
|
||||
double JumpRange() {
|
||||
return CastRange() / 2;
|
||||
}
|
||||
constexpr uint32_t kConstant30 = 0x0088ef18; // float 30.0, shared by 72 instructions
|
||||
constexpr uint32_t kRayRemainingMinusSegment = 0x00526db6; // fsub [30.0]
|
||||
constexpr uint32_t kRayLastSegmentCheck = 0x00526e0b; // fcomp [30.0]: remaining shorter than a segment?
|
||||
constexpr uint32_t kRaySegmentLengthInt = 0x00526e23; // mov eax,30: a full segment's length
|
||||
constexpr uint32_t kRayStep = 0x00526f3d; // push 30.0f: advance the ray point
|
||||
constexpr size_t kObjectOwnerPlayer = 0x39; // u8, copied from the caster like the game does
|
||||
constexpr size_t kCreaturePosition = 0x18; // 4 dwords, as effects are placed
|
||||
constexpr size_t kEventHitsLeft = 14;
|
||||
@@ -89,15 +105,23 @@ void OnOrder(void* creature, const game::CreatureOrder& order, void*) {
|
||||
}
|
||||
}
|
||||
|
||||
// Hits per chain and chains at an art level.
|
||||
uint32_t Hits(unsigned level) {
|
||||
return 3 + level / 5;
|
||||
}
|
||||
unsigned Chains(unsigned level) {
|
||||
return 1 + level / 10;
|
||||
}
|
||||
|
||||
unsigned ArtLevel(void* creature, uint16_t art) {
|
||||
uint8_t* entry = game::FindCooldownEntry(static_cast<uint8_t*>(creature) + game::kCreatureArts, art);
|
||||
return entry ? entry[game::kCooldownLevel] + entry[game::kCooldownBonusLevels] : 0;
|
||||
}
|
||||
|
||||
// The nearest living enemy of `caster` within the cast range of `from`, not
|
||||
// The nearest living enemy of `caster` within the jump range of `from`, not
|
||||
// `current`; one not in `avoid` if there is any.
|
||||
uint32_t Nearest(void* caster, void* from, uint32_t current, const std::vector<uint32_t>& avoid) {
|
||||
return game::NearestHostile(caster, from, CastRange(), current, avoid.data(), avoid.size());
|
||||
return game::NearestHostile(caster, from, JumpRange(), current, avoid.data(), avoid.size());
|
||||
}
|
||||
|
||||
void Forget() {
|
||||
@@ -160,8 +184,8 @@ void BeforeFxEvent(uint8_t* fx, uint32_t* event) {
|
||||
}
|
||||
Forget();
|
||||
const unsigned level = ArtLevel(caster, g_heroArt);
|
||||
const uint32_t hits = 3 + level / 5;
|
||||
const unsigned chains = 1 + level / 10;
|
||||
const uint32_t hits = Hits(level);
|
||||
const unsigned chains = Chains(level);
|
||||
event[kEventHitsLeft] = hits;
|
||||
auto chain = std::make_shared<Chain>();
|
||||
chain->hit.push_back(target);
|
||||
@@ -200,6 +224,18 @@ __declspec(naked) void NextTargetThunk() {
|
||||
void Startup() {
|
||||
const bool ok = re::RedirectCall(kJumpSearchCall, kFindJumpTarget, reinterpret_cast<const void*>(&NextTargetThunk));
|
||||
re::Log("chainlightning: jump targets%s", ok ? "" : " (patch FAILED)");
|
||||
static const float segmentLength = kRaySegmentLength;
|
||||
const auto bits = [](float value) {
|
||||
uint32_t result;
|
||||
std::memcpy(&result, &value, sizeof(result));
|
||||
return result;
|
||||
};
|
||||
const bool range = re::RedirectOperand(kRayRemainingMinusSegment, kConstant30, &segmentLength) &&
|
||||
re::RedirectOperand(kRayLastSegmentCheck, kConstant30, &segmentLength) &&
|
||||
re::PatchInstructionDword(kRaySegmentLengthInt, static_cast<uint32_t>(kRaySegmentLengthOriginal),
|
||||
static_cast<uint32_t>(kRaySegmentLength)) &&
|
||||
re::PatchPushImm32(kRayStep, bits(kRaySegmentLengthOriginal), bits(kRaySegmentLength));
|
||||
re::Log("chainlightning: cast range %.0f%s", CastRange(), range ? "" : " (patch FAILED)");
|
||||
}
|
||||
RE_STARTUP(ChainLightningStartup, Startup);
|
||||
|
||||
@@ -237,3 +273,86 @@ uint32_t __fastcall NextTarget(void* manager, void*, const int32_t* position, ui
|
||||
}
|
||||
|
||||
} // namespace mods::chainlightning
|
||||
|
||||
// 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
|
||||
// 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).
|
||||
namespace mods::chainlightning {
|
||||
namespace {
|
||||
|
||||
constexpr uint16_t kArtBlitzschlag = 0x21; // "Kettenblitz" in the tooltip
|
||||
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;
|
||||
size_t start = 0;
|
||||
while (start < text.size()) {
|
||||
size_t end = text.find(L'\n', start);
|
||||
end = end == std::wstring::npos ? text.size() : end + 1;
|
||||
const std::wstring line = text.substr(start, end - start);
|
||||
start = end;
|
||||
if (line.find(kCurrentLevelLine) != std::wstring::npos) {
|
||||
level = currentLevel;
|
||||
} else if (line.find(kNextLevelLine) != std::wstring::npos) {
|
||||
level = currentLevel + 1;
|
||||
}
|
||||
const size_t count = line.find(kCountLine);
|
||||
if (count == std::wstring::npos) {
|
||||
out += line;
|
||||
continue;
|
||||
}
|
||||
const std::wstring prefix = line.substr(0, count); // colour codes
|
||||
const std::wstring newline = line.back() == L'\n' ? L"\n" : L"";
|
||||
out += prefix + L"Treffer je Kette: " + std::to_wstring(Hits(level)) + L"\n";
|
||||
out += prefix + L"Ketten: " + std::to_wstring(Chains(level)) + L"\n";
|
||||
out += prefix + L"Reichweite: " + std::to_wstring(static_cast<int>(CastRange())) + L"\n";
|
||||
out += prefix + L"Sprungweite: " + std::to_wstring(static_cast<int>(JumpRange())) + newline;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace mods::chainlightning
|
||||
+19
-2
@@ -489,6 +489,16 @@ int VisibleDialogTooltipsImpl(void** out, int max) {
|
||||
return count;
|
||||
}
|
||||
|
||||
// A tooltip is destroyed: its entry goes, so its memory is never taken for a
|
||||
// tooltip again (Esc closed a "visible dialog" that was a freed one: crash).
|
||||
void ForgetTooltip(void* tooltip) {
|
||||
for (TooltipState& t : g_tooltips) {
|
||||
if (t.tooltip == tooltip) {
|
||||
t = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int KnownTooltipOffset(void* tooltip) {
|
||||
for (const TooltipState& t : g_tooltips) {
|
||||
if (t.tooltip == tooltip) {
|
||||
@@ -598,8 +608,7 @@ HUD_EVENT(0x006d1080, UiMaster_HandleEvent, Anchor::Center)
|
||||
HUD_RENDER(0x006d0f00, UiMaster_Render, Anchor::Center)
|
||||
HUD_EVENT(0x006d2450, UiMasterCombo_HandleEvent, Anchor::Center)
|
||||
HUD_RENDER(0x006d2b70, UiMasterCombo_Render, Anchor::Center)
|
||||
HUD_EVENT(0x006d54e0, UiMasterLotto_HandleEvent, Anchor::Center)
|
||||
HUD_RENDER(0x006d4720, UiMasterLotto_Render, Anchor::Center)
|
||||
// cUI_MasterLotto (rune master): event and render in mods/runetrade.cpp.
|
||||
HUD_EVENT(0x006d5e20, UiMasterPortal_HandleEvent, Anchor::Center)
|
||||
HUD_RENDER(0x006d5e00, UiMasterPortal_Render, Anchor::Center)
|
||||
HUD_EVENT(0x006dab10, UiMerchandise_HandleEvent, Anchor::Center)
|
||||
@@ -663,6 +672,14 @@ int __fastcall UiTooltip_Render(void* self, void* edx, void* device) {
|
||||
return UiTooltip_Render_original(self, edx, device);
|
||||
}
|
||||
|
||||
// cUI_Tooltip::~cUI_Tooltip (0x6e6560, plain ret; from the deleting destructor vf00).
|
||||
void __fastcall UiTooltip_Destruct(void* self, void* edx);
|
||||
RE_HOOK(0x006e6560, UiTooltip_Destruct);
|
||||
void __fastcall UiTooltip_Destruct(void* self, void* edx) {
|
||||
mods::hud::ForgetTooltip(self);
|
||||
UiTooltip_Destruct_original(self, edx);
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
|
||||
namespace mods::hud {
|
||||
|
||||
+75
-15
@@ -20,17 +20,20 @@
|
||||
// SkillSlot0=0 ... SkillSlot6=6 ; cast combat-art slot (0 = right mouse button art)
|
||||
// WeaponSet1=F1 ... WeaponSet7=F7 ; select weapon set
|
||||
// ActionSlot3=G ; what W did (W is movement)
|
||||
// PickUp=V ; what A did (pick up items nearby)
|
||||
// PickUp=Q ; what A did (pick up items nearby; Q's own action slot is given up)
|
||||
// SaveGame=K ; what S did (save game window)
|
||||
|
||||
#include "mods/input.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
#include "game/engine/events.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "mods/skillbar.h"
|
||||
#include "mods/skillkeys.h"
|
||||
#include "mods/wasd.h"
|
||||
@@ -43,7 +46,7 @@ namespace mods::input {
|
||||
namespace {
|
||||
|
||||
// --- sacred.exe ---------------------------------------------------------------
|
||||
constexpr uint32_t kUiManager = 0x017ecb3c; // cUI_Manager*: focused control at +0x148
|
||||
constexpr uint32_t kUiManager = 0x017ecb3c; // cUI_Manager*
|
||||
constexpr uint32_t kKeyModifiers = 0x0182cdec; // u16, as the in-game window procedure keeps it
|
||||
constexpr uint32_t kEventKeyVtable = 0x00895568;
|
||||
|
||||
@@ -75,7 +78,7 @@ void PostGameKey(char key, bool pressed) {
|
||||
|
||||
// --- bindings ------------------------------------------------------------------
|
||||
|
||||
enum class Kind { SkillSlot, WeaponSet, GameKey };
|
||||
enum class Kind { SkillSlot, WeaponSet, GameKey, PickUp };
|
||||
|
||||
struct Binding {
|
||||
const char* name;
|
||||
@@ -102,7 +105,7 @@ Binding g_bindings[] = {
|
||||
{"WeaponSet6", "F6", Kind::WeaponSet, 5, 0},
|
||||
{"WeaponSet7", "F7", Kind::WeaponSet, 6, 0},
|
||||
{"ActionSlot3", "G", Kind::GameKey, 0, 'W'},
|
||||
{"PickUp", "V", Kind::GameKey, 0, 'A'},
|
||||
{"PickUp", "Q", Kind::PickUp, 0, 0},
|
||||
{"SaveGame", "K", Kind::GameKey, 0, 'S'},
|
||||
};
|
||||
|
||||
@@ -147,6 +150,37 @@ bool Reserved(int vk) {
|
||||
return (vk >= '0' && vk <= '9') || mods::wasd::IsMovementKey(vk);
|
||||
}
|
||||
|
||||
// Pick up items nearby: the order the engine's A hotkey sends (creature order
|
||||
// 0x113, cEngine::ReceiveEvent 0x618c41; the hero collects, mode +0xfc 0xc),
|
||||
// given to the controlled creature on the next engine tick. (Posting the A
|
||||
// key event did not reach it: no pickup was logged.)
|
||||
constexpr uint32_t kOrderPickUp = 0x113;
|
||||
std::atomic<bool> g_pickUp{false};
|
||||
// The collect mode (0x4ec3a0, substate 0 without a target) looks for an item
|
||||
// in square rings of map cells around the hero, nearest ring first, while the
|
||||
// ring's low offset is above -20 (cmp edi,-20 at 0x4ec8c6). Twice as far.
|
||||
constexpr uint32_t kCollectRingLimit = 0x004ec8c6;
|
||||
constexpr uint8_t kCollectRingsOriginal = 0xec; // -20
|
||||
constexpr uint8_t kCollectRings = 0xd8; // -40
|
||||
|
||||
|
||||
void OnTick(uint8_t* engine) {
|
||||
if (!g_pickUp.exchange(false)) {
|
||||
return;
|
||||
}
|
||||
if (void* hero = game::ControlledCreature(engine)) {
|
||||
game::CreatureOrder order;
|
||||
order.command = kOrderPickUp;
|
||||
game::SendCreatureOrder(engine, hero, order);
|
||||
}
|
||||
}
|
||||
|
||||
struct Registration {
|
||||
Registration() {
|
||||
game::OnEngineTick(&OnTick);
|
||||
}
|
||||
} g_registration;
|
||||
|
||||
void Press(const Binding& binding, int vk) {
|
||||
const auto slot = static_cast<unsigned>(binding.index);
|
||||
switch (binding.kind) {
|
||||
@@ -159,6 +193,9 @@ void Press(const Binding& binding, int vk) {
|
||||
case Kind::GameKey:
|
||||
PostGameKey(binding.gameKey, true);
|
||||
break;
|
||||
case Kind::PickUp:
|
||||
g_pickUp = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +205,7 @@ void Release(const Binding& binding, int vk) {
|
||||
mods::skillkeys::KeyReleased(vk);
|
||||
break;
|
||||
case Kind::WeaponSet:
|
||||
case Kind::PickUp:
|
||||
break;
|
||||
case Kind::GameKey:
|
||||
PostGameKey(binding.gameKey, false);
|
||||
@@ -184,23 +222,45 @@ bool g_swallowChar = false;
|
||||
|
||||
void Startup() {
|
||||
LoadBindings();
|
||||
if (!re::PatchInstructionByte(kCollectRingLimit, 2, kCollectRingsOriginal, kCollectRings)) {
|
||||
re::Log("input: pickup range patch FAILED");
|
||||
}
|
||||
re::Log("input: key bindings active");
|
||||
}
|
||||
RE_STARTUP(InputStartup, Startup);
|
||||
|
||||
// The text field that has the keyboard, as the game decides it: the UI
|
||||
// manager hands key events to its dialog (+0x144) while that is shown
|
||||
// (+0x10 bit 0; cUI_Manager::ReceiveEvent 0x7573ba), and the dialog's focused
|
||||
// control (+0x148) is an edit control without the read-only bit (+0x10 bit
|
||||
// 16). The focus pointer alone stays set after the dialog closed (measured:
|
||||
// the save window's 'DEFAULT_EDIT' after closing it), which switched the
|
||||
// bindings off.
|
||||
constexpr size_t kUiDialog = 0x144;
|
||||
constexpr size_t kUiFocus = 0x148;
|
||||
constexpr size_t kControlFlags = 0x10;
|
||||
constexpr uint32_t kControlVisible = 1;
|
||||
constexpr uint32_t kEditReadOnly = 1u << 16;
|
||||
|
||||
uint8_t* FocusedTextField() {
|
||||
auto* ui = *reinterpret_cast<uint8_t**>(kUiManager);
|
||||
auto* dialog = ui ? *reinterpret_cast<uint8_t**>(ui + kUiDialog) : nullptr;
|
||||
if (!dialog || (*reinterpret_cast<uint32_t*>(dialog + kControlFlags) & kControlVisible) == 0) {
|
||||
return nullptr;
|
||||
}
|
||||
void* focus = *reinterpret_cast<void**>(ui + kUiFocus);
|
||||
auto* edit = static_cast<uint8_t*>(focus ? game::DynamicCast(focus, game::kTypeControl, game::kTypeEditControl)
|
||||
: nullptr);
|
||||
if (!edit || (*reinterpret_cast<uint32_t*>(edit + kControlFlags) & kEditReadOnly) != 0) {
|
||||
return nullptr;
|
||||
}
|
||||
return edit;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool TypingIntoTextField() {
|
||||
auto* ui = *reinterpret_cast<uint8_t**>(kUiManager);
|
||||
if (!ui) {
|
||||
return false;
|
||||
}
|
||||
void* focus = *reinterpret_cast<void**>(ui + 0x148);
|
||||
if (!focus) {
|
||||
return false;
|
||||
}
|
||||
auto* edit = static_cast<uint8_t*>(game::DynamicCast(focus, game::kTypeControl, game::kTypeEditControl));
|
||||
return edit && ((*reinterpret_cast<uint32_t*>(edit + 0x10) >> 16) & 1) == 0;
|
||||
return FocusedTextField() != nullptr;
|
||||
}
|
||||
|
||||
bool HandleWindowMessage(HWND, UINT message, WPARAM wParam, LPARAM lParam) {
|
||||
@@ -210,7 +270,7 @@ bool HandleWindowMessage(HWND, UINT message, WPARAM wParam, LPARAM lParam) {
|
||||
const bool repeat = (lParam & (1 << 30)) != 0;
|
||||
if (!repeat) {
|
||||
g_taken[vk] = false;
|
||||
if (TypingIntoTextField()) {
|
||||
if (FocusedTextField()) {
|
||||
return false;
|
||||
}
|
||||
const Binding* binding = BindingFor(vk);
|
||||
|
||||
+75
-99
@@ -26,7 +26,10 @@
|
||||
|
||||
#include "game/engine/events.h"
|
||||
#include "game/engine/items.h"
|
||||
#include "mods/potionstack.h"
|
||||
#include "game/engine/stream.h"
|
||||
#include "mods/inventory_save.h"
|
||||
#include "mods/itemstack.h"
|
||||
#include "mods/sharedchest.h"
|
||||
#include "mods/skillslots.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
@@ -36,81 +39,64 @@ namespace {
|
||||
|
||||
using nlohmann::json;
|
||||
using namespace mods::skillslots;
|
||||
using game::Hex;
|
||||
using game::MakeMemoryStream;
|
||||
using game::MemoryStream;
|
||||
using game::Read;
|
||||
using game::Seek;
|
||||
using game::Stream;
|
||||
using game::Tell;
|
||||
using game::Unhex;
|
||||
using game::Write;
|
||||
|
||||
struct HeroData {
|
||||
const char* key;
|
||||
HeroDataWriteFn write;
|
||||
HeroDataReadFn read;
|
||||
};
|
||||
std::vector<HeroData>& HeroDataList() {
|
||||
static std::vector<HeroData> list;
|
||||
return list;
|
||||
}
|
||||
|
||||
// The hero index of a hero inventory, or -1 (chest, traders, other uses).
|
||||
int HeroIndexOf(const void* inventory) {
|
||||
for (unsigned i = 0; i < game::kChestInventory; ++i) {
|
||||
if (mods::skillslots::Inventory(i) == inventory) {
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void WriteHeroData(json& j, const void* inventory) {
|
||||
const int hero = HeroIndexOf(inventory);
|
||||
if (hero < 0) {
|
||||
return;
|
||||
}
|
||||
for (const HeroData& data : HeroDataList()) {
|
||||
json value = data.write(static_cast<unsigned>(hero));
|
||||
if (!value.is_null()) {
|
||||
j["hero"][data.key] = std::move(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ReadHeroData(const json& j, const void* inventory) {
|
||||
const int hero = HeroIndexOf(inventory);
|
||||
if (hero < 0) {
|
||||
return;
|
||||
}
|
||||
const json empty = json::object();
|
||||
const json& all = j.contains("hero") ? j.at("hero") : empty;
|
||||
for (const HeroData& data : HeroDataList()) {
|
||||
data.read(static_cast<unsigned>(hero), all.contains(data.key) ? all.at(data.key) : json());
|
||||
}
|
||||
}
|
||||
|
||||
constexpr char kMagic[4] = {'S', 'R', 'I', 'J'};
|
||||
constexpr int kVersion = 1;
|
||||
|
||||
// --- streams -------------------------------------------------------------------
|
||||
|
||||
// The game's file interface (cFile): vf04 read, vf08 write, vf0c seek, vf10
|
||||
// size, vf14 tell; all __thiscall.
|
||||
struct Stream {
|
||||
void** vtable;
|
||||
};
|
||||
|
||||
uint32_t Read(Stream* stream, void* buffer, uint32_t size) {
|
||||
using Fn = uint32_t(__fastcall*)(Stream*, void*, void*, uint32_t);
|
||||
return reinterpret_cast<Fn>(stream->vtable[1])(stream, nullptr, buffer, size);
|
||||
}
|
||||
uint32_t Write(Stream* stream, const void* buffer, uint32_t size) {
|
||||
using Fn = uint32_t(__fastcall*)(Stream*, void*, const void*, uint32_t);
|
||||
return reinterpret_cast<Fn>(stream->vtable[2])(stream, nullptr, buffer, size);
|
||||
}
|
||||
void Seek(Stream* stream, uint32_t position) {
|
||||
using Fn = void(__fastcall*)(Stream*, void*, uint32_t);
|
||||
reinterpret_cast<Fn>(stream->vtable[3])(stream, nullptr, position);
|
||||
}
|
||||
uint32_t Tell(Stream* stream) {
|
||||
using Fn = uint32_t(__fastcall*)(Stream*, void*);
|
||||
return reinterpret_cast<Fn>(stream->vtable[5])(stream, nullptr);
|
||||
}
|
||||
|
||||
// A cFile over a byte vector.
|
||||
struct MemoryStream {
|
||||
void** vtable;
|
||||
std::vector<uint8_t>* data;
|
||||
uint32_t position;
|
||||
};
|
||||
|
||||
void* __fastcall MemDestroy(MemoryStream* self, void*, int) {
|
||||
return self;
|
||||
}
|
||||
uint32_t __fastcall MemRead(MemoryStream* self, void*, void* buffer, uint32_t size) {
|
||||
const uint32_t available = static_cast<uint32_t>(self->data->size()) - self->position;
|
||||
const uint32_t n = size < available ? size : available;
|
||||
std::memcpy(buffer, self->data->data() + self->position, n);
|
||||
self->position += n;
|
||||
return n;
|
||||
}
|
||||
uint32_t __fastcall MemWrite(MemoryStream* self, void*, const void* buffer, uint32_t size) {
|
||||
if (self->data->size() < self->position + size) {
|
||||
self->data->resize(self->position + size);
|
||||
}
|
||||
std::memcpy(self->data->data() + self->position, buffer, size);
|
||||
self->position += size;
|
||||
return size;
|
||||
}
|
||||
void __fastcall MemSeek(MemoryStream* self, void*, uint32_t position) {
|
||||
self->position = position;
|
||||
}
|
||||
uint32_t __fastcall MemSize(MemoryStream* self, void*) {
|
||||
return static_cast<uint32_t>(self->data->size());
|
||||
}
|
||||
uint32_t __fastcall MemTell(MemoryStream* self, void*) {
|
||||
return self->position;
|
||||
}
|
||||
|
||||
void* g_memoryVtable[8] = {
|
||||
reinterpret_cast<void*>(&MemDestroy), reinterpret_cast<void*>(&MemRead),
|
||||
reinterpret_cast<void*>(&MemWrite), reinterpret_cast<void*>(&MemSeek),
|
||||
reinterpret_cast<void*>(&MemSize), reinterpret_cast<void*>(&MemTell),
|
||||
reinterpret_cast<void*>(0x0065afb0), reinterpret_cast<void*>(0x0065af80), // cFile helpers
|
||||
};
|
||||
|
||||
MemoryStream MakeMemoryStream(std::vector<uint8_t>& data) {
|
||||
return MemoryStream{g_memoryVtable, &data, 0};
|
||||
}
|
||||
|
||||
// --- block <-> JSON ------------------------------------------------------------
|
||||
|
||||
// The block Serialize writes, in order.
|
||||
@@ -151,28 +137,6 @@ public:
|
||||
std::vector<uint8_t> data;
|
||||
};
|
||||
|
||||
std::string Hex(const uint8_t* p, size_t size) {
|
||||
static const char digits[] = "0123456789abcdef";
|
||||
std::string s(size * 2, '0');
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
s[2 * i] = digits[p[i] >> 4];
|
||||
s[2 * i + 1] = digits[p[i] & 15];
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
void Unhex(const std::string& s, uint8_t* out, size_t size) {
|
||||
if (s.size() != size * 2) {
|
||||
throw std::runtime_error("hex field has the wrong length");
|
||||
}
|
||||
auto nibble = [](char c) -> uint8_t {
|
||||
return static_cast<uint8_t>(c <= '9' ? c - '0' : (c | 0x20) - 'a' + 10);
|
||||
};
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
out[i] = static_cast<uint8_t>(nibble(s[2 * i]) << 4 | nibble(s[2 * i + 1]));
|
||||
}
|
||||
}
|
||||
|
||||
bool AllZero(const uint8_t* p, size_t size) {
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
if (p[i]) {
|
||||
@@ -245,8 +209,8 @@ json ToJson(const std::vector<uint8_t>& block, const uint8_t* shadow) {
|
||||
const uint8_t* cell = cells + i * kGridCellSize;
|
||||
if (!AllZero(cell, kGridCellSize)) {
|
||||
json entry = {{"cell", i}, {"item", U32At(cell)}, {"data", Hex(cell + 4, kGridCellSize - 4)}};
|
||||
if (const unsigned count = mods::potionstack::StackCount(U32At(cell)); count > 1) {
|
||||
entry["count"] = count; // a potion stack (src/mods/potionstack.cpp)
|
||||
if (const unsigned count = mods::itemstack::StackCount(U32At(cell)); count > 1) {
|
||||
entry["count"] = count; // a potion stack (src/mods/itemstack.cpp)
|
||||
}
|
||||
grid.push_back(entry);
|
||||
}
|
||||
@@ -357,6 +321,10 @@ std::vector<uint8_t> ToBlock(const json& j) {
|
||||
} // namespace
|
||||
|
||||
// Defined in namespace game below.
|
||||
void RegisterHeroData(const char* key, HeroDataWriteFn write, HeroDataReadFn read) {
|
||||
HeroDataList().push_back({key, write, read});
|
||||
}
|
||||
|
||||
} // namespace mods::inventory_save
|
||||
|
||||
namespace game {
|
||||
@@ -380,13 +348,15 @@ int __fastcall InvManager_Serialize(void* self, void* edx, void* stream, int wri
|
||||
++g_heroFileDepth;
|
||||
g_readJson = g_readVanilla = 0;
|
||||
if (!static_cast<char>(write)) {
|
||||
mods::potionstack::ResetStacks(); // the ids of the previous game are gone
|
||||
mods::itemstack::ResetStacks(); // the ids of the previous game are gone
|
||||
}
|
||||
const int result = InvManager_Serialize_original(self, edx, stream, write, x);
|
||||
--g_heroFileDepth;
|
||||
if (!static_cast<char>(write)) {
|
||||
re::Log("inventory: save game read, %d JSON / %d vanilla inventories", g_readJson, g_readVanilla);
|
||||
NotifySaveGameLoaded();
|
||||
} else {
|
||||
mods::sharedchest::Save(); // in-game saves write the save game, not saveHero
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -398,6 +368,9 @@ uint32_t __cdecl ItemDataMgr_SaveHero(uint32_t a, uint32_t b, uint32_t c, uint32
|
||||
++g_heroFileDepth;
|
||||
const uint32_t result = ItemDataMgr_SaveHero_original(a, b, c, d, e, f);
|
||||
--g_heroFileDepth;
|
||||
if (result) {
|
||||
mods::sharedchest::Save(); // with every saved hero, the shared chest too
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -423,7 +396,9 @@ int __fastcall Inv_Serialize(void* self, void* edx, void* streamArg, int write)
|
||||
std::vector<uint8_t> block;
|
||||
MemoryStream memory = MakeMemoryStream(block);
|
||||
Inv_Serialize_original(self, edx, &memory, 1);
|
||||
const std::string text = ToJson(block, mods::skillslots::ShadowFor(self, false)).dump(1);
|
||||
json j = ToJson(block, mods::skillslots::ShadowFor(self, false));
|
||||
WriteHeroData(j, self);
|
||||
const std::string text = j.dump(1);
|
||||
const auto length = static_cast<uint32_t>(text.size());
|
||||
Write(stream, kMagic, 4);
|
||||
Write(stream, &length, 4);
|
||||
@@ -449,10 +424,11 @@ int __fastcall Inv_Serialize(void* self, void* edx, void* streamArg, int write)
|
||||
MemoryStream memory = MakeMemoryStream(block);
|
||||
Inv_Serialize_original(self, edx, &memory, 0); // also clears the shadow (Clear)
|
||||
++g_readJson;
|
||||
ReadHeroData(j, self);
|
||||
for (const json& cell : j.at("grid")) {
|
||||
if (cell.contains("count")) {
|
||||
const size_t i = cell.at("cell");
|
||||
mods::potionstack::SetStackCount(game::GridItem(static_cast<uint8_t*>(self), i), cell.at("count"));
|
||||
mods::itemstack::SetStackCount(game::GridItem(static_cast<uint8_t*>(self), i), cell.at("count"));
|
||||
}
|
||||
}
|
||||
for (unsigned s = kGameSlots; s < kSlots; ++s) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
// Per-hero data of other mods, saved with the hero's inventory JSON (save
|
||||
// games and hero files, src/mods/inventory_save.cpp) under "hero": {key: ...}.
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace mods::inventory_save {
|
||||
|
||||
// `hero`: the hero's inventory index (= its creature id, below the chest's 0x11).
|
||||
using HeroDataWriteFn = nlohmann::json (*)(unsigned hero); // null: nothing to save
|
||||
using HeroDataReadFn = void (*)(unsigned hero, const nlohmann::json& data);
|
||||
|
||||
// Call at startup (static initialisation).
|
||||
void RegisterHeroData(const char* key, HeroDataWriteFn write, HeroDataReadFn read);
|
||||
|
||||
} // namespace mods::inventory_save
|
||||
@@ -1,33 +1,36 @@
|
||||
// Potion stacks (request 2026-10-17, docs/potion-stacks.md).
|
||||
// Item stacks: potions and combat-art runes (requests 2026-10-17,
|
||||
// docs/item-stacks.md).
|
||||
//
|
||||
// Potions of the same item type share one inventory cell, up to kMaxStack. A
|
||||
// stack is the one potion object in the cell plus a count kept here (by item
|
||||
// id, with the item type to tell a reused id apart) and saved with the JSON
|
||||
// inventories (src/mods/inventory_save.cpp).
|
||||
// Items of the same type that the game counts as consumable (potions, runes;
|
||||
// ItemIsConsumable 0x43f5f0) share one inventory cell, up to kMaxStack. Such
|
||||
// items differ only in their object state (measured for runes), so the type
|
||||
// alone decides. A stack is the one object in the cell plus a count kept here
|
||||
// (by item id, with the item type to tell a reused id apart) and saved with
|
||||
// the JSON inventories (src/mods/inventory_save.cpp).
|
||||
//
|
||||
// Stacks exist in the players' inventories and the chest (inventory 0x11);
|
||||
// trader stock waits for stack prices.
|
||||
//
|
||||
// - Entry: a potion placed into a grid (picked up, bought, dropped from the
|
||||
// - Entry: an item placed into a grid (picked up, bought, dropped from the
|
||||
// cursor; cInventoryEntry::PlaceItem 0x5d9ec0) pours into other
|
||||
// stacks of its type that have room, on the next engine tick (the caller
|
||||
// still uses the object while placing it). An emptied object is removed and
|
||||
// destroyed.
|
||||
// - Sort button (cInventoryEntry::Sort 0x5db670, the inventory window's
|
||||
// button): all potions of a type are poured together first.
|
||||
// - Drinking (cCreature::UseItem 0x54a8f0, measured): the potion's event
|
||||
// handler (cPotion3D::vf18) applies the effect and marks it used up; UseItem
|
||||
// then removes it from the grid (RemoveItem 0x5da5f0) and destroys it
|
||||
// (cObjectManager::DestroyObject 0x5fbe10), and cEngine::UseActionSlot
|
||||
// removes it once more. For a stack of more than one, the first removal
|
||||
// takes one potion off the count instead, and the rest of that drink leaves
|
||||
// the object alone.
|
||||
// button): all items of a type are poured together first.
|
||||
// - Use (cCreature::UseItem 0x54a8f0, measured for potions; runes, class 0x1c,
|
||||
// take the same path): the item's event handler (vf18) applies the effect
|
||||
// and marks it used up; UseItem then removes it from the grid (RemoveItem
|
||||
// 0x5da5f0) and destroys it (cObjectManager::DestroyObject 0x5fbe10), and
|
||||
// cEngine::UseActionSlot removes it once more. For a stack of more than
|
||||
// one, the first removal takes one off the count instead, and the rest of
|
||||
// that use leaves the object alone.
|
||||
// - Load: cInventoryEntry::ReplaceItemId (0x5d80e0, hooked in skillslots.cpp;
|
||||
// hero files map saved ids to the new objects) moves the count to the new id.
|
||||
// - Drawing: the count on each stack's cell, after cInventoryEntry::DrawItems
|
||||
// (0x5dc3f0; cell rects from CellRect 0x5dd530).
|
||||
|
||||
#include "mods/potionstack.h"
|
||||
#include "mods/itemstack.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
@@ -42,7 +45,7 @@
|
||||
#include "game/ui/text.h"
|
||||
#include "re/hook.h"
|
||||
|
||||
namespace mods::potionstack {
|
||||
namespace mods::itemstack {
|
||||
namespace {
|
||||
|
||||
constexpr unsigned kMaxStack = 20;
|
||||
@@ -62,7 +65,7 @@ struct Stack {
|
||||
std::mutex g_lock;
|
||||
std::unordered_map<uint32_t, Stack> g_stacks;
|
||||
|
||||
// Potions placed into a grid, poured on the next tick.
|
||||
// Stackable items placed into a grid, poured on the next tick.
|
||||
struct Entry {
|
||||
uint8_t* inventory;
|
||||
uint32_t id;
|
||||
@@ -70,7 +73,7 @@ struct Entry {
|
||||
std::vector<Entry> g_entered;
|
||||
// Inside cInventoryEntry::Sort (its re-placements are no entries).
|
||||
thread_local bool g_sorting = false;
|
||||
// The stack a drink just took a potion from (per thread: drinks come from the
|
||||
// The stack a use just took one from (per thread: uses come from the
|
||||
// engine and the UI thread).
|
||||
thread_local uint32_t g_drunk = 0;
|
||||
thread_local DWORD g_drunkAt = 0;
|
||||
@@ -80,23 +83,31 @@ uint32_t TypeOf(uint32_t id) {
|
||||
return item ? game::ItemType(item) : 0;
|
||||
}
|
||||
|
||||
// Potions stack in the players' inventories and the chest; trader stock not
|
||||
// Stacks exist in the players' inventories and the chest; trader stock not
|
||||
// yet (buying from a stack would sell the whole stack for one price).
|
||||
bool Stacks(const void* inventory) {
|
||||
return inventory && game::IsPlayerInventory(static_cast<const uint8_t*>(inventory));
|
||||
}
|
||||
|
||||
bool Stackable(uint32_t type) {
|
||||
return type && game::ItemIsConsumable(type);
|
||||
}
|
||||
|
||||
bool StackableItem(uint32_t id) {
|
||||
return Stackable(TypeOf(id));
|
||||
}
|
||||
|
||||
bool InGrid(const uint8_t* inventory, uint32_t id) {
|
||||
bool found = false;
|
||||
game::ForEachGridItem(inventory, [&](uint32_t item) { found = found || item == id; });
|
||||
return found;
|
||||
}
|
||||
|
||||
// Moves potions of `id` into the other stacks of its type in `inventory`
|
||||
// Moves the items of `id` into the other stacks of its type in `inventory`
|
||||
// that have room (in grid order); an emptied `id` is removed and destroyed.
|
||||
void Pour(uint8_t* inventory, uint32_t id) {
|
||||
const uint32_t type = TypeOf(id);
|
||||
if (!type || !game::ItemPotionKind(type)) {
|
||||
if (!Stackable(type)) {
|
||||
return;
|
||||
}
|
||||
const unsigned had = StackCount(id);
|
||||
@@ -125,17 +136,17 @@ void Pour(uint8_t* inventory, uint32_t id) {
|
||||
game::DestroyObject(id);
|
||||
}
|
||||
|
||||
// Pours the potions from the back of the grid into those before them: full
|
||||
// Pours the stacks from the back of the grid into those before them: full
|
||||
// stacks first.
|
||||
void StackAll(uint8_t* inventory) {
|
||||
std::vector<uint32_t> potions;
|
||||
std::vector<uint32_t> items;
|
||||
game::ForEachGridItem(inventory, [&](uint32_t id) {
|
||||
if (game::PotionKindOf(id)) {
|
||||
potions.push_back(id);
|
||||
if (StackableItem(id)) {
|
||||
items.push_back(id);
|
||||
}
|
||||
});
|
||||
for (size_t i = potions.size(); i-- > 0;) {
|
||||
Pour(inventory, potions[i]);
|
||||
for (size_t i = items.size(); i-- > 0;) {
|
||||
Pour(inventory, items[i]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,24 +240,36 @@ void OnItemIdReplaced(uint32_t oldId, uint32_t newId) {
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t StackWithRoom(const uint8_t* inventory, uint32_t type) {
|
||||
uint32_t found = 0;
|
||||
if (inventory && Stacks(inventory) && Stackable(type)) {
|
||||
game::ForEachGridItem(inventory, [&](uint32_t id) {
|
||||
if (!found && TypeOf(id) == type && StackCount(id) < kMaxStack) {
|
||||
found = id;
|
||||
}
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
void ResetStacks() {
|
||||
std::lock_guard guard(g_lock);
|
||||
g_stacks.clear();
|
||||
g_entered.clear();
|
||||
}
|
||||
|
||||
} // namespace mods::potionstack
|
||||
} // namespace mods::itemstack
|
||||
|
||||
namespace game {
|
||||
|
||||
using namespace mods::potionstack;
|
||||
using namespace mods::itemstack;
|
||||
|
||||
// cInventoryEntry::PlaceItem (0x5d9ec0, ret 0x14): (cell, id, w, h, notify).
|
||||
int __fastcall Inv_PlaceItem(void* self, void* edx, uint32_t cell, uint32_t id, uint32_t w, uint32_t h, uint32_t notify);
|
||||
RE_HOOK(0x005d9ec0, Inv_PlaceItem);
|
||||
int __fastcall Inv_PlaceItem(void* self, void* edx, uint32_t cell, uint32_t id, uint32_t w, uint32_t h, uint32_t notify) {
|
||||
const int previous = Inv_PlaceItem_original(self, edx, cell, id, w, h, notify);
|
||||
if (!g_sorting && PotionKindOf(id) && Stacks(self)) {
|
||||
if (!g_sorting && StackableItem(id) && Stacks(self)) {
|
||||
std::lock_guard guard(g_lock);
|
||||
g_entered.push_back({static_cast<uint8_t*>(self), id});
|
||||
}
|
||||
@@ -264,7 +287,7 @@ int __fastcall Inv_RemoveItem(void* self, void* edx, uint32_t id, uint32_t notif
|
||||
auto* flags = item ? reinterpret_cast<uint32_t*>(item + kObjectFlags) : nullptr;
|
||||
const unsigned count = flags && (*flags & kObjectUsedUp) ? StackCount(id) : 1;
|
||||
if (count > 1) {
|
||||
// A drink from a stack: one potion less, the object stays.
|
||||
// A use from a stack: one less, the object stays.
|
||||
*flags &= ~kObjectUsedUp;
|
||||
SetStackCount(id, count - 1);
|
||||
g_drunk = id;
|
||||
@@ -293,7 +316,7 @@ char __fastcall Creature_UseItem(void* self, void* edx, uint32_t id) {
|
||||
return Creature_UseItem_original(self, edx, id);
|
||||
}
|
||||
|
||||
// cInventoryEntry::Sort (0x5db670, ret 4): stack the potions first.
|
||||
// cInventoryEntry::Sort (0x5db670, ret 4): stack first.
|
||||
void __fastcall Inv_Sort(void* self, void* edx, uint32_t mode);
|
||||
RE_HOOK(0x005db670, Inv_Sort);
|
||||
void __fastcall Inv_Sort(void* self, void* edx, uint32_t mode) {
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace mods::potionstack {
|
||||
namespace mods::itemstack {
|
||||
|
||||
// Potions of one type in the stack held by item `id` (1 for any other item).
|
||||
// Items of one type in the stack held by item `id` (1 for an item without one).
|
||||
unsigned StackCount(uint32_t id);
|
||||
|
||||
// Sets the stack size of item `id` (1 or less: no stack). Used when loading.
|
||||
@@ -13,7 +13,10 @@ void SetStackCount(uint32_t id, unsigned count);
|
||||
// cInventoryEntry::ReplaceItemId: the stack follows its item to the new id.
|
||||
void OnItemIdReplaced(uint32_t oldId, uint32_t newId);
|
||||
|
||||
// An item of `type` in `inventory` whose stack has room for one more, or 0.
|
||||
uint32_t StackWithRoom(const uint8_t* inventory, uint32_t type);
|
||||
|
||||
// Forgets all stacks (a save game is about to be loaded).
|
||||
void ResetStacks();
|
||||
|
||||
} // namespace mods::potionstack
|
||||
} // namespace mods::itemstack
|
||||
@@ -8,7 +8,7 @@
|
||||
// cInventoryEntry::GetPotionRef(k) finds a potion of kind k, else the empty
|
||||
// control behind it (+0x3ac). GetPotionRef walks the inventory grid and asks
|
||||
// ItemPotionKind for each item's kind; the count here does the same and adds
|
||||
// up the potion stacks (potionstack.cpp).
|
||||
// up the potion stacks (itemstack.cpp).
|
||||
|
||||
#include <cstdint>
|
||||
#include <cwchar>
|
||||
@@ -16,7 +16,7 @@
|
||||
#include "game/engine/items.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/ui/text.h"
|
||||
#include "mods/potionstack.h"
|
||||
#include "mods/itemstack.h"
|
||||
#include "mods/skillslots.h"
|
||||
#include "re/hook.h"
|
||||
|
||||
@@ -42,7 +42,7 @@ void RenderCounts(uint8_t* taskbar, void* renderer) {
|
||||
game::ForEachGridItem(inventory, [&](uint32_t id) {
|
||||
const int kind = game::PotionKindOf(id);
|
||||
if (kind >= 1 && kind <= game::kPotionKinds) {
|
||||
counts[kind] += potionstack::StackCount(id);
|
||||
counts[kind] += itemstack::StackCount(id);
|
||||
}
|
||||
});
|
||||
const int lineHeight = game::FontLineHeight(kCountFont);
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
// The rune master (cUI_MasterLotto), requests 2026-10-17:
|
||||
//
|
||||
// - Rune stacks: a rune stack dropped into one of the four slots gives up one
|
||||
// rune; the rest stays on the cursor (or goes back to the backpack when the
|
||||
// slot's old rune comes onto the cursor in exchange).
|
||||
// - Buying: the rune to buy can always be chosen (the arrows), with 1 to 4
|
||||
// runes given; the price falls with the runes given (kPrices: 1,000 for 4,
|
||||
// each missing rune x10). Buy works with at least one rune and enough gold.
|
||||
// The last rune bought is remembered per hero (saved with the hero,
|
||||
// src/mods/inventory_save.cpp) and chosen again when the window opens.
|
||||
//
|
||||
// The window, read (docs/item-stacks.md has the slot details):
|
||||
// - HandleEvent (0x6d54e0): a left press over slot i (control at +0x154 +
|
||||
// i * 0x114, hit test vf1c(x, y)) puts the slot's old item (descriptor at
|
||||
// +0x24c + i * 0x114) on the cursor afterwards, and if the cursor carries a
|
||||
// rune (descriptor kind 1, item class 0x1c) copies the cursor's descriptor
|
||||
// into the slot and clears the cursor. The descriptor is the cursor's
|
||||
// carried item (cursor +0x68): {u16 kind, u16 class, u32 id, ...}, 0x14
|
||||
// bytes. A press on the buy button (+0x7c8) with no bought rune waiting
|
||||
// (+0x798) and gold >= price[runes] pays and buys (0x6d42a0).
|
||||
// - The price table at 0xad2aac (u32 by rune count, the game fills 2..4 from
|
||||
// its settings at 0x5efc7b) is read by the render, the button state and the
|
||||
// pay helper 0x6d3ce0; buying needed 2 runes (cmp dx, 2 at 0x6d3e20,
|
||||
// 0x6d4544, 0x6d4a13).
|
||||
// - 0x6d42a0 (buy): 2 runes: a random rune (0x6d4180(0)), 3: random of the
|
||||
// hero's class, 4: the chosen one (+0x7b4); the slot runes are destroyed and
|
||||
// the new rune is created into +0x798 (type +0x79c), shown at +0x69c until
|
||||
// taken.
|
||||
// - 0x6d3d50 (after slot changes): with 4 runes shows the arrows (+0x5a4 next,
|
||||
// +0x620 previous; doLottoSelect 0x6d3fd0(hero type, 1 / 0, 2 = keep)),
|
||||
// otherwise hides them and clears the choice +0x7b4.
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#include "game/engine/events.h"
|
||||
#include "game/engine/items.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "game/ui/events.h"
|
||||
#include "game/ui/tabs.h"
|
||||
#include "game/ui/text.h"
|
||||
#include "mods/hud.h"
|
||||
#include "mods/inventory_save.h"
|
||||
#include "mods/itemstack.h"
|
||||
#include "mods/skillslots.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
#include "re/patch.h"
|
||||
|
||||
namespace mods::runetrade {
|
||||
namespace {
|
||||
|
||||
// cUI_MasterLotto
|
||||
constexpr int kSlots = 4;
|
||||
constexpr size_t kSlotControls = 0x154;
|
||||
constexpr size_t kSlotStride = 0x114;
|
||||
constexpr size_t kSlotItems = 0x24c; // descriptors: kind (u16) 0: empty; the rune's id at +4
|
||||
constexpr size_t kNextArrow = 0x5a4;
|
||||
constexpr size_t kPreviousArrow = 0x620;
|
||||
constexpr size_t kChosenRune = 0x7b4; // item type
|
||||
constexpr size_t kBuyButton = 0x7c8;
|
||||
constexpr size_t kChosenControl = 0x69c; // shows the chosen rune's icon
|
||||
constexpr size_t kChosenObject = 0x7b0; // an object of the chosen rune (doLottoSelect), for its name
|
||||
// The buy button's text colour (+0x840 = button +0x78) and its colour set
|
||||
// (button +0x54..+0x60, 0x6d5d90), enabled / disabled, as the render picks them.
|
||||
constexpr size_t kBuyTextColour = 0x840;
|
||||
constexpr uint32_t kDisabledTextColour = 0x009f028c;
|
||||
constexpr uint32_t kDisabledColours = 0x009f0290;
|
||||
constexpr size_t kControlHitTest = 0x1c / 4;
|
||||
constexpr size_t kControlFlags = 0x10;
|
||||
constexpr uint8_t kControlVisible = 1; // +0x10 bit 0: the window handles events only while shown
|
||||
|
||||
// doLottoSelect (0x6d3fd0) modes.
|
||||
constexpr uint32_t kSelectPrevious = 0;
|
||||
constexpr uint32_t kSelectNext = 1;
|
||||
constexpr uint32_t kSelectKeep = 2;
|
||||
constexpr int kMaxRuneChoices = 256; // a bound for stepping through the choices
|
||||
|
||||
// Prices by the number of runes given (0: nothing to buy, not shown).
|
||||
constexpr uint32_t kPriceTable = 0x00ad2aac;
|
||||
constexpr uint32_t kPrices[kSlots + 1] = {0, 1000000, 100000, 10000, 1000};
|
||||
|
||||
// Buying needs at least one rune: cmp dx, 2 -> 1 (imm8 at +3).
|
||||
constexpr re::BytePatch kMinRuneCompares[] = {
|
||||
{0x006d3e20, 3, 2}, // button colour
|
||||
{0x006d4544, 3, 2}, // button state (0x6d4390)
|
||||
{0x006d4a13, 3, 2}, // render
|
||||
};
|
||||
constexpr uint8_t kMinRunes = 1;
|
||||
|
||||
// The cursor's carried item (MouseCursor_Instance 0x6550f0, +0x68).
|
||||
struct CarriedItem {
|
||||
uint16_t kind; // 1: an item
|
||||
uint16_t itemClass;
|
||||
uint32_t id;
|
||||
uint32_t rest[3];
|
||||
};
|
||||
static_assert(sizeof(CarriedItem) == 0x14);
|
||||
constexpr uint16_t kCarriedItem = 1;
|
||||
constexpr size_t kCursorCarried = 0x68;
|
||||
|
||||
// The rune master window (one per session; set by its handlers).
|
||||
uint8_t* g_window = nullptr;
|
||||
|
||||
// The last rune each hero bought (by hero index), 0: none yet.
|
||||
uint32_t g_lastRune[game::kChestInventory] = {};
|
||||
|
||||
template <typename T>
|
||||
T& Field(uint8_t* window, size_t offset) {
|
||||
return *reinterpret_cast<T*>(window + offset);
|
||||
}
|
||||
|
||||
void* Cursor() {
|
||||
return reinterpret_cast<void*(__cdecl*)()>(0x006550f0)(); // MouseCursor_Instance (hud.cpp: shifted copy)
|
||||
}
|
||||
|
||||
CarriedItem* Carried() {
|
||||
void* cursor = Cursor();
|
||||
return cursor ? *reinterpret_cast<CarriedItem**>(static_cast<uint8_t*>(cursor) + kCursorCarried) : nullptr;
|
||||
}
|
||||
|
||||
// cMouseCursor::SetItem (0x655710, ret 0x20): takes the descriptor (game heap),
|
||||
// as the rune master puts an item back on the cursor.
|
||||
void CarryItem(const CarriedItem& item) {
|
||||
auto* copy = static_cast<CarriedItem*>(game::GameNew(sizeof(CarriedItem)));
|
||||
if (!copy) {
|
||||
return;
|
||||
}
|
||||
*copy = item;
|
||||
using SetFn = void(__fastcall*)(void*, void*, void*, int, int, int, float, float, int, int);
|
||||
reinterpret_cast<SetFn>(0x00655710)(Cursor(), nullptr, copy, 0, 0, 0, 1.0f, 1.0f, -32, -32);
|
||||
}
|
||||
|
||||
bool HitTest(uint8_t* control, int x, int y) {
|
||||
using HitTestFn = char(__fastcall*)(void*, void*, int, int);
|
||||
return reinterpret_cast<HitTestFn>((*reinterpret_cast<void***>(control))[kControlHitTest])(control, nullptr, x,
|
||||
y) != 0;
|
||||
}
|
||||
|
||||
// The slot under (x, y), or -1.
|
||||
int SlotAt(uint8_t* window, int x, int y) {
|
||||
for (int slot = 0; slot < kSlots; ++slot) {
|
||||
if (HitTest(window + kSlotControls + slot * kSlotStride, x, y)) {
|
||||
return slot;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool SlotFilled(uint8_t* window, int slot) {
|
||||
return Field<uint16_t>(window, kSlotItems + slot * kSlotStride) != 0;
|
||||
}
|
||||
|
||||
int RuneCount(uint8_t* window) {
|
||||
int count = 0;
|
||||
for (int slot = 0; slot < kSlots; ++slot) {
|
||||
count += SlotFilled(window, slot) ? 1 : 0;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int HeroIndex() {
|
||||
void* hero = game::GetPlayerHero();
|
||||
const int index = hero ? game::CreatureField(hero, game::kCreatureId) : -1;
|
||||
return index >= 0 && index < game::kChestInventory ? index : -1;
|
||||
}
|
||||
|
||||
// The hero's object type (hero +0x10), which doLottoSelect takes.
|
||||
uint32_t HeroType() {
|
||||
void* hero = game::GetPlayerHero();
|
||||
return hero ? game::ItemType(hero) : 0;
|
||||
}
|
||||
|
||||
uint8_t* HeroInventory() {
|
||||
const int hero = HeroIndex();
|
||||
return hero >= 0 ? skillslots::Inventory(static_cast<unsigned>(hero)) : nullptr;
|
||||
}
|
||||
|
||||
// Free space for a 1 x 1 item in `inventory` (cInventoryEntry::FindFreeSpace 0x5dab60).
|
||||
bool FindFreeSpace(uint8_t* inventory, uint16_t* cell) {
|
||||
using FindFn = char(__fastcall*)(void*, void*, uint32_t, uint32_t, uint16_t*, uint32_t);
|
||||
return reinterpret_cast<FindFn>(0x005dab60)(inventory, nullptr, 1, 1, cell, 0) != 0;
|
||||
}
|
||||
|
||||
void SetPrices() {
|
||||
std::memcpy(reinterpret_cast<void*>(kPriceTable), kPrices, sizeof(kPrices));
|
||||
}
|
||||
|
||||
void Select(uint8_t* window, uint32_t mode) {
|
||||
using SelectFn = void(__fastcall*)(void*, void*, uint32_t, uint32_t);
|
||||
reinterpret_cast<SelectFn>(0x006d3fd0)(window, nullptr, HeroType(), mode); // doLottoSelect
|
||||
}
|
||||
|
||||
// Shows a control (0x731e70) / hides it (0x731ee0); flag 1 = visible.
|
||||
void SetShown(uint8_t* control, bool shown) {
|
||||
using FlagFn = void(__fastcall*)(void*, void*, uint32_t);
|
||||
reinterpret_cast<FlagFn>(shown ? 0x00731e70 : 0x00731ee0)(control, nullptr, kControlVisible);
|
||||
}
|
||||
|
||||
// Chooses the hero's last bought rune (steps through the choices).
|
||||
void ChooseRemembered(uint8_t* window) {
|
||||
const int hero = HeroIndex();
|
||||
const uint32_t remembered = hero >= 0 ? g_lastRune[hero] : 0;
|
||||
for (int i = 0; remembered && i < kMaxRuneChoices && Field<uint32_t>(window, kChosenRune) != remembered; ++i) {
|
||||
Select(window, kSelectNext);
|
||||
}
|
||||
}
|
||||
|
||||
// Where a bought rune of `type` goes in the hero's backpack: onto a stack
|
||||
// with room (`stack`), else into a free cell (`cell`). False: no room.
|
||||
bool FindRoom(uint8_t* inventory, uint32_t type, uint32_t* stack, uint16_t* cell) {
|
||||
*stack = itemstack::StackWithRoom(inventory, type);
|
||||
return *stack != 0 || FindFreeSpace(inventory, cell);
|
||||
}
|
||||
|
||||
// Buying is possible: runes given, a rune chosen, gold for the price
|
||||
// (0x6d3ce0(0) checks it) and room in the backpack.
|
||||
bool CanBuy(uint8_t* window) {
|
||||
uint8_t* inventory = HeroInventory();
|
||||
const uint32_t rune = Field<uint32_t>(window, kChosenRune);
|
||||
uint32_t stack = 0;
|
||||
uint16_t cell = 0;
|
||||
using PayFn = char(__fastcall*)(void*, void*, uint32_t);
|
||||
return RuneCount(window) >= kMinRunes && rune != 0 && inventory && FindRoom(inventory, rune, &stack, &cell) &&
|
||||
reinterpret_cast<PayFn>(0x006d3ce0)(window, nullptr, 0);
|
||||
}
|
||||
|
||||
// Buys the chosen rune for the runes in the slots and puts it into the
|
||||
// backpack (CanBuy checked; 0x6d3ce0(1) pays).
|
||||
void Buy(uint8_t* window) {
|
||||
uint8_t* inventory = HeroInventory();
|
||||
const uint32_t rune = Field<uint32_t>(window, kChosenRune);
|
||||
uint32_t stack = 0;
|
||||
uint16_t cell = 0;
|
||||
using PayFn = char(__fastcall*)(void*, void*, uint32_t);
|
||||
if (!FindRoom(inventory, rune, &stack, &cell) || !reinterpret_cast<PayFn>(0x006d3ce0)(window, nullptr, 1)) {
|
||||
return;
|
||||
}
|
||||
for (int slot = 0; slot < kSlots; ++slot) {
|
||||
auto* descriptor = &Field<CarriedItem>(window, kSlotItems + slot * kSlotStride);
|
||||
if (descriptor->id) {
|
||||
game::DestroyObject(descriptor->id);
|
||||
*descriptor = {};
|
||||
}
|
||||
}
|
||||
if (stack) {
|
||||
itemstack::SetStackCount(stack, itemstack::StackCount(stack) + 1);
|
||||
} else {
|
||||
using CreateFn = uint32_t(__fastcall*)(void*, void*, uint32_t, uint32_t, uint32_t, uint32_t);
|
||||
const uint32_t id =
|
||||
reinterpret_cast<CreateFn>(0x005fb590)(game::ObjectManager(), nullptr, rune, 0, 0, 0); // cObjectManager::Create
|
||||
if (id) {
|
||||
using PlaceFn = int(__fastcall*)(void*, void*, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t);
|
||||
reinterpret_cast<PlaceFn>(0x005d9ec0)(inventory, nullptr, cell, id, 1, 1, 1); // cInventoryEntry::PlaceItem
|
||||
}
|
||||
}
|
||||
if (const int hero = HeroIndex(); hero >= 0) {
|
||||
g_lastRune[hero] = rune;
|
||||
}
|
||||
using RefreshFn = void(__fastcall*)(void*, void*, uint32_t);
|
||||
reinterpret_cast<RefreshFn>(0x006d4390)(window, nullptr, 1);
|
||||
}
|
||||
|
||||
// A press on the buy button: buys if possible. True if the press was taken.
|
||||
bool HandleBuy(uint8_t* window, int x, int y) {
|
||||
if (!HitTest(window + kBuyButton, x, y)) {
|
||||
return false;
|
||||
}
|
||||
if (CanBuy(window)) {
|
||||
Buy(window);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// A rune stack dropped on a slot: one rune goes in. Returns the handler's
|
||||
// result, or -1 if this is no such drop.
|
||||
int DropFromStack(uint8_t* window, void* event, int x, int y,
|
||||
int(__fastcall* original)(void*, void*, void*)) {
|
||||
CarriedItem* carried = Carried();
|
||||
const unsigned count = carried && carried->kind == kCarriedItem && carried->itemClass == game::kItemClassRune
|
||||
? itemstack::StackCount(carried->id)
|
||||
: 1;
|
||||
const int slot = count > 1 ? SlotAt(window, x, y) : -1;
|
||||
if (slot < 0) {
|
||||
return -1;
|
||||
}
|
||||
// The slot's old rune comes onto the cursor: the rest of the stack goes to
|
||||
// the backpack, which needs room.
|
||||
const bool exchange = SlotFilled(window, slot);
|
||||
uint8_t* inventory = exchange ? HeroInventory() : nullptr;
|
||||
uint16_t cell = 0;
|
||||
if (exchange && (!inventory || !FindFreeSpace(inventory, &cell))) {
|
||||
return 1;
|
||||
}
|
||||
const uint32_t single = game::CloneItem(carried->id);
|
||||
if (!single) {
|
||||
return -1;
|
||||
}
|
||||
const CarriedItem rest = *carried;
|
||||
itemstack::SetStackCount(rest.id, count - 1);
|
||||
carried->id = single;
|
||||
const int result = original(window, nullptr, event);
|
||||
if (exchange) {
|
||||
using PlaceFn = int(__fastcall*)(void*, void*, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t);
|
||||
reinterpret_cast<PlaceFn>(0x005d9ec0)(inventory, nullptr, cell, rest.id, 1, 1, 1); // cInventoryEntry::PlaceItem
|
||||
} else {
|
||||
CarryItem(rest);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
struct EventCall {
|
||||
uint8_t* window;
|
||||
void* event;
|
||||
int(__fastcall* original)(void*, void*, void*);
|
||||
};
|
||||
|
||||
int EventBody(void* context) {
|
||||
auto* call = static_cast<EventCall*>(context);
|
||||
g_window = call->window;
|
||||
SetPrices();
|
||||
const bool visible = (call->window[kControlFlags] & kControlVisible) != 0;
|
||||
if (visible && call->event && 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);
|
||||
if (HandleBuy(call->window, x, y)) {
|
||||
return 1;
|
||||
}
|
||||
if (const int result = DropFromStack(call->window, call->event, x, y, call->original); result >= 0) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return call->original(call->window, nullptr, call->event);
|
||||
}
|
||||
|
||||
// --- per hero: the last rune bought -----------------------------------------------
|
||||
|
||||
nlohmann::json WriteLastRune(unsigned hero) {
|
||||
return hero < game::kChestInventory && g_lastRune[hero] ? nlohmann::json(g_lastRune[hero]) : nlohmann::json();
|
||||
}
|
||||
|
||||
void ReadLastRune(unsigned hero, const nlohmann::json& data) {
|
||||
if (hero < game::kChestInventory) {
|
||||
g_lastRune[hero] = data.is_number_unsigned() ? data.get<uint32_t>() : 0;
|
||||
}
|
||||
}
|
||||
|
||||
void OnGameEnd() {
|
||||
std::memset(g_lastRune, 0, sizeof(g_lastRune));
|
||||
g_window = nullptr;
|
||||
}
|
||||
|
||||
struct Registration {
|
||||
Registration() {
|
||||
inventory_save::RegisterHeroData("lastRune", &WriteLastRune, &ReadLastRune);
|
||||
game::OnGameStart(&SetPrices);
|
||||
game::OnGameEnd(&OnGameEnd);
|
||||
}
|
||||
} g_registration;
|
||||
|
||||
void Startup() {
|
||||
if (re::PatchAll(kMinRuneCompares, kMinRunes) != 0) {
|
||||
re::Log("runetrade: rune count patches failed");
|
||||
}
|
||||
}
|
||||
RE_STARTUP(RuneTradeStartup, Startup);
|
||||
|
||||
} // namespace
|
||||
|
||||
// 0x6d3d50 (plain ret), after slot changes: the arrows always shown, a rune
|
||||
// always chosen (the last one bought first), unless a bought rune is waiting.
|
||||
void UpdateChoice(uint8_t* window) {
|
||||
// doLottoSelect refreshes the window (0x6d4390), which comes back here
|
||||
// while no rune is chosen (an empty choice list).
|
||||
static thread_local bool updating = false;
|
||||
g_window = window;
|
||||
SetPrices();
|
||||
if (updating) {
|
||||
return;
|
||||
}
|
||||
updating = true;
|
||||
SetShown(window + kNextArrow, true);
|
||||
SetShown(window + kPreviousArrow, true);
|
||||
if (Field<uint32_t>(window, kChosenRune) == 0) {
|
||||
Select(window, kSelectKeep);
|
||||
ChooseRemembered(window);
|
||||
}
|
||||
updating = false;
|
||||
}
|
||||
|
||||
} // namespace mods::runetrade
|
||||
|
||||
namespace game {
|
||||
|
||||
// cUI_MasterLotto::HandleEvent (0x6d54e0, ret 4); centred like the other
|
||||
// windows (mods/hud.cpp).
|
||||
int __fastcall UiMasterLotto_HandleEvent(void* self, void* edx, void* event);
|
||||
RE_HOOK(0x006d54e0, UiMasterLotto_HandleEvent);
|
||||
int __fastcall UiMasterLotto_HandleEvent(void* self, void*, void* event) {
|
||||
mods::runetrade::EventCall call{static_cast<uint8_t*>(self), event, UiMasterLotto_HandleEvent_original};
|
||||
return mods::hud::HandleCentred(event, &mods::runetrade::EventBody, &call);
|
||||
}
|
||||
|
||||
// cUI_MasterLotto::UpdateChoice (0x6d3d50, plain ret): replaced.
|
||||
void __fastcall UiMasterLotto_UpdateChoice(void* self, void* edx);
|
||||
RE_HOOK(0x006d3d50, UiMasterLotto_UpdateChoice);
|
||||
void __fastcall UiMasterLotto_UpdateChoice(void* self, void*) {
|
||||
mods::runetrade::UpdateChoice(static_cast<uint8_t*>(self));
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
|
||||
namespace mods::runetrade {
|
||||
|
||||
// The buy button's colours (0x6d5d90, set by the render each frame): disabled
|
||||
// while buying is not possible (also without room in the backpack).
|
||||
const void* BuyButtonColours(void* button, const void* colours) {
|
||||
if (!g_window || button != g_window + kBuyButton || CanBuy(g_window)) {
|
||||
return colours;
|
||||
}
|
||||
Field<uint32_t>(g_window, kBuyTextColour) = *reinterpret_cast<const uint32_t*>(kDisabledTextColour);
|
||||
return reinterpret_cast<const void*>(kDisabledColours);
|
||||
}
|
||||
|
||||
} // namespace mods::runetrade
|
||||
|
||||
namespace game {
|
||||
|
||||
// cUI_Button colours (0x6d5d90, ret 4): (colour set) into +0x54..+0x60.
|
||||
void __fastcall UiButton_SetColours(void* self, void* edx, const void* colours);
|
||||
RE_HOOK(0x006d5d90, UiButton_SetColours);
|
||||
void __fastcall UiButton_SetColours(void* self, void* edx, const void* colours) {
|
||||
UiButton_SetColours_original(self, edx, mods::runetrade::BuyButtonColours(self, colours));
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
|
||||
namespace mods::runetrade {
|
||||
namespace {
|
||||
|
||||
// The chosen rune's name below its icon (request 2026-10-17: the icons are
|
||||
// small). The name is the item's (0x44a420, thiscall: text of the item type's
|
||||
// name id, as the tooltips start with it).
|
||||
constexpr uint16_t kNameFont = 1;
|
||||
constexpr uint32_t kNameColour = 0xffe6d6aa; // as the tab headers' normal text (cUI_TabControl::Render)
|
||||
constexpr int kNameGap = 4;
|
||||
constexpr int kNameWidth = 320;
|
||||
|
||||
const wchar_t* ItemName(void* item) {
|
||||
return reinterpret_cast<const wchar_t*(__fastcall*)(void*, void*)>(0x0044a420)(item, nullptr);
|
||||
}
|
||||
|
||||
void DrawChosenName(uint8_t* window, void* renderer) {
|
||||
const uint32_t object = Field<uint32_t>(window, kChosenObject);
|
||||
void* item = object && Field<uint32_t>(window, kChosenRune) ? game::GetObjectById(object) : nullptr;
|
||||
const wchar_t* name = item ? ItemName(item) : nullptr;
|
||||
if (!name || !*name) {
|
||||
return;
|
||||
}
|
||||
const game::UiRect icon = game::ControlScreenRect(window + kChosenControl);
|
||||
const game::UiRect rect = {icon.x + icon.w / 2 - kNameWidth / 2, icon.y + icon.h + kNameGap,
|
||||
static_cast<int16_t>(kNameWidth),
|
||||
static_cast<int16_t>(game::FontLineHeight(kNameFont) + kNameGap)};
|
||||
game::DrawText(renderer, kNameFont, rect, name, kNameColour, game::kTextCenter);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct RenderCall {
|
||||
uint8_t* window;
|
||||
void* renderer;
|
||||
int(__fastcall* original)(void*, void*, void*);
|
||||
};
|
||||
|
||||
int RenderBody(void* context) {
|
||||
auto* call = static_cast<RenderCall*>(context);
|
||||
const int result = call->original(call->window, nullptr, call->renderer);
|
||||
if ((call->window[kControlFlags] & kControlVisible) != 0) {
|
||||
DrawChosenName(call->window, call->renderer);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace mods::runetrade
|
||||
|
||||
namespace game {
|
||||
|
||||
// cUI_MasterLotto::Render (0x6d4720, ret 4); centred like the other windows.
|
||||
int __fastcall UiMasterLotto_Render(void* self, void* edx, void* renderer);
|
||||
RE_HOOK(0x006d4720, UiMasterLotto_Render);
|
||||
int __fastcall UiMasterLotto_Render(void* self, void*, void* renderer) {
|
||||
mods::runetrade::RenderCall call{static_cast<uint8_t*>(self), renderer, UiMasterLotto_Render_original};
|
||||
return mods::hud::RenderCentred(self, &mods::runetrade::RenderBody, &call);
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
@@ -0,0 +1,410 @@
|
||||
// Shared chest: a second tab in the chest window whose contents belong to no
|
||||
// hero but to all of them (request 2026-10-17, docs/shared-chest.md).
|
||||
//
|
||||
// The chest window (cUI_Chest) works on the inventory at window +0x158
|
||||
// (inventory 0x11, the hero's chest, set by its constructor 0x6ac7f0; a
|
||||
// 20 x 10 grid from (window x, 20)). The shared tab is an inventory of our
|
||||
// own. A cUI_TabSheet of the game's (the inventory's header look), its headers
|
||||
// in a column left of the grid, switches +0x158 between the two. Shift+click
|
||||
// (cInventoryManager::MoveItem 0x5dec80 from 0x11) follows the shown tab.
|
||||
//
|
||||
// Items: the hero file stores items as their type plus the object's own
|
||||
// serializer (vf1c(stream, 1, 0)); loadHero recreates them with
|
||||
// cObjectManager::Create(type, 0, 0, 0), vf1c(stream, 0, file version) and
|
||||
// gives them their new id. The shared chest does the same into
|
||||
// save\shared_chest.json: written whenever a hero is saved, read at the start
|
||||
// of each game session; cleared when the session ends (the ids die with the
|
||||
// object manager).
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "game/engine/events.h"
|
||||
#include "game/engine/items.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "game/engine/stream.h"
|
||||
#include "game/ui/tabs.h"
|
||||
#include "game/ui/text.h"
|
||||
#include "game/ui/texts.h"
|
||||
#include "mods/itemstack.h"
|
||||
#include "mods/sharedchest.h"
|
||||
#include "mods/skillslots.h"
|
||||
#include "re/config.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
|
||||
namespace mods::sharedchest {
|
||||
namespace {
|
||||
|
||||
using nlohmann::json;
|
||||
|
||||
constexpr int kFileVersion = 1;
|
||||
|
||||
// cUI_Chest
|
||||
constexpr size_t kWindowInventory = 0x158;
|
||||
constexpr uint32_t kControlVisible = 1; // +0x10 bit 0
|
||||
constexpr size_t kControlFlags = 0x10;
|
||||
|
||||
// cInventoryEntry: the grid origin on screen and the item's size in its first cell.
|
||||
constexpr size_t kGridOriginX = 0x1164;
|
||||
constexpr size_t kGridOriginY = 0x1166;
|
||||
constexpr size_t kCellWidth = 5; // u8 in the cell, as PlaceItem stores it
|
||||
constexpr size_t kCellHeight = 6; // u8
|
||||
constexpr int kCellPixels = 32;
|
||||
|
||||
alignas(8) uint8_t g_storage[skillslots::kInventorySize];
|
||||
bool g_constructed = false;
|
||||
// The shared chest of this session came from the file (only then is it saved).
|
||||
bool g_loaded = false;
|
||||
bool g_loadPending = false;
|
||||
void* g_chestWindow = nullptr;
|
||||
void* g_sheet = nullptr; // our cUI_TabSheet in it
|
||||
|
||||
uint8_t* GameInventory(uint32_t index) {
|
||||
return skillslots::Inventory(index);
|
||||
}
|
||||
|
||||
uint8_t* Shared() {
|
||||
if (!g_constructed) {
|
||||
using ConstructFn = void(__fastcall*)(void*, void*, uint32_t x, uint32_t y, uint32_t w, uint32_t h, uint32_t);
|
||||
reinterpret_cast<ConstructFn>(0x005d7a10)(g_storage, nullptr, 0, 0, 0x14, 0xa, 0); // cInventoryEntry::Construct
|
||||
*reinterpret_cast<uint16_t*>(g_storage + game::kInventoryOwner) = game::kChestInventory;
|
||||
g_constructed = true;
|
||||
}
|
||||
return g_storage;
|
||||
}
|
||||
|
||||
void ClearGrid() {
|
||||
std::memset(Shared() + game::kInventoryGrid, 0, game::GridCells(Shared()) * game::kGridCellSize);
|
||||
}
|
||||
|
||||
bool SharedShown() {
|
||||
return g_chestWindow &&
|
||||
*reinterpret_cast<uint8_t**>(static_cast<uint8_t*>(g_chestWindow) + kWindowInventory) == Shared();
|
||||
}
|
||||
|
||||
bool Visible(void* window) {
|
||||
return (*reinterpret_cast<uint32_t*>(static_cast<uint8_t*>(window) + kControlFlags) & kControlVisible) != 0;
|
||||
}
|
||||
|
||||
// The chest window is open on the shared tab.
|
||||
bool SharedOpen() {
|
||||
return SharedShown() && Visible(g_chestWindow);
|
||||
}
|
||||
|
||||
std::string FilePath() {
|
||||
std::string dir = re::ConfigString("paths", "GameDir", "");
|
||||
if (!dir.empty() && dir.back() != '\\' && dir.back() != '/') {
|
||||
dir += '\\';
|
||||
}
|
||||
return dir + "save\\shared_chest.json";
|
||||
}
|
||||
|
||||
// --- items <-> JSON ------------------------------------------------------------
|
||||
|
||||
json ItemJson(const uint8_t* inventory, unsigned cell, uint32_t id) {
|
||||
void* item = game::GetObjectById(id);
|
||||
if (!item) {
|
||||
return nullptr;
|
||||
}
|
||||
const std::vector<uint8_t> data = game::SerializeItem(item);
|
||||
const uint8_t* entry = inventory + game::kInventoryGrid + cell * game::kGridCellSize;
|
||||
json j = {{"cell", cell},
|
||||
{"w", entry[kCellWidth]},
|
||||
{"h", entry[kCellHeight]},
|
||||
{"type", game::ItemType(item)},
|
||||
{"data", game::Hex(data.data(), data.size())}};
|
||||
if (const unsigned count = itemstack::StackCount(id); count > 1) {
|
||||
j["count"] = count;
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
// Recreates an item as loadHero does; returns its new id, 0 on failure.
|
||||
uint32_t CreateItem(const json& j, uint32_t heroId) {
|
||||
const std::string hex = j.at("data");
|
||||
std::vector<uint8_t> data(hex.size() / 2);
|
||||
game::Unhex(hex, data.data(), data.size());
|
||||
return game::CreateItem(j.at("type"), data, heroId);
|
||||
}
|
||||
|
||||
void Load() {
|
||||
ClearGrid();
|
||||
std::ifstream file(FilePath(), std::ios::binary);
|
||||
if (!file) {
|
||||
re::Log("shared chest: no %s yet", FilePath().c_str());
|
||||
return;
|
||||
}
|
||||
void* hero = game::GetPlayerHero();
|
||||
const auto heroId = hero ? static_cast<uint32_t>(game::CreatureField(hero, game::kCreatureId)) : 0u;
|
||||
int loaded = 0;
|
||||
try {
|
||||
const json j = json::parse(file);
|
||||
using PlaceFn = int(__fastcall*)(void*, void*, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t);
|
||||
for (const json& entry : j.at("items")) {
|
||||
const uint32_t id = CreateItem(entry, heroId);
|
||||
if (!id) {
|
||||
re::Log("shared chest: item of type %#x not recreated", entry.at("type").get<uint32_t>());
|
||||
continue;
|
||||
}
|
||||
reinterpret_cast<PlaceFn>(0x005d9ec0)(Shared(), nullptr, entry.at("cell"), id, entry.at("w"),
|
||||
entry.at("h"), 0); // cInventoryEntry::PlaceItem
|
||||
if (entry.contains("count")) {
|
||||
itemstack::SetStackCount(id, entry.at("count"));
|
||||
}
|
||||
++loaded;
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
re::Log("shared chest: %s unreadable (%s)", FilePath().c_str(), e.what());
|
||||
}
|
||||
re::Log("shared chest: %d items loaded", loaded);
|
||||
}
|
||||
|
||||
// A session started or a save game was loaded into it (its objects replace
|
||||
// the old ones): (re)load on the next tick with a hero.
|
||||
void RequestLoad() {
|
||||
g_loadPending = true;
|
||||
}
|
||||
|
||||
// The chest grid's place and size, as the cUI_Chest constructor sets them for
|
||||
// inventory 0x11: (window x, 20), 20 x 10. Loads overwrite the place of 0x11
|
||||
// (the game's inventory read resets some to 0, the JSON inventories restore the
|
||||
// saved one), so both tabs are put back every tick.
|
||||
constexpr size_t kWindowX = 0x24; // i16
|
||||
constexpr uint16_t kGridY = 20;
|
||||
constexpr uint16_t kChestColumns = 0x14;
|
||||
constexpr uint16_t kChestRows = 0xa;
|
||||
|
||||
void PlaceGrid(void* window, uint8_t* inventory) {
|
||||
*reinterpret_cast<uint16_t*>(inventory + kGridOriginX) =
|
||||
*reinterpret_cast<const uint16_t*>(static_cast<uint8_t*>(window) + kWindowX);
|
||||
*reinterpret_cast<uint16_t*>(inventory + kGridOriginY) = kGridY;
|
||||
*reinterpret_cast<uint16_t*>(inventory + game::kGridWidth) = kChestColumns;
|
||||
*reinterpret_cast<uint16_t*>(inventory + game::kGridHeight) = kChestRows;
|
||||
}
|
||||
|
||||
void PlaceGrids() {
|
||||
if (g_chestWindow) {
|
||||
PlaceGrid(g_chestWindow, GameInventory(game::kChestInventory));
|
||||
PlaceGrid(g_chestWindow, Shared());
|
||||
}
|
||||
}
|
||||
|
||||
void OnTick(uint8_t*) {
|
||||
if (g_loadPending && game::GetPlayerHero()) {
|
||||
g_loadPending = false;
|
||||
g_loaded = true;
|
||||
Load();
|
||||
}
|
||||
PlaceGrids();
|
||||
}
|
||||
|
||||
void OnGameEnd() {
|
||||
if (g_constructed) {
|
||||
ClearGrid();
|
||||
}
|
||||
g_loaded = false;
|
||||
g_loadPending = false;
|
||||
g_chestWindow = nullptr; // set again when the next session builds its chest window
|
||||
g_sheet = nullptr;
|
||||
}
|
||||
|
||||
// The inventory window's tab headers (Rucksack / Kampfkünste / Combos, AddTab at
|
||||
// 0x6c63ab..0x6c63d5) are upper case in the text resource; capitalized like the
|
||||
// chest's (request 2026-10-17).
|
||||
constexpr uint32_t kInventoryTabTexts[] = {0x483, 0x484, 0x485};
|
||||
|
||||
struct Registration {
|
||||
Registration() {
|
||||
game::OnEngineTick(&OnTick);
|
||||
game::OnGameStart(&RequestLoad);
|
||||
game::OnSaveGameLoaded(&RequestLoad);
|
||||
game::OnGameEnd(&OnGameEnd);
|
||||
for (const uint32_t id : kInventoryTabTexts) {
|
||||
game::CapitalizeText(id);
|
||||
}
|
||||
}
|
||||
} g_registration;
|
||||
|
||||
// --- tabs ----------------------------------------------------------------------
|
||||
// A cUI_TabSheet of the game's own (the inventory's header look) in the chest
|
||||
// window, its headers stacked in a column left of the grid's top-left corner.
|
||||
|
||||
constexpr int kHeaderGap = 2;
|
||||
|
||||
void ShowTab(void* window, bool shared) {
|
||||
uint8_t* chest = GameInventory(game::kChestInventory);
|
||||
PlaceGrids();
|
||||
*reinterpret_cast<uint8_t**>(static_cast<uint8_t*>(window) + kWindowInventory) = shared ? Shared() : chest;
|
||||
}
|
||||
|
||||
// The headers as a column: same width, right edge at the sheet's left edge.
|
||||
void LayOutColumn(void* sheet) {
|
||||
uint8_t* control = game::TabControl(sheet);
|
||||
if (!control) {
|
||||
return;
|
||||
}
|
||||
uint8_t* entries = game::TabEntries(control);
|
||||
const size_t count = game::TabCount(control);
|
||||
int width = 0;
|
||||
int height = *reinterpret_cast<const int16_t*>(game::kTabHeaderArtHeight);
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
const auto* rect = reinterpret_cast<game::UiRect*>(entries + i * game::kTabEntrySize + game::kTabEntryRect);
|
||||
width = std::max<int>(width, rect->w);
|
||||
height = std::max<int>(height, rect->h);
|
||||
}
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
uint8_t* entry = entries + i * game::kTabEntrySize;
|
||||
auto* rect = reinterpret_cast<game::UiRect*>(entry + game::kTabEntryRect);
|
||||
rect->x = -width - kHeaderGap;
|
||||
rect->y = static_cast<int32_t>(i) * (height + kHeaderGap);
|
||||
rect->w = static_cast<int16_t>(width);
|
||||
if (auto* header = *reinterpret_cast<uint8_t**>(entry + game::kTabEntryHeader)) {
|
||||
*reinterpret_cast<game::UiRect*>(header + game::kControlRect) = *rect;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Built right after the chest window (cUI_Chest constructor).
|
||||
void CreateTabs(void* window) {
|
||||
static const uint32_t kNames[2] = {game::RegisterText(L"Truhe"), game::RegisterText(L"Gemeinsam")};
|
||||
g_chestWindow = window;
|
||||
g_sheet = game::NewTabSheet("UI_TABSHEET_CHEST", {0, kGridY, 0, 0}, window);
|
||||
if (!g_sheet) {
|
||||
return;
|
||||
}
|
||||
// Pages: empty controls (the grid stays the chest's own; the tab only
|
||||
// switches the inventory behind it).
|
||||
static const char* const kPageNames[2] = {"UI_TABSHEET_CHEST_HERO", "UI_TABSHEET_CHEST_SHARED"};
|
||||
for (int tab = 0; tab < 2; ++tab) {
|
||||
game::AddTab(g_sheet, kNames[tab], game::NewTabSheet(kPageNames[tab], {0, 0, 0, 0}, g_sheet));
|
||||
}
|
||||
game::SetActiveTab(g_sheet, 0);
|
||||
game::AddWindowControl(window, g_sheet);
|
||||
}
|
||||
|
||||
// Moves `id` from `from` into free space in `to` (as MoveItem does).
|
||||
bool MoveBetween(uint8_t* from, uint8_t* to, uint32_t id) {
|
||||
const unsigned cells = game::GridCells(from);
|
||||
for (unsigned cell = 0; cell < cells; ++cell) {
|
||||
if (game::GridItem(from, cell) != id) {
|
||||
continue;
|
||||
}
|
||||
const uint8_t* entry = from + game::kInventoryGrid + cell * game::kGridCellSize;
|
||||
const uint32_t w = entry[kCellWidth];
|
||||
const uint32_t h = entry[kCellHeight];
|
||||
uint16_t target = 0;
|
||||
using FindFn = char(__fastcall*)(void*, void*, uint32_t, uint32_t, uint16_t*, uint32_t);
|
||||
if (!reinterpret_cast<FindFn>(0x005dab60)(to, nullptr, w, h, &target, 0)) { // FindFreeSpace
|
||||
return false;
|
||||
}
|
||||
using RemoveFn = int(__fastcall*)(void*, void*, uint32_t, uint32_t);
|
||||
using PlaceFn = int(__fastcall*)(void*, void*, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t);
|
||||
reinterpret_cast<RemoveFn>(0x005da5f0)(from, nullptr, id, 1); // RemoveItem
|
||||
reinterpret_cast<PlaceFn>(0x005d9ec0)(to, nullptr, target, id, w, h, 1); // PlaceItem
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Called after the game saved a hero (inventory_save.cpp, ItemDataMgr::saveHero).
|
||||
void Save() {
|
||||
if (!g_loaded || !g_constructed) {
|
||||
return;
|
||||
}
|
||||
json items = json::array();
|
||||
const uint8_t* shared = Shared();
|
||||
const unsigned cells = game::GridCells(shared);
|
||||
for (unsigned cell = 0; cell < cells; ++cell) {
|
||||
if (const uint32_t id = game::GridItem(shared, cell)) {
|
||||
json item = ItemJson(shared, cell, id);
|
||||
if (!item.is_null()) {
|
||||
items.push_back(std::move(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
const json j = {{"format", "sacred_re.shared_chest"}, {"version", kFileVersion}, {"items", items}};
|
||||
const std::string path = FilePath();
|
||||
const std::string temp = path + ".tmp";
|
||||
{
|
||||
std::ofstream file(temp, std::ios::binary | std::ios::trunc);
|
||||
file << j.dump(1);
|
||||
if (!file) {
|
||||
re::Log("shared chest: writing %s failed", temp.c_str());
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!MoveFileExA(temp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING)) {
|
||||
re::Log("shared chest: replacing %s failed (%lu)", path.c_str(), GetLastError());
|
||||
return;
|
||||
}
|
||||
re::Log("shared chest: %u items saved", static_cast<unsigned>(items.size()));
|
||||
}
|
||||
|
||||
} // namespace mods::sharedchest
|
||||
|
||||
namespace game {
|
||||
|
||||
using namespace mods::sharedchest;
|
||||
|
||||
// cUI_Chest::cUI_Chest (0x6ac7f0, ret 0x18): (name, rect, parent, flags); then
|
||||
// the tabs.
|
||||
void* __fastcall UiChest_Construct(void* self, void* edx, const char* name, UiRect rect, void* parent,
|
||||
uint32_t flags);
|
||||
RE_HOOK(0x006ac7f0, UiChest_Construct);
|
||||
void* __fastcall UiChest_Construct(void* self, void* edx, const char* name, UiRect rect, void* parent,
|
||||
uint32_t flags) {
|
||||
void* window = UiChest_Construct_original(self, edx, name, rect, parent, flags);
|
||||
if (window) {
|
||||
CreateTabs(window);
|
||||
re::Log("shared chest: tabs created");
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
// cUI_TabSheet layout (0x729210, plain ret): headers in a row; ours in a column.
|
||||
void __fastcall TabSheet_Layout(void* self, void* edx);
|
||||
RE_HOOK(0x00729210, TabSheet_Layout);
|
||||
void __fastcall TabSheet_Layout(void* self, void* edx) {
|
||||
TabSheet_Layout_original(self, edx);
|
||||
if (self && self == g_sheet) {
|
||||
LayOutColumn(self);
|
||||
}
|
||||
}
|
||||
|
||||
// cUI_TabControl::SetActive (0x728af0, ret 4): our tabs switch the chest's inventory.
|
||||
void __fastcall TabControl_SetActive(void* self, void* edx, uint32_t index);
|
||||
RE_HOOK(0x00728af0, TabControl_SetActive);
|
||||
void __fastcall TabControl_SetActive(void* self, void* edx, uint32_t index) {
|
||||
TabControl_SetActive_original(self, edx, index);
|
||||
if (g_sheet && g_chestWindow && self == TabControl(g_sheet)) {
|
||||
ShowTab(g_chestWindow, (index & 0xffff) == 1);
|
||||
}
|
||||
}
|
||||
|
||||
// cInventoryManager::MoveItem (0x5dec80, ret 0x10): (from, to, id, ?). Shift+click
|
||||
// in the chest moves from 0x11; with the shared tab shown, from the shared chest.
|
||||
char __fastcall InvManager_MoveItem(void* self, void* edx, uint32_t from, uint32_t to, uint32_t id, uint32_t net);
|
||||
RE_HOOK(0x005dec80, InvManager_MoveItem);
|
||||
char __fastcall InvManager_MoveItem(void* self, void* edx, uint32_t from, uint32_t to, uint32_t id, uint32_t net) {
|
||||
if (SharedOpen() && (from & 0xffff) == kChestInventory && (to & 0xffff) < 0x20) {
|
||||
return MoveBetween(Shared(), GameInventory(to & 0xffff), id) ? 1 : 0;
|
||||
}
|
||||
if (SharedOpen() && (to & 0xffff) == kChestInventory && (from & 0xffff) < 0x20) {
|
||||
return MoveBetween(GameInventory(from & 0xffff), Shared(), id) ? 1 : 0;
|
||||
}
|
||||
return InvManager_MoveItem_original(self, edx, from, to, id, net);
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
namespace mods::sharedchest {
|
||||
|
||||
// Writes the shared chest (save\shared_chest.json); called after a hero is saved.
|
||||
void Save();
|
||||
|
||||
} // namespace mods::sharedchest
|
||||
@@ -13,7 +13,7 @@
|
||||
// Network events sent from the shadow would name slot - 5: multiplayer with
|
||||
// more than 5 slots is not supported.
|
||||
|
||||
#include "mods/potionstack.h"
|
||||
#include "mods/itemstack.h"
|
||||
#include "mods/skillslots.h"
|
||||
|
||||
#include <cstring>
|
||||
@@ -203,7 +203,7 @@ void __fastcall Inv_ReplaceItemId(void* self, void* edx, int oldId, int newId);
|
||||
RE_HOOK(0x005d80e0, Inv_ReplaceItemId);
|
||||
void __fastcall Inv_ReplaceItemId(void* self, void*, int oldId, int newId) {
|
||||
WithShadow(Inv_ReplaceItemId_original, self, oldId, newId);
|
||||
mods::potionstack::OnItemIdReplaced(static_cast<uint32_t>(oldId), static_cast<uint32_t>(newId));
|
||||
mods::itemstack::OnItemIdReplaced(static_cast<uint32_t>(oldId), static_cast<uint32_t>(newId));
|
||||
}
|
||||
|
||||
void __fastcall Inv_FixInventory(void* self, void* edx);
|
||||
|
||||
Reference in new issue
Block a user