From 73a0048aad04490ccd413dcad4b59298ff89fe0a Mon Sep 17 00:00:00 2001 From: 4Luke4 Date: Fri, 6 Mar 2026 19:55:13 +0100 Subject: [PATCH] New feature "CLSSPLAB.2DA": Add support for op6/10/19/49. --- EEex/copy/CLSSPLAB.2DA | 25 +++++ EEex/copy/EEex_Opcode.lua | 95 ++++++++++++++++ EEex/copy/EEex_Opcode_Patch.lua | 191 ++++++++++++++++++++++++++++++++ EEex/loader/InfinityLoader.db | 29 +++++ 4 files changed, 340 insertions(+) create mode 100644 EEex/copy/CLSSPLAB.2DA diff --git a/EEex/copy/CLSSPLAB.2DA b/EEex/copy/CLSSPLAB.2DA new file mode 100644 index 0000000..1679ce0 --- /dev/null +++ b/EEex/copy/CLSSPLAB.2DA @@ -0,0 +1,25 @@ +2DA V1.0 +6 + RESERVED STR STREX CON INT WIS DEX CHR +UNUSED 0 6 0 6 6 6 6 6 +MAGE 0 4 0 4 8 4 6 5 +FIGHTER 0 8 100 8 4 4 6 4 +CLERIC 0 6 0 6 4 8 4 6 +THIEF 0 6 0 5 6 4 8 5 +BARD 0 6 0 5 6 5 8 8 +PALADIN 0 8 100 8 4 7 6 8 +FIGHTER_MAGE 0 8 100 7 8 4 6 5 +FIGHTER_CLERIC 0 8 100 8 4 8 6 6 +FIGHTER_THIEF 0 8 100 7 6 4 8 5 +FIGHTER_MAGE_THIEF 0 8 100 7 7 4 8 6 +DRUID 0 6 0 6 4 8 4 6 +RANGER 0 8 100 8 4 7 6 6 +MAGE_THIEF 0 6 0 5 8 4 8 5 +CLERIC_MAGE 0 6 0 5 7 7 6 6 +CLERIC_THIEF 0 6 0 6 5 7 8 6 +FIGHTER_DRUID 0 8 100 8 4 8 6 6 +FIGHTER_MAGE_CLERIC 0 8 100 7 6 7 6 6 +CLERIC_RANGER 0 8 100 7 4 8 6 6 +SORCERER 0 4 0 4 7 4 6 8 +MONK 0 6 0 6 5 8 4 5 +SHAMAN 0 6 0 5 4 8 4 7 diff --git a/EEex/copy/EEex_Opcode.lua b/EEex/copy/EEex_Opcode.lua index e92b6d2..074280c 100644 --- a/EEex/copy/EEex_Opcode.lua +++ b/EEex/copy/EEex_Opcode.lua @@ -5,6 +5,101 @@ EEex_Opcode_ListsResolvedListeners = {} +-- Mode-3 stat bonuses for op6/op10/op15/op19/op44/op49 ultimately flow through +-- CRuleTables::GetSpellAbilityValue(), which indexes CLSSPLAB by numeric row / +-- column rather than by symbolic labels. Keep the full schema contract here so +-- Lua and the patch layer both talk about the same layout. +EEex_Opcode_ClassSpellAbilityTable = { + ["columns"] = { + -- Slot 0 must exist because the native STR / DEX helpers reference columns + -- 1 and 6 respectively. The name is not semantically important; the index is. + "RESERVED", + "STR", + -- Slot 2 is likewise structural padding. We opted for the STREX name so the + -- shipped table still looks familiar to modders. + "STREX", + "CON", + "INT", + "WIS", + "DEX", + "CHR", + }, + ["columnIndices"] = { + -- These are zero-based indexes as consumed by CRuleTables::GetSpellAbilityValue(). + -- They are intentionally sparse because columns 0 and 2 are reserved by layout. + ["STR"] = 1, + ["CON"] = 3, + ["INT"] = 4, + ["WIS"] = 5, + ["DEX"] = 6, + ["CHR"] = 7, + }, + ["rows"] = { + -- Row order also matters. The engine derives a class byte, then uses that byte + -- as a direct row index into CLSSPLAB. + "UNUSED", + "MAGE", + "FIGHTER", + "CLERIC", + "THIEF", + "BARD", + "PALADIN", + "FIGHTER_MAGE", + "FIGHTER_CLERIC", + "FIGHTER_THIEF", + "FIGHTER_MAGE_THIEF", + "DRUID", + "RANGER", + "MAGE_THIEF", + "CLERIC_MAGE", + "CLERIC_THIEF", + "FIGHTER_DRUID", + "FIGHTER_MAGE_CLERIC", + "CLERIC_RANGER", + "SORCERER", + "MONK", + "SHAMAN", + }, +} + +function EEex_Opcode_Private_ValidateClassSpellAbilityTable() + + local array = EEex_Resource_Load2DA("CLSSPLAB") + if not array then + EEex_Error("[EEex_Opcode] Missing CLSSPLAB.2DA; install the EEex copy before using mode-3 class spell bonuses.") + end + + -- Fail fast on schema drift. If another override reorders columns, the engine + -- would silently read the wrong stat budgets unless we stop here. + for index, expectedLabel in ipairs(EEex_Opcode_ClassSpellAbilityTable["columns"]) do + local actualIndex = array:findColumnLabel(expectedLabel) + local wantedIndex = index - 1 + if actualIndex ~= wantedIndex then + EEex_Error(string.format( + "[EEex_Opcode] CLSSPLAB.2DA column '%s' must be at zero-based index %d, found %d.", + expectedLabel, wantedIndex, actualIndex + )) + end + end + + -- Row labels are validated for the same reason: native code passes a numeric class + -- id, not a row label, so row order must stay synchronized with the executable. + for index, expectedLabel in ipairs(EEex_Opcode_ClassSpellAbilityTable["rows"]) do + local actualLabel = array:getRowLabel(index - 1) + if actualLabel ~= expectedLabel then + EEex_Error(string.format( + "[EEex_Opcode] CLSSPLAB.2DA row %d must be '%s', found '%s'.", + index - 1, expectedLabel, actualLabel + )) + end + end +end + +EEex_GameState_AddInitializedListener(function() + -- Run once the game is initialized enough for resource loading to be reliable. + EEex_Opcode_Private_ValidateClassSpellAbilityTable() +end) + function EEex_Opcode_AddListsResolvedListener(func) -- [EEex.dll] EEex.Opcode_LuaHook_AfterListsResolved_Enabled = true diff --git a/EEex/copy/EEex_Opcode_Patch.lua b/EEex/copy/EEex_Opcode_Patch.lua index 0167106..e95cff5 100644 --- a/EEex/copy/EEex_Opcode_Patch.lua +++ b/EEex/copy/EEex_Opcode_Patch.lua @@ -148,6 +148,197 @@ EEex_HookIntegrityWatchdogRegister.R11 }) + --[[ + +--------------------------------------------------------------------------------------------------------------------+ + | Opcodes #6 / #10 / #19 / #49 | + +--------------------------------------------------------------------------------------------------------------------+ + | param2 == 3 -> Roll a class-based stat bonus from CLSSPLAB.2DA, using the same native helpers that opcode #15 | + | (DEX) and opcode #44 (STR) already rely on. | + | | + | The CLSSPLAB schema is validated in EEex_Opcode.lua. Column indexes are zero-based and must remain stable. | + +--------------------------------------------------------------------------------------------------------------------+ + --]] + + local function EEex_Opcode_Private_AssertLabelBytes(label, expectedBytes) + -- Loader patterns intentionally give us symbolic entry points instead of RVAs. + -- We still assert the first bytes here so the Lua patch fails loudly if a + -- future executable keeps the same pattern label but lands on an unexpected + -- instruction sequence that would invalidate the hook assumptions below. + local address = EEex_Label(label) + for i, expectedByte in ipairs(expectedBytes) do + local actualByte = EEex_ReadU8(address + i - 1) + if actualByte ~= expectedByte then + EEex_Error(string.format( + "[EEex_Opcode_Patch] %s mismatch at %s+0x%X: expected %s, found %s.", + label, EEex_ToHex(address), i - 1, EEex_ToHex(expectedByte, 2), EEex_ToHex(actualByte, 2) + )) + end + end + end + + local classSpellColumns = EEex_Opcode_ClassSpellAbilityTable["columnIndices"] + + -- These helpers are the same native building blocks used by opcode 15 / 44. + -- Reusing them keeps row lookup and class-to-row mapping consistent with the + -- engine instead of reimplementing that logic in Lua. + EEex_Opcode_Private_AssertLabelBytes("CAIObjectType::GetClass", { + 0x0F, 0xB6, 0x41, 0x0B, 0xC3 + }) + EEex_Opcode_Private_AssertLabelBytes("CRuleTables::GetSpellAbilityValue", { + 0x4C, 0x8B, 0xC9, 0x0F, 0xBF, 0x89, 0x88, 0x38, 0x00, 0x00 + }) + EEex_Opcode_Private_AssertLabelBytes("Hook-CGameEffectCHR::ApplyEffect()-Entry", { + 0x40, 0x55, 0x53, 0x56, 0x57 + }) + EEex_Opcode_Private_AssertLabelBytes("Hook-CGameEffectCON::ApplyEffect()-Entry", { + 0x40, 0x55, 0x53, 0x56, 0x57 + }) + EEex_Opcode_Private_AssertLabelBytes("Hook-CGameEffectINT::ApplyEffect()-Entry", { + 0x40, 0x55, 0x53, 0x56, 0x57 + }) + EEex_Opcode_Private_AssertLabelBytes("Hook-CGameEffectWIS::ApplyEffect()-Entry", { + 0x40, 0x55, 0x53, 0x56, 0x57 + }) + + local classSpellMode3Entries = { + { + ["opcode"] = 6, + ["handlerLabel"] = "Hook-CGameEffectCHR::ApplyEffect()-Entry", + -- Stat offsets stay here because they are part of the handler ABI we are + -- emulating, not addresses to discover. These were recovered from the + -- native opcode handlers and are specific to the current engine layout. + ["statOffset"] = 0x796, + ["statCap"] = 20, + ["classSpellColumn"] = classSpellColumns["CHR"], + }, + { + ["opcode"] = 10, + ["handlerLabel"] = "Hook-CGameEffectCON::ApplyEffect()-Entry", + ["statOffset"] = 0x795, + ["statCap"] = 20, + ["classSpellColumn"] = classSpellColumns["CON"], + }, + { + ["opcode"] = 19, + ["handlerLabel"] = "Hook-CGameEffectINT::ApplyEffect()-Entry", + ["statOffset"] = 0x792, + ["statCap"] = 20, + ["classSpellColumn"] = classSpellColumns["INT"], + }, + { + ["opcode"] = 49, + ["handlerLabel"] = "Hook-CGameEffectWIS::ApplyEffect()-Entry", + ["statOffset"] = 0x793, + ["statCap"] = 20, + ["classSpellColumn"] = classSpellColumns["WIS"], + }, + } + + local function EEex_Opcode_Private_InstallClassSpellMode3(entry) + -- Hook at function entry, but only special-case param2 == 3. All other modes + -- must continue through the untouched native implementation. + -- + -- EEex_HookBeforeRestoreWithLabels is the right fit here because we need to: + -- 1) inspect / rewrite the incoming effect before the handler's own logic branches on param2 + -- 2) preserve the original function body after our rewrite + -- 3) resume normal control flow with the stolen entry bytes restored + -- + -- In other words, this is an entry shim, not a tail hook and not a full handler replacement. + -- + -- The numeric arguments here are: + -- 0 -> restoreDelay: start stealing / restoring bytes at the first instruction + -- 5 -> restoreSize: overwrite exactly the 5-byte function prologue we validated above + -- 5 -> returnDelay: jump back immediately after those same 5 bytes + EEex_HookBeforeRestoreWithLabels(EEex_Label(entry["handlerLabel"]), 0, 5, 5, { + -- We arrive here via a jump inserted at the original function entry, so the + -- missing return address means rsp is effectively 8 bytes "higher" than a + -- normal call frame. stack_mod tells the assembly preprocessor about that + -- difference so shadow-space / alignment helpers still compute correctly. + {"stack_mod", 8}, + {"hook_integrity_watchdog_ignore_registers", { + EEex_HookIntegrityWatchdogRegister.RAX, EEex_HookIntegrityWatchdogRegister.RCX, EEex_HookIntegrityWatchdogRegister.RDX, + EEex_HookIntegrityWatchdogRegister.R8, EEex_HookIntegrityWatchdogRegister.R9, EEex_HookIntegrityWatchdogRegister.R10, + EEex_HookIntegrityWatchdogRegister.R11 + }}}, + EEex_FlattenTable({ + {[[ + cmp dword ptr ds:[rcx+0x20], 3 + jne #L(return) + + ; Save effect / sprite so we can rewrite param1+param2 in-place and + ; then resume execution through the normal mode-0 branch. + #MAKE_SHADOW_SPACE(32) + mov qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-8)], rcx + mov qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-16)], rdx + + ; Native opcode 15 / 44 mode-3 logic refuses to increase the stat + ; past its natural cap. We mirror that behavior here. + movzx eax, byte ptr ds:[rdx+#$(1)] ]], {entry["statOffset"]}, [[ #ENDL + mov dword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-24)], eax + cmp eax, #$(1) ]], {entry["statCap"]}, [[ #ENDL + jge set_zero + + ; Call the sprite virtual that returns the CAIObjectType, then ask + ; the engine for its class byte. This preserves any engine-specific + ; class normalization before CLSSPLAB is indexed. + mov rcx, qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-16)] + mov rax, qword ptr ds:[rcx] + call qword ptr ds:[rax+0x20] + + mov rcx, rax + call #L(CAIObjectType::GetClass) + + mov r10, qword ptr ds:[#L(g_pBaldurChitin)] + mov rcx, qword ptr ds:[r10+0x1090] + movzx edx, al + mov r8d, #$(1) ]], {entry["classSpellColumn"]}, [[ #ENDL + call #L(CRuleTables::GetSpellAbilityValue) + + ; Treat non-positive table values as "no bonus". This matches the + ; defensive behavior we want if a row / column is intentionally zeroed. + test eax, eax + jle set_zero + + ; The engine scales rand() into 1..N with the classic 0x7FFF mask + ; and arithmetic shift. We keep the exact formula so the bonus + ; distribution matches opcode 15 / 44. + mov dword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-32)], eax + call #L(rand) + and eax, 0x7FFF + imul eax, dword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-32)] + sar eax, 0x0F + inc eax + + mov ecx, dword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-24)] + add ecx, eax + cmp ecx, #$(1) ]], {entry["statCap"]}, [[ #ENDL + jle amount_ready + + mov eax, #$(1) ]], {entry["statCap"]}, [[ #ENDL + sub eax, dword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-24)] + jmp amount_ready + + set_zero: + ; Rewrite mode-3 into an empty mode-0 adjustment rather than trying + ; to skip the handler. That lets the native tail continue to own all + ; side effects, dirty flags, and stat-specific bookkeeping. + xor eax, eax + + amount_ready: + mov rcx, qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-8)] + mov dword ptr ds:[rcx+0x1C], eax + mov dword ptr ds:[rcx+0x20], 0 + mov rdx, qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-16)] + #DESTROY_SHADOW_SPACE + ]]}, + }) + ) + end + + for _, entry in ipairs(classSpellMode3Entries) do + EEex_Opcode_Private_InstallClassSpellMode3(entry) + end + --[[ +------------------------------------------------------------------------------------------------------------------------------------------+ | Opcode #248 | diff --git a/EEex/loader/InfinityLoader.db b/EEex/loader/InfinityLoader.db index 4d0aa32..57f376c 100644 --- a/EEex/loader/InfinityLoader.db +++ b/EEex/loader/InfinityLoader.db @@ -119,6 +119,11 @@ Operations=ADD -22 Pattern=4883EC20410FB6F8 Operations=ADD -6 +; Tiny leaf helper consumed by EEex_Opcode_Patch.lua when emulating native +; CLSSPLAB-driven mode-3 stat rolls for op6/op10/op19/op49. +[CAIObjectType::GetClass] +Pattern=0FB6410BC3CCCCCCCCCCCCCC + [CAIObjectType::OfType] Pattern=415441574883EC30488BFA Operations=ADD -4 @@ -493,6 +498,11 @@ Operations=ADD -2 [CRuleTables::MapCharacterSpecializationToSchool] Pattern=81FA000800007736 +; Native CLSSPLAB accessor used by opcode 15 / 44. Exposing it here lets the +; Lua patch reuse engine row/column indexing instead of duplicating the table logic. +[CRuleTables::GetSpellAbilityValue] +Pattern=4C8BC90FBF8988380000443BC17D31 + [CScreenWorld::TogglePauseGame] Pattern=48895C241855565741564157488D6C24D9 @@ -1787,6 +1797,17 @@ Pattern=488BD84C8B7C2460 Pattern=F6C301740DBA58010000 Operations=ADD -5 +; Entry labels for handlers that do not natively implement param2 == 3. +; The opcode patch hooks at the first instruction, rewrites mode-3 into mode-0, +; then resumes through the original handler body. +[Hook-CGameEffectCHR::ApplyEffect()-Entry] +Pattern=83F9010F853F0100000FB68296070000 +Operations=ADD -70 + +[Hook-CGameEffectCON::ApplyEffect()-Entry] +Pattern=83F9010F853F0100000FB68295070000 +Operations=ADD -70 + [Hook-CGameEffectApplyEffectEquipItem::ApplyEffect()-CheckRetVal] Pattern=6685C00F848E0000004885DB Operations=ADD 3 @@ -1795,6 +1816,10 @@ Operations=ADD 3 Pattern=4C8BCF8B5320 Operations=ADD 9 +[Hook-CGameEffectINT::ApplyEffect()-Entry] +Pattern=83F9010F853F0100000FB68292070000 +Operations=ADD -70 + [Hook-CGameEffectCopySelf::ApplyEffect()-CGameSprite::Copy()] Pattern=4533C948899C2440010000 Operations=ADD 64 @@ -1838,6 +1863,10 @@ Operations=ADD 20 [Hook-CGameEffectSecondaryCastList::ApplyEffect()] Pattern=48895C24185556574154415541564157488D6C24D94881EC00010000 +[Hook-CGameEffectWIS::ApplyEffect()-Entry] +Pattern=83F9010F853F0100000FB68293070000 +Operations=ADD -70 + [Hook-CGameEffectStaticCharge::ApplyEffect()-CopyOp333Call] Pattern=FF50088B9350010000