From 8d02216df99693f333a175914c8d5dd0bc5f1663 Mon Sep 17 00:00:00 2001 From: 4Luke4 <39967126+4Luke4@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:28:33 +0200 Subject: [PATCH] Fix signed XP loss handling --- .gitignore | 2 + EEex/EEex.tp2 | 13 ++ EEex/copy/EEex_scripts/EEex_Fix.lua | 4 + EEex/copy/EEex_scripts/EEex_Fix_Patch.lua | 163 ++++++++++++++++++ .../party_xp_arithmetic_and_formatting.tra | 2 + .../party_xp_arithmetic_and_formatting.tra | 2 + EEex/loader/v2.7.3.0/pattern_dbs/v2.7.3.0.db | 28 +++ 7 files changed, 214 insertions(+) create mode 100644 EEex/language/english/party_xp_arithmetic_and_formatting.tra create mode 100644 EEex/language/italian/party_xp_arithmetic_and_formatting.tra diff --git a/.gitignore b/.gitignore index 1b85ce38..9c579854 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ !/EEex/language/schinese/*.* !/EEex/language/spanish !/EEex/language/spanish/*.* +!/EEex/language/italian +!/EEex/language/italian/*.* !/EEex/lib !/EEex/lib/*.* !/EEex/loader diff --git a/EEex/EEex.tp2 b/EEex/EEex.tp2 index 94b6aa6a..385bf001 100644 --- a/EEex/EEex.tp2 +++ b/EEex/EEex.tp2 @@ -130,6 +130,19 @@ REQUIRE_PREDICATE MOD_IS_INSTALLED ~EEex.tp2~ 0 @1001 // This component requires COPY ~EEex/loader/%game_version%/pattern_dbs~ ~EEex_scripts/extra_pattern_dbs~ COPY ~EEex/loader/%game_version%/%game_folder%~ ~EEex_scripts/profiler~ + // Allocate a translatable TLK entry. Its index belongs to this installation + // and must never be baked into a runtime hook or pattern database. + WITH_TRA "EEex\language\english\party_xp_arithmetic_and_formatting.tra" "EEex\language\%LANGUAGE%\party_xp_arithmetic_and_formatting.tra" BEGIN + WITH_SCOPE BEGIN + ACTION_IF ~%game_version%~ STRING_EQUAL ~v2.7.3.0~ BEGIN + OUTER_SET "EEex_xp_loss_strref" = RESOLVE_STR_REF (@0) + COPY ~EEex_scripts\EEex_Fix.lua~ ~EEex_scripts~ + REPLACE_TEXTUALLY CASE_SENSITIVE EXACT_MATCH ~EEex_Fix_Private_ExperienceLossStrref = -1~ ~EEex_Fix_Private_ExperienceLossStrref = %EEex_xp_loss_strref%~ + BUT_ONLY + END + END + END + //////////////////////////////////////////////////////////////////////////////////////////// // Enable attack info module: Show to-hit chances when hovering attack cursor over target // //////////////////////////////////////////////////////////////////////////////////////////// diff --git a/EEex/copy/EEex_scripts/EEex_Fix.lua b/EEex/copy/EEex_scripts/EEex_Fix.lua index e82bdff0..40bb3098 100644 --- a/EEex/copy/EEex_scripts/EEex_Fix.lua +++ b/EEex/copy/EEex_scripts/EEex_Fix.lua @@ -1,3 +1,7 @@ +-- Assigned by WeiDU when installing EEex. +-- A v2.7.3.0 runtime without an assigned TLK reference fails before installing any XP hook. +-- Older databases skip it. +EEex_Fix_Private_ExperienceLossStrref = -1 ---------------------------------------------------------------------------------------------------------- -- Fix quick spell slots not updating when a special ability is added (for example, by op171 or act279) -- diff --git a/EEex/copy/EEex_scripts/EEex_Fix_Patch.lua b/EEex/copy/EEex_scripts/EEex_Fix_Patch.lua index c3eccba8..6afe7118 100644 --- a/EEex/copy/EEex_scripts/EEex_Fix_Patch.lua +++ b/EEex/copy/EEex_scripts/EEex_Fix_Patch.lua @@ -3,6 +3,169 @@ EEex_DisableCodeProtection() + --[[ + +-----------------------------------------------------------------------------+ + | Fix signed XP losses in the three x64 v2.7.3.0 engines. The permanent op104 | + | ADD can underflow; character feedback always chooses the gain string; party | + | distribution uses unsigned DIV and consumes only positive remainders. | + +-----------------------------------------------------------------------------+ + | C++ owns the arithmetic and formatting. ASM only adapts verified operands. | + +-----------------------------------------------------------------------------+ + --]] + + EEex_Utility_NewScope(function() + + local names = { + "Hook-CGameEffectXP::ApplyEffect()-PermanentAdd", + "Hook-CGameSprite::FeedBack()-ExperienceFormatCall", + "Hook-CGameSprite::FeedBack()-ExperienceFetchCall", + "Hook-CInfGame::AddPartyXP()-SignedSplit", + "Hook-CInfGame::AddPartyXP()-SignedRemainder", + "Hook-CInfGame::FeedBack()-ExperienceFormatCall", + } + local sites, present = {}, 0 + for i, name in ipairs(names) do + sites[i] = EEex_TryLabel(name) + if sites[i] == 0 then EEex_Error("XP loss fix: null "..name) end + if sites[i] ~= nil then present = present + 1 end + end + -- The shared scripts also run with v2.6.6.0 databases and DLLs. None of + -- their sites are advertised, so do not ask them for the new helpers. + if present == 0 then return end + if present ~= #names then EEex_Error("XP loss fix: incomplete pattern database") end + + local function requireLabel(name) + local value = EEex_TryLabel(name) + if value == nil or value == 0 then EEex_Error("XP loss fix: missing "..name) end + return value + end + for _, name in ipairs({ + "EEex::Fix_Hook_ClampXPLoss", "EEex::Fix_Hook_FormatExperienceAmount", + "EEex::Fix_Hook_SplitPartyXP", "EEex::Fix_Hook_NextPartyXPShare", + }) do requireLabel(name) end + local formatTarget = requireLabel("CString::Format") + local fetchTarget = requireLabel("CTlkTable::Fetch") + local lossStrref = EEex_Fix_Private_ExperienceLossStrref + if type(lossStrref) ~= "number" or lossStrref ~= math.floor(lossStrref) + or lossStrref < 0 or lossStrref > 0xFFFFFF then + EEex_Error("XP loss fix: reinstall EEex to assign its loss-message TLK reference") + end + + local function expectBytes(address, hex) + for i = 1, #hex, 2 do + if EEex_ReadU8(address + (i - 1) / 2) ~= tonumber(hex:sub(i, i + 1), 16) then + EEex_Error("XP loss fix: unexpected instruction bytes") + end + end + end + local function expectCall(address, target) + expectBytes(address, "E8") + if address + 5 + EEex_Read32(address + 1) ~= target then + EEex_Error("XP loss fix: unexpected native call target") + end + end + local function expectFormatPointer(address) + expectBytes(address, "488D15") -- lea rdx, [rip+disp32] + expectBytes(address + 7 + EEex_Read32(address + 3), "256400") -- %d\0 + end + + -- Preflight ALL six contracts before allocating/writing the first hook. + -- Relative operands are decoded, not compared to one game's raw addresses. + local xpOffset = EEex_OffsetOf("CGameSprite.m_baseStats.m_xp") + expectBytes(sites[1] - 11, "4183782401418B401C7522") + expectBytes(sites[1], "0182") + if EEex_Read32(sites[1] + 2) ~= xpOffset then EEex_Error("XP loss fix: XP offset differs") end + expectBytes(sites[1] + 6, "B80100000041C780180100000100000041C7801401000001000000C3") + expectBytes(sites[2] - 14, "458BC7") -- r8d = original signed int1 in r15d + expectFormatPointer(sites[2] - 11) + expectBytes(sites[2] - 4, "488D4D97") -- existing CString local + expectCall(sites[2], formatTarget) + expectBytes(sites[3] - 19, "4C8D45CF4881C1501100004533C9BA9E00F000") + expectCall(sites[3], fetchTarget) + expectBytes(sites[4] - 4, "410FBFC9") -- ecx = native positive recipient count + expectBytes(sites[4], "33D28BC3F7F1") + expectBytes(sites[4] + 6, "488D4C24408BEA448BF8BA68000000") + expectBytes(sites[5], "6685ED7E0D418D470166FFCD89442444EB0544897C2444") + expectBytes(sites[5] + 23, "4C8D4C2430") + expectFormatPointer(sites[6] - 23) + expectBytes(sites[6] - 16, "F7D8488D4DB74585F6440F4EF0458BC6") + expectCall(sites[6], formatTarget) + + -- These three helpers are verified integer-only leaves in the built DLL. + -- Save every volatile integer register except RAX (the result), and save + -- flags outside the callee's 32-byte shadow space. The macros account for + -- the leaf effect's RSP%16 == 8 and the party routine's aligned frame. + local function arithmeticCall(helper, args, result) + return EEex_FlattenTable({{[[ + #MAKE_SHADOW_SPACE(56) + mov qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-8)], rcx + mov qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-16)], rdx + mov qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-24)], r8 + mov qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-32)], r9 + mov qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-40)], r10 + mov qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-48)], r11 + pushfq + pop r11 + mov qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-56)], r11 + ]]}, args, {"call #L("..helper..") #ENDL"}, {[[ + mov rcx, qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-8)] + mov rdx, qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-16)] + mov r8, qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-24)] + mov r9, qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-32)] + mov r10, qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-40)] + mov r11, qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-48)] + ]]}, result or {}, {[[ + push qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-56)] + popfq + #DESTROY_SHADOW_SPACE + ]]}}) + end + local registers = EEex_HookIntegrityWatchdogRegister + EEex_HookBeforeRestoreWithLabels(sites[1], 0, 6, 6, { + {"stack_mod", 8}, {"hook_integrity_watchdog_ignore_registers", {registers.RAX}}}, + arithmeticCall("EEex::Fix_Hook_ClampXPLoss", { + "mov ecx, dword ptr ds:[rdx+#$(1)] #ENDL", {xpOffset}, "mov edx, eax #ENDL", + }) + ) + + -- Both are call replacements with the original RCX/RDX/R8 arguments and + -- caller-provided shadow space. C++ forwards to the bound engine formatter; + -- no extra frame, allocation policy, or character-name handling is invented. + for _, site in ipairs({sites[2], sites[6]}) do + EEex_HookRemoveCallWithLabels(site, {}, {"call #L(EEex::Fix_Hook_FormatExperienceAmount) #ENDL"}) + end + EEex_HookBeforeCallWithLabels(sites[3], { + {"hook_integrity_watchdog_ignore_registers", {registers.RDX}}}, + {"pushfq #ENDL test r15d, r15d #ENDL jns keep_gain #ENDL mov edx, #$(1) #ENDL keep_gain: #ENDL popfq #ENDL", {lossStrref}} + ) + + -- Replace XOR/MOV/DIV, then unpack the proven eight-byte Win64 result + -- into the original EAX quotient / EDX remainder. The native following + -- instructions still copy them to R15D / EBP and construct the XP effect. + EEex_HookBeforeRestoreWithLabels(sites[4], 0, 0, 6, { + {"hook_integrity_watchdog_ignore_registers", {registers.RAX, registers.RDX}}}, + arithmeticCall("EEex::Fix_Hook_SplitPartyXP", {"mov edx, ecx #ENDL mov ecx, ebx #ENDL"}, { + "mov rdx, rax #ENDL shr rdx, 32 #ENDL mov eax, eax #ENDL", + }) + ) + + -- Replace the WHOLE remainder branch, rather than relocating its relative + -- jumps. No outside branch enters its interior (proved by the audit). + -- RSP+44h is this routine's verified Item_effect_st.effectAmount local; + -- LAST_FRAME_TOP compensates for the helper frame. R15D remains the base + -- quotient. EAX is dead after the store; EBP becomes the next remainder. + EEex_HookBeforeRestoreWithLabels(sites[5], 0, 0, 23, { + {"hook_integrity_watchdog_ignore_registers", {registers.RAX, registers.RBP}}}, + arithmeticCall("EEex::Fix_Hook_NextPartyXPShare", {"mov ecx, r15d #ENDL mov edx, ebp #ENDL"}, { + "mov dword ptr ss:[rsp+#LAST_FRAME_TOP(44h)], eax #ENDL shr rax, 32 #ENDL mov ebp, eax #ENDL", + }) + ) + + -- This replacement writes the engine local before the watchdog exit; + -- permit exactly that DWORD, in addition to the ordinary caller shadow. + EEex_HookIntegrityWatchdog_IgnoreStackSizes(sites[5], {{0x44, 4}}) + end) + --[[ +-------------------------------------------------------------------------------------------------------------------------+ | Implement the missing WSPECIAL.2DA["SPEED"] bonus | diff --git a/EEex/language/english/party_xp_arithmetic_and_formatting.tra b/EEex/language/english/party_xp_arithmetic_and_formatting.tra new file mode 100644 index 00000000..c3926776 --- /dev/null +++ b/EEex/language/english/party_xp_arithmetic_and_formatting.tra @@ -0,0 +1,2 @@ +// Character XP feedback: the engine supplies the name and expands the token. +@0 = ~has lost quest experience points.~ diff --git a/EEex/language/italian/party_xp_arithmetic_and_formatting.tra b/EEex/language/italian/party_xp_arithmetic_and_formatting.tra new file mode 100644 index 00000000..ef9b89d5 --- /dev/null +++ b/EEex/language/italian/party_xp_arithmetic_and_formatting.tra @@ -0,0 +1,2 @@ +// Character XP feedback: the engine supplies the name and expands the token. +@0 = ~ha perso punti esperienza per la missione.~ diff --git a/EEex/loader/v2.7.3.0/pattern_dbs/v2.7.3.0.db b/EEex/loader/v2.7.3.0/pattern_dbs/v2.7.3.0.db index 690eef8f..9fcf8328 100644 --- a/EEex/loader/v2.7.3.0/pattern_dbs/v2.7.3.0.db +++ b/EEex/loader/v2.7.3.0/pattern_dbs/v2.7.3.0.db @@ -38,3 +38,31 @@ Operations=ADD 17 [Hook-drawItem()-FixForcedScrollbarDivideByZero2] Pattern=8B5DDC488D55E0 Operations=ADD 10 + +; Signed XP losses. Derived minima are exact instruction-aligned windows in +; the containing PDB contribution, unique in all three v2.7.3.0 executables. +; Stable internal branch bytes may identify a site; no branch is relocated. + +[Hook-CGameEffectXP::ApplyEffect()-PermanentAdd] +Pattern=418B481C +Operations=ADD 132 + +[Hook-CGameSprite::FeedBack()-ExperienceFormatCall] +Pattern=8D4B42 +Operations=ADD 10609 + +[Hook-CGameSprite::FeedBack()-ExperienceFetchCall] +Pattern=8D4B42 +Operations=ADD 10681 + +[Hook-CInfGame::AddPartyXP()-SignedSplit] +Pattern=74478BC8 +Operations=ADD 102 + +[Hook-CInfGame::AddPartyXP()-SignedRemainder] +Pattern=EBDA33D2 +Operations=ADD -160 + +[Hook-CInfGame::FeedBack()-ExperienceFormatCall] +Pattern=41B03A +Operations=ADD -1617