Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions EEex/copy/CLSSPLAB.2DA
Original file line number Diff line number Diff line change
@@ -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
95 changes: 95 additions & 0 deletions EEex/copy/EEex_Opcode.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
191 changes: 191 additions & 0 deletions EEex/copy/EEex_Opcode_Patch.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
29 changes: 29 additions & 0 deletions EEex/loader/InfinityLoader.db
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down