From c9f2eb102d98382974b859224771e08eb0e5f7f3 Mon Sep 17 00:00:00 2001 From: 4Luke4 Date: Fri, 1 May 2026 20:29:24 +0200 Subject: [PATCH 1/4] See PR for further details --- EEex/copy/EEex_scripts/EEex_Assembly.lua | 94 +++ EEex/copy/EEex_scripts/EEex_EarlyMain.lua | 2 + EEex/copy/EEex_scripts/EEex_GameObject.lua | 78 +++ EEex/copy/EEex_scripts/EEex_LuaModule.lua | 376 ++++++++++++ EEex/copy/EEex_scripts/EEex_Main.lua | 2 + EEex/copy/EEex_scripts/EEex_Projectile.lua | 260 +++++++++ EEex/copy/EEex_scripts/EEex_Resource.lua | 73 ++- EEex/copy/EEex_scripts/EEex_Sprite.lua | 633 +++++++++++++++++++++ EEex/copy/EEex_scripts/EEex_Utility.lua | 101 ++++ EEex/copy/override/M___EEex.lua | 102 ++++ 10 files changed, 1720 insertions(+), 1 deletion(-) create mode 100644 EEex/copy/EEex_scripts/EEex_LuaModule.lua diff --git a/EEex/copy/EEex_scripts/EEex_Assembly.lua b/EEex/copy/EEex_scripts/EEex_Assembly.lua index ed7adb4..fc49fd7 100644 --- a/EEex/copy/EEex_scripts/EEex_Assembly.lua +++ b/EEex/copy/EEex_scripts/EEex_Assembly.lua @@ -53,6 +53,20 @@ function EEex_IsMaskUnset(original, isUnsetMask) return EEex_BAnd(original, isUnsetMask) == 0x0 end +-- The classic bit trick for "exactly one bit set" is: +-- mask the bits you care about, then check that ``x & (x-1) == 0`` +-- (which clears the lowest set bit — if the result is zero, there was only one bit set to begin with) + +function EEex_IsExactlyOneBitSet(original, isSetMask) + local masked = EEex_BAnd(original, isSetMask) + return masked ~= 0 and EEex_BAnd(masked, masked - 1) == 0 +end + +function EEex_IsAtMostOneBitSet(original, isSetMask) + local masked = EEex_BAnd(original, isSetMask) + return EEex_BAnd(masked, masked - 1) == 0 +end + function EEex_SetBit(original, toSetIndex) return EEex_BOr(original, EEex_LShift(0x1, toSetIndex)) end @@ -103,6 +117,86 @@ function EEex_UnsetMask(original, toUnsetmask) return EEex_BAnd(original, EEex_BNot(toUnsetmask)) end +-- Combines two byte components into an unsigned 16-bit word. +-- Each component accepts both signed [-128, 127] and unsigned [0, 255] values. +-- Negative inputs are treated as two's complement and normalized before packing: +-- e.g. -1 → 0xFF (255), -128 → 0x80 (128) +-- Always returns an unsigned value in [0, 65535]. +function EEex_PackWord(lowByte, highByte) + if type(lowByte) ~= "number" then EEex_Error("lowByte must be a number") end + if type(highByte) ~= "number" then EEex_Error("highByte must be a number") end + lowByte = math.floor(lowByte) + highByte = math.floor(highByte) + -- Accept both signed and unsigned ranges + if lowByte < -128 or lowByte > 0xFF then EEex_Error("lowByte out-of-bounds (expected [-128, 255])") end + if highByte < -128 or highByte > 0xFF then EEex_Error("highByte out-of-bounds (expected [-128, 255])") end + -- Normalize signed two's complement: -1 → 255, -128 → 128 + if lowByte < 0 then lowByte = lowByte + 0x100 end + if highByte < 0 then highByte = highByte + 0x100 end + return lowByte + highByte * 0x100 +end + +-- Splits a 16-bit word into its low and high byte components. +-- The optional `signed` parameter (default: false) controls how the returned +-- bytes are interpreted: +-- signed=false → both bytes are unsigned [0, 255] +-- signed=true → both bytes use signed two's complement [-128, 127] +-- e.g. 0xFF → -1, 0x80 → -128 +-- Input always accepts the full unsigned range [0, 65535]. +function EEex_UnpackWord(word, signed) + if type(word) ~= "number" then EEex_Error("word must be a number") end + word = math.floor(word) + if word < 0 or word > 0xFFFF then EEex_Error("word out-of-bounds (expected [0, 65535])") end + local lowByte = word % 0x100 + local highByte = math.floor(word / 0x100) + if signed then + -- Reinterpret as signed: values above 0x7F wrap to negative + if lowByte > 0x7F then lowByte = lowByte - 0x100 end + if highByte > 0x7F then highByte = highByte - 0x100 end + end + return lowByte, highByte +end + +-- Combines two 16-bit word components into an unsigned 32-bit dword. +-- Each component accepts both signed [-32768, 32767] and unsigned [0, 65535] values. +-- Negative inputs are treated as two's complement and normalized before packing: +-- e.g. -1 → 0xFFFF (65535), -32768 → 0x8000 (32768) +-- Always returns an unsigned value in [0, 4294967295]. +function EEex_PackDWord(lowWord, highWord) + if type(lowWord) ~= "number" then EEex_Error("lowWord must be a number") end + if type(highWord) ~= "number" then EEex_Error("highWord must be a number") end + lowWord = math.floor(lowWord) + highWord = math.floor(highWord) + -- Accept both signed and unsigned ranges + if lowWord < -32768 or lowWord > 0xFFFF then EEex_Error("lowWord out-of-bounds (expected [-32768, 65535])") end + if highWord < -32768 or highWord > 0xFFFF then EEex_Error("highWord out-of-bounds (expected [-32768, 65535])") end + -- Normalize signed two's complement: -1 → 65535, -32768 → 32768 + if lowWord < 0 then lowWord = lowWord + 0x10000 end + if highWord < 0 then highWord = highWord + 0x10000 end + return lowWord + highWord * 0x10000 +end + +-- Splits a 32-bit dword into its low and high 16-bit word components. +-- The optional `signed` parameter (default: false) controls how the returned +-- words are interpreted: +-- signed=false → both words are unsigned [0, 65535] +-- signed=true → both words use signed two's complement [-32768, 32767] +-- e.g. 0xFFFF → -1, 0x8000 → -32768 +-- Input always accepts the full unsigned range [0, 4294967295]. +function EEex_UnpackDWord(dword, signed) + if type(dword) ~= "number" then EEex_Error("dword must be a number") end + dword = math.floor(dword) + if dword < 0 or dword > 0xFFFFFFFF then EEex_Error("dword out-of-bounds (expected [0, 4294967295])") end + local lowWord = dword % 0x10000 + local highWord = math.floor(dword / 0x10000) + if signed then + -- Reinterpret as signed: values above 0x7FFF wrap to negative + if lowWord > 0x7FFF then lowWord = lowWord - 0x10000 end + if highWord > 0x7FFF then highWord = highWord - 0x10000 end + end + return lowWord, highWord +end + ------------------- -- Debug Utility -- ------------------- diff --git a/EEex/copy/EEex_scripts/EEex_EarlyMain.lua b/EEex/copy/EEex_scripts/EEex_EarlyMain.lua index 1d97a36..e8f2975 100644 --- a/EEex/copy/EEex_scripts/EEex_EarlyMain.lua +++ b/EEex/copy/EEex_scripts/EEex_EarlyMain.lua @@ -9,4 +9,6 @@ EEex_DoFile("EEex_Assembly_Patch") -- Replaces the statically compiled, in-exe Lua version with LuaLibrary. EEex_DoFile("EEex_ReplaceLua") + -- Build the verified module trust chain using the now-active external Lua runtime. + EEex_DoFile("EEex_LuaModule") end)() diff --git a/EEex/copy/EEex_scripts/EEex_GameObject.lua b/EEex/copy/EEex_scripts/EEex_GameObject.lua index 5bf7b88..90079f3 100644 --- a/EEex/copy/EEex_scripts/EEex_GameObject.lua +++ b/EEex/copy/EEex_scripts/EEex_GameObject.lua @@ -259,6 +259,45 @@ function EEex_GameObject_IsSpriteID(objectID, allowDead) return EEex_GameObject_IsSprite(EEex_GameObject_Get(objectID), allowDead) end +-- @bubb_doc { EEex_GameObject_GetEA / instance_name=getEA } +-- +-- @summary: Returns the given ``object``'s EA. +-- +-- @self { object / usertype=CGameObject }: The object whose EA is being fetched. +-- +-- @return { type=number }: See summary. + +function EEex_GameObject_GetEA(object) + return object.m_typeAI.m_EnemyAlly +end +CGameObject.getEA = EEex_GameObject_GetEA + +-- @bubb_doc { EEex_GameObject_GetGeneral / instance_name=getGeneral } +-- +-- @summary: Returns the given ``object``'s general. +-- +-- @self { object / usertype=CGameObject }: The object whose general is being fetched. +-- +-- @return { type=number }: See summary. + +function EEex_GameObject_GetGeneral(object) + return object.m_typeAI.m_General +end +CGameObject.getGeneral = EEex_GameObject_GetGeneral + +-- @bubb_doc { EEex_GameObject_GetRace / instance_name=getRace } +-- +-- @summary: Returns the given ``object``'s race. +-- +-- @self { object / usertype=CGameObject }: The object whose race is being fetched. +-- +-- @return { type=number }: See summary. + +function EEex_GameObject_GetRace(object) + return object.m_typeAI.m_Race +end +CGameObject.getRace = EEex_GameObject_GetRace + -- @bubb_doc { EEex_GameObject_GetClass / instance_name=getClass } -- -- @summary: Returns the given ``object``'s class. @@ -272,6 +311,45 @@ function EEex_GameObject_GetClass(object) end CGameObject.getClass = EEex_GameObject_GetClass +-- @bubb_doc { EEex_GameObject_GetSpecifics / instance_name=getSpecifics } +-- +-- @summary: Returns the given ``object``'s specifics. +-- +-- @self { object / usertype=CGameObject }: The object whose specifics is being fetched. +-- +-- @return { type=number }: See summary. + +function EEex_GameObject_GetSpecifics(object) + return object.m_typeAI.m_Specifics +end +CGameObject.getSpecifics = EEex_GameObject_GetSpecifics + +-- @bubb_doc { EEex_GameObject_GetGender / instance_name=getGender } +-- +-- @summary: Returns the given ``object``'s gender. +-- +-- @self { object / usertype=CGameObject }: The object whose gender is being fetched. +-- +-- @return { type=number }: See summary. + +function EEex_GameObject_GetGender(object) + return object.m_typeAI.m_Gender +end +CGameObject.getGender = EEex_GameObject_GetGender + +-- @bubb_doc { EEex_GameObject_GetAlignment / instance_name=getAlignment } +-- +-- @summary: Returns the given ``object``'s alignment. +-- +-- @self { object / usertype=CGameObject }: The object whose alignment is being fetched. +-- +-- @return { type=number }: See summary. + +function EEex_GameObject_GetAlignment(object) + return object.m_typeAI.m_Alignment +end +CGameObject.getAlignment = EEex_GameObject_GetAlignment + ------------------------------ -- Game Object Manipulation -- ------------------------------ diff --git a/EEex/copy/EEex_scripts/EEex_LuaModule.lua b/EEex/copy/EEex_scripts/EEex_LuaModule.lua new file mode 100644 index 0000000..93473be --- /dev/null +++ b/EEex/copy/EEex_scripts/EEex_LuaModule.lua @@ -0,0 +1,376 @@ +-- Lua-only module provider. +-- Exposes: EEex.GetLuaModule(moduleName) and EEex_GetLuaModule(moduleName). +-- +-- MUST be loaded AFTER the active Lua runtime is in place: +-- - In the EarlyMain path (LuaJIT): after EEex_ReplaceLua, so that string.dump and debug.getinfo +-- are those of the external LuaJIT runtime, not the in-engine Lua that is being replaced. +-- - In the Main path (internal Lua 5.2): after EEex_OpenLuaBindings("EEex"), so that the EEex +-- global table already exists when we try to bind EEex.GetLuaModule. +-- +-- LIMITATION: Everything here executes inside the same Lua state as untrusted code. +-- It cannot create true privilege separation — an attacker with access to the Lua state +-- and native APIs could still bypass these checks. The goal is detection, not prevention. + +(function() + -- ── 0. Bedrock capture ────────────────────────────────────────────────────────────────────── + -- Capture all primitives we depend on as upvalues right now, before anything else runs. + -- Using direct upvalue references (not _G lookups) means later code cannot shadow these by + -- replacing the global "rawget", "pcall", etc. after this closure is created. + local _G_local = _G + local _type = type + local _pairs = pairs + local _ipairs = ipairs + local _next = next + local _rawget = rawget + local _rawset = rawset + local _setmetatable = setmetatable + local _error = error + local _pcall = pcall + local _tostring = tostring + + local function fail(msg) + _error("[EEex] LuaModule: " .. msg, 0) + end + + -- ── 1. Re-installation guard ───────────────────────────────────────────────────────────────── + -- This file may be executed twice: once in EarlyMain (before EEex table exists) and once in + -- Main (after EEex table exists, so we can bind EEex.GetLuaModule). + -- On the second run the trust chain is already built; we only need to re-export the getter + -- into any globals that may now be available (e.g. EEex table). + local existingGetter = _rawget(_G_local, "__EEex_LuaModule_Getter") + if _type(existingGetter) == "function" then + -- Re-export the already-built getter to the well-known global name. + _rawset(_G_local, "EEex_GetLuaModule", existingGetter) + -- If the EEex table is now available, bind EEex.GetLuaModule as well. + local eeexTable = _rawget(_G_local, "EEex") + if _type(eeexTable) == "table" then + eeexTable.GetLuaModule = existingGetter + end + return + end + + -- ── 2. Module lookup helpers ───────────────────────────────────────────────────────────────── + -- We never read module tables via plain _G["string"] etc., because the global table is the + -- first thing an attacker would override. package.loaded is the canonical runtime registry + -- where the VM stores genuinely loaded modules, and we bypass its __index with rawget. + local packageModule = _rawget(_G_local, "package") + local loadedTable = _type(packageModule) == "table" and _rawget(packageModule, "loaded") or nil + + local function fromLoaded(moduleName) + -- rawget on loadedTable bypasses any hostile __index metamethod on the table itself. + return _type(loadedTable) == "table" and _rawget(loadedTable, moduleName) or nil + end + + local function rawModuleByName(moduleName) + -- Prefer package.loaded; fall back to a rawget on _G only if not found there. + -- The _G fallback handles built-ins (e.g. math, string) that some runtimes register + -- both in package.loaded and as globals. + local fromPkg = fromLoaded(moduleName) + if fromPkg ~= nil then + return fromPkg + end + return _rawget(_G_local, moduleName) + end + + -- Runtime mode: keep strict snapshot enforcement on stock Lua 5.2, but relax it on LuaJIT. + -- LuaJIT may expose legitimate standard-library helpers implemented in Lua (e.g. math.deg), + -- so requiring every function member to be C would produce false positives there. + local jitModule = _rawget(_G_local, "jit") + local isLuaJIT = _type(jitModule) == "table" and _type(_rawget(jitModule, "version")) == "string" + local strictAllFunctionsMustBeC = not isLuaJIT + + -- ── 3. Oracle 1: string.dump ───────────────────────────────────────────────────────────────── + -- string.dump is our first verification oracle. It exploits a fundamental Lua VM property: + -- genuine C functions cannot be serialized to bytecode, so string.dump will always ERROR on them. + -- A Lua function masquerading as a C function will instead SUCCEED and return its bytecode. + -- + -- Self-verification: apply string.dump to itself. + -- If it errors → string.dump is a genuine C function. Good. + -- If it succeeds → string.dump is a Lua impostor. Fail immediately. + local stringModule = rawModuleByName("string") + local stringDump = _type(stringModule) == "table" and _rawget(stringModule, "dump") or nil + if _type(stringDump) ~= "function" then + fail("trust chain failed: string.dump unavailable") + end + if _pcall(stringDump, stringDump) then + -- pcall returned true → string.dump succeeded on itself → it is a Lua function, not C. + fail("trust chain failed: string.dump is a Lua impostor") + end + + -- ── 4. Oracle 2: debug.getinfo ─────────────────────────────────────────────────────────────── + -- debug.getinfo is our second verification oracle. Its "S" query returns a table with a + -- "what" field: "C" for native functions, "Lua" for scripted functions. + -- We first verify debug.getinfo itself with Oracle 1 before trusting any result it returns. + local debugModule = rawModuleByName("debug") + local debugGetInfo = _type(debugModule) == "table" and _rawget(debugModule, "getinfo") or nil + if _type(debugGetInfo) ~= "function" then + fail("trust chain failed: debug.getinfo unavailable") + end + if _pcall(stringDump, debugGetInfo) then + -- string.dump succeeded on debug.getinfo → it is a Lua function, not C. + fail("trust chain failed: debug.getinfo is a Lua impostor") + end + + -- ── 5. Cross-verification: Oracle 2 confirms Oracle 1 ─────────────────────────────────────── + -- Ask debug.getinfo whether string.dump reports itself as a C function. + -- This closes the trust loop: each oracle independently vouches for the other. + -- An attacker would have to compromise BOTH oracles simultaneously and consistently, + -- which is not achievable from pure Lua code. + local dumpInfo = debugGetInfo(stringDump, "S") + if _type(dumpInfo) ~= "table" or _rawget(dumpInfo, "what") ~= "C" then + fail("trust chain failed: string.dump fails debug.getinfo cross-check") + end + + -- ── 6. Dual-oracle predicate ───────────────────────────────────────────────────────────────── + -- The core verification primitive used throughout the rest of this module. + -- Both oracles must independently agree that a function is a genuine C function. + -- A fake that defeats one oracle will be caught by the other. + -- + -- LIMITATION: this checks C-vs-Lua classification and per-call identity, but cannot attest + -- the actual machine-code address (i.e. which native symbol a C function points to). + local function isCFunction(value) + if _type(value) ~= "function" then + return false, "not a function" + end + -- Oracle 1: string.dump must fail (C functions are not serializable). + if _pcall(stringDump, value) then + return false, "string.dump succeeded" + end + -- Oracle 2: debug.getinfo must report what == "C". + local info = debugGetInfo(value, "S") + if _type(info) ~= "table" then + return false, "debug.getinfo returned nil" + end + if _rawget(info, "what") ~= "C" then + return false, "debug.getinfo what=" .. _tostring(_rawget(info, "what")) + end + return true + end + + local function assertCFunction(value, name) + local ok, reason = isCFunction(value) + if not ok then + fail("trust chain failed: " .. name .. ": " .. reason) + end + end + + -- ── 7. Retroactive verification of bootstrap primitives ───────────────────────────────────── + -- The oracles are now trusted. Use them to verify the primitives captured in step 0, + -- which we had to accept blindly until now. + -- If any of them are Lua impostors, every check we ran above was potentially tainted, + -- so we fail hard rather than silently continuing with a corrupted trust base. + -- Note: _G_local is intentionally not checked here because it is a table, not a function. + assertCFunction(_type, "type") + assertCFunction(_pairs, "pairs") + assertCFunction(_ipairs, "ipairs") + assertCFunction(_next, "next") + assertCFunction(_rawget, "rawget") + assertCFunction(_rawset, "rawset") + assertCFunction(_setmetatable, "setmetatable") + assertCFunction(_error, "error") + assertCFunction(_pcall, "pcall") + assertCFunction(_tostring, "tostring") + + -- ── 8. Persistent state ────────────────────────────────────────────────────────────────────── + -- Store snapshots and proxy objects in a global table so that the re-installation path + -- (step 1 above) can reuse them without rebuilding everything from scratch. + local STATE_KEY = "__EEex_LuaModule_State" + local state = _rawget(_G_local, STATE_KEY) + if _type(state) ~= "table" then + state = { + moduleSnapshots = {}, -- keyed by module name; holds expected member values and C flags + proxyCache = {}, -- keyed by module name; holds the read-only proxy objects + } + _rawset(_G_local, STATE_KEY, state) + end + + -- ── 9. Module snapshot ─────────────────────────────────────────────────────────────────────── + -- Capture a point-in-time snapshot of a module's members. + -- For each function member, we record whether it was a genuine C function at snapshot time. + -- In strict mode (non-LuaJIT), any Lua function member is rejected immediately. + -- In LuaJIT mode, Lua function members are tolerated to avoid false positives. + -- Required-member enforcement remains strict in validateRequiredMembers() in both modes. + -- Non-function members (e.g. math.pi) are captured as-is without C verification. + -- Snapshots are cached; the first call for a given module name wins. + local function snapshotModule(moduleName) + local cached = state.moduleSnapshots[moduleName] + if cached ~= nil then + return cached + end + + local module = rawModuleByName(moduleName) + if _type(module) ~= "table" then + fail("module '" .. moduleName .. "' is missing or is not a table") + end + + local expected = {} -- maps memberName -> value at snapshot time + local expectedFunctionIsC = {} -- set of memberNames that were verified as C at snapshot time + for memberName, memberValue in _pairs(module) do + expected[memberName] = memberValue + if _type(memberValue) == "function" then + -- Record C-ness for each function member and optionally enforce strict mode. + local ok, reason = isCFunction(memberValue) + if ok then + expectedFunctionIsC[memberName] = true + elseif strictAllFunctionsMustBeC then + fail("module '" .. moduleName .. "' member '" .. _tostring(memberName) .. "' not trusted: " .. reason) + end + end + end + + local snapshot = { + name = moduleName, + module = module, -- reference to the live module table (used for drift detection) + expected = expected, + expectedFunctionIsC = expectedFunctionIsC, + } + state.moduleSnapshots[moduleName] = snapshot + return snapshot + end + + -- ── 10. Required-member validation ────────────────────────────────────────────────────────── + -- Called when the caller passes a requiredMembers list to getLuaModule. + -- Each listed member must exist in the snapshot AND be a verified C function. + local function validateRequiredMembers(snapshot, requiredMembers) + if requiredMembers == nil then + return + end + if _type(requiredMembers) ~= "table" then + fail("arg 2 (requiredMembers) must be a table or nil") + end + for _, memberName in _ipairs(requiredMembers) do + if _type(memberName) ~= "string" then + fail("arg 2 (requiredMembers) must only contain strings") + end + local expectedValue = snapshot.expected[memberName] + -- The member must be present and must be a function; non-function members cannot be + -- meaningfully "required" in the C-function sense. + if _type(expectedValue) ~= "function" then + fail("module '" .. snapshot.name .. "' missing required function '" .. memberName .. "'") + end + -- Double-check C-ness even if snapshot already recorded it; belt-and-suspenders. + local ok, reason = isCFunction(expectedValue) + if not ok then + fail("module '" .. snapshot.name .. "' required function '" .. memberName .. "' is not trusted: " .. reason) + end + end + end + + -- ── 11. Read-only proxy ────────────────────────────────────────────────────────────────────── + -- The proxy is what callers receive. It is a plain empty table with a locked metatable. + -- All reads go through __index which enforces two invariants on every access: + -- (a) Identity: the live value in the module table must be the same object as in the snapshot. + -- (b) C-ness: if the member was a C function at snapshot time, it must still pass isCFunction. + -- Writes are always rejected via __newindex. + -- __metatable = false prevents getmetatable() from exposing the metatable to untrusted code. + -- + -- LIMITATION: the proxy cannot stop code that bypasses it entirely (e.g. rawset on the original + -- module table). Drift is only detected at access time, not at write time. + local function makeReadOnlyProxy(snapshot) + local cached = state.proxyCache[snapshot.name] + if cached ~= nil then + return cached + end + + local proxy = {} + local mt = { + __index = function(_, key) + local expectedValue = snapshot.expected[key] + if expectedValue == nil then + -- Key was not present at snapshot time; return nil rather than forwarding + -- to the live table, which may have had members injected since. + return nil + end + + -- Identity check: verify the live module still holds the exact same value. + -- Catches simple replacement attacks (e.g. string.format = evil_func). + local currentValue = _rawget(snapshot.module, key) + if currentValue ~= expectedValue then + fail("module '" .. snapshot.name .. "' member '" .. _tostring(key) .. "' was modified") + end + + -- C-function check: re-run the dual-oracle test on every access for function members. + -- This catches cases where the value identity is preserved (same object reference) + -- but the function's implementation was patched at the C level (very unlikely from + -- pure Lua, but included for completeness). + if snapshot.expectedFunctionIsC[key] then + local ok, reason = isCFunction(currentValue) + if not ok then + fail("module '" .. snapshot.name .. "' member '" .. _tostring(key) .. "' is compromised: " .. reason) + end + end + + return expectedValue + end, + __newindex = function(_, key, _) + -- Any write attempt through the proxy is unconditionally rejected. + fail("cannot assign member '" .. _tostring(key) .. "' on read-only proxy for module '" .. snapshot.name .. "'") + end, + __pairs = function() + -- Iterate over snapshot keys (not the live table) to avoid exposing injected members. + local key + return function() + key = _next(snapshot.expected, key) + if key == nil then + return nil + end + -- Read through the proxy's own __index so that all access-time checks run. + return key, proxy[key] + end + end, + -- Prevent untrusted code from retrieving this metatable via getmetatable(). + __metatable = false, + } + + _setmetatable(proxy, mt) + state.proxyCache[snapshot.name] = proxy + return proxy + end + + -- ── 12. Public API: getLuaModule ───────────────────────────────────────────────────────────── + -- Parameters: + -- moduleName (string) – name of the module to retrieve + -- requiredMembers (table?) – optional list of function names that must exist and be C + -- functionalTest (func?) – optional callback(proxy) called via pcall; failure aborts + local function getLuaModule(moduleName, requiredMembers, functionalTest) + if _type(moduleName) ~= "string" then + fail("arg 1 (moduleName) must be a string") + end + + -- Take (or retrieve) the snapshot for this module, verifying all function members. + local snapshot = snapshotModule(moduleName) + + -- If the caller specified required members, check them before returning the proxy. + validateRequiredMembers(snapshot, requiredMembers) + + -- Build (or retrieve from cache) the read-only proxy. + local proxy = makeReadOnlyProxy(snapshot) + + -- If the caller provided a functional test, run it now via pcall so that any error is + -- wrapped with context rather than propagating as a raw Lua error. + if functionalTest ~= nil then + if _type(functionalTest) ~= "function" then + fail("arg 3 (functionalTest) must be a function or nil") + end + local ok, err = _pcall(functionalTest, proxy) + if not ok then + fail("functional test failed for module '" .. moduleName .. "': " .. _tostring(err)) + end + end + + return proxy + end + + -- ── 13. Export ─────────────────────────────────────────────────────────────────────────────── + -- Store the getter under a private key so the re-installation guard (step 1) can recover it. + _rawset(_G_local, "__EEex_LuaModule_Getter", getLuaModule) + -- Expose as a standalone global for scripts that do not use the EEex table. + _rawset(_G_local, "EEex_GetLuaModule", getLuaModule) + -- Bind onto the EEex table if it already exists (it will if we are running in the Main path). + -- In the EarlyMain path the EEex table does not exist yet; the re-installation guard in step 1 + -- will bind it when EEex_LuaModule.lua is loaded a second time from EEex_Main.lua. + local eeexTable = _rawget(_G_local, "EEex") + if _type(eeexTable) == "table" then + eeexTable.GetLuaModule = getLuaModule + end +end)() diff --git a/EEex/copy/EEex_scripts/EEex_Main.lua b/EEex/copy/EEex_scripts/EEex_Main.lua index e183464..2059068 100644 --- a/EEex/copy/EEex_scripts/EEex_Main.lua +++ b/EEex/copy/EEex_scripts/EEex_Main.lua @@ -87,6 +87,8 @@ EEex_Main_Private_Modules = { -- Contains EEex's C++ functionality EEex_OpenLuaBindings("EEex") + -- Build the verified module trust chain after EEex table is available, so EEex.GetLuaModule is bound. + EEex_DoFile("EEex_LuaModule") EEex_DoFile("EEex_HookIntegrityWatchdog") -- Defines aliases for EEex functions to preserve API compatibility if internal names change diff --git a/EEex/copy/EEex_scripts/EEex_Projectile.lua b/EEex/copy/EEex_scripts/EEex_Projectile.lua index 5b6ba27..42023a3 100644 --- a/EEex/copy/EEex_scripts/EEex_Projectile.lua +++ b/EEex/copy/EEex_scripts/EEex_Projectile.lua @@ -634,3 +634,263 @@ function EEex_Projectile_GetStartingPosForID(projectileID, sourceObject, args) function() projectile:virtual_Destruct(true) end, projectile, sourceObject, args) end + +-- @bubb_doc { EEex_Projectile_IsPtInArc } +-- +-- @summary: +-- +-- Checks if the point (``ptCheckX``, ``ptCheckY``) is within an arc defined by the edge vector (``ptEdgeX``, ``ptEdgeY``) and angle ``nCheckAngle``. +-- +-- @param { ptEdgeX / type=number }: The X component of the edge of the cone. +-- @param { ptEdgeY / type=number }: The Y component of the edge of the cone. +-- @param { nCheckAngle / type=number }: The angle of the cone in degrees. +-- @param { ptCheckX / type=number }: The X component of the point to check. +-- @param { ptCheckY / type=number }: The Y component of the point to check. +-- +-- @return { type=boolean }: See summary. + +function EEex_Projectile_IsPtInArc(ptEdgeX, ptEdgeY, nCheckAngle, ptCheckX, ptCheckY) + + --[[ + print(string.format(" [isPtInArc] ptEdge: (%d,%d), nCheckAngle: %d, ptCheck: (%d,%d)", + ptEdgeX, ptEdgeY, nCheckAngle, ptCheckX, ptCheckY)) + --]] + + if ptCheckX == 0 and ptCheckY == 0 then + --print(" result: true") + return true + end + + local nDotProduct = ptEdgeX * ptCheckX + ptEdgeY * ptCheckY + + local fEdgeMagnitude = math.sqrt(ptEdgeX * ptEdgeX + ptEdgeY * ptEdgeY) + local fCheckMagnitude = math.sqrt(ptCheckX * ptCheckX + ptCheckY * ptCheckY) + + local fCosine = nDotProduct / (fEdgeMagnitude * fCheckMagnitude) + + local fClampedCosine = math.max(-1.0, math.min(fCosine, 1.0)) + local fRadians = math.acos(fClampedCosine) + local nAngle = math.floor((fRadians * 180 / math.pi)) -- truncating to match engine + + local result = nAngle <= nCheckAngle / 2 + --print(string.format(" result: %s", result and "true" or "false")) + return result +end + +-- @bubb_doc { EEex_Projectile_TestCone } +-- +-- @summary: +-- +-- Checks if the point (``px``, ``py``) is within a cone defined by the origin (``ox``, ``oy``), target (``tx``, ``ty``), and angle ``angle``. +-- +-- @param { angle / type=number }: The angle of the cone in degrees. +-- @param { ox / type=number }: The X component of the origin of the cone. +-- @param { oy / type=number }: The Y component of the origin of the cone. +-- @param { tx / type=number }: The X component of the target position. +-- @param { ty / type=number }: The Y component of the target position. +-- @param { px / type=number }: The X component of the point to check. +-- @param { py / type=number }: The Y component of the point to check. +-- +-- @return { type=boolean }: See summary. + +function EEex_Projectile_TestCone(angle, ox, oy, tx, ty, px, py) + + if angle <= 180 then + -- Vector from cone source to target pos + local nEdgeX = tx - ox + local nEdgeY = ty - oy + -- Vector from cone source to potential target object + local nCheckX = px - ox + local nCheckY = py - oy + + if EEex_Projectile_IsPtInArc(nEdgeX, nEdgeY, angle, nCheckX, nCheckY) then + return true + end + else + -- Vector from cone source to target pos + local nEdgeX = ox - tx + local nEdgeY = oy - ty + -- Vector from cone source to potential target object + local nCheckX = px - ox + local nCheckY = py - oy + + if not EEex_Projectile_IsPtInArc(nEdgeX, nEdgeY, 360 - angle, nCheckX, nCheckY) then + return true + end + end + + return false +end + +-- @bubb_doc { EEex_Projectile_AoERadiusCheck } +-- +-- @summary: +-- +-- Returns ``true`` if casting an AoE projectile at ``targetSprite`` is considered @EOL +-- worthwhile from the perspective of ``scriptRunner``. @EOL @EOL +-- +-- For non-AoE projectiles this always returns ``true``. @EOL @EOL +-- +-- For AoE projectiles the function decodes the projectile, computes its actual launch position, then counts @EOL +-- two groups of sprites within the explosion radius (or cone, depending on the projectile's area flags): @EOL +-- those the caster *wants* to hit (``toHitWithinRange``) and those it *wants* to avoid hitting due to @EOL +-- friendly fire (``toAvoidWithinRange``). The EA of both the caster and the target is used to @EOL +-- determine which group each nearby sprite belongs to, mirroring the engine's own targeting logic @EOL +-- (e.g. EA-unfriendly spells such as Fireball count enemies to hit and allies to avoid, while @EOL +-- EA-friendly spells such as Bless only count allies to hit and ignore friendly fire entirely). @EOL @EOL +-- +-- The final decision is probabilistic: a random integer in ``[0, toHitWithinRange]`` is rolled, and @EOL +-- the function returns ``true`` only if that roll **strictly exceeds** ``toAvoidWithinRange``. This @EOL +-- means the cast is always refused when there are more allies in range than enemies, is guaranteed @EOL +-- when ``toAvoidWithinRange`` is 0 and at least one enemy is in range, and becomes increasingly @EOL +-- likely as the enemy count grows relative to the friendly-fire count. +-- +-- @param { missileType / type=number }: The ID of the projectile (as per MISSILE.IDS). +-- @param { scriptRunner / type=userdata }: The script runner object. +-- @param { targetSprite / type=userdata }: The target sprite object. +-- +-- @return { type=boolean }: See summary. + +function EEex_Projectile_AoERadiusCheck(missileType, scriptRunner, targetSprite) + + local toReturn = true + + if scriptRunner == nil then + scriptRunner = EEex_LuaTrigger_Object -- CGameSprite + end + + if targetSprite == nil then + targetSprite = scriptRunner:getStoredScriptingTarget("B3TEST") -- CGameSprite + end + + local proResRef = EEex_Resource_IDSToSymbol("PROJECTL", missileType - 1) + + if proResRef == nil then + return true + end + + local pHeader = EEex_Resource_Demand(proResRef, "pro") -- CProjectileFileFormat + + if pHeader == nil then + return true + end + + local m_wFileType = pHeader.m_wFileType + + if m_wFileType == 3 then -- AoE + + local projectile = CProjectile.DecodeProjectile(missileType, scriptRunner) -- CProjectile + + if projectile == nil then + return true + end + + toReturn = false + + EEex_Utility_TryFinally(function() + + -- NB.: the projectile starts at an offset from the caster!!! + local projX, projY, projZ = projectile:getStartingPos(scriptRunner, { + ["targetObject"] = targetSprite, + }) + + local m_dwAreaFlags = pHeader.m_dwAreaFlags + local m_explosionRange = pHeader.m_explosionRange + local m_bIgnoreLOS = EEex_IsBitSet(m_dwAreaFlags, 12) + local m_checkForNonSprites = EEex_IsBitSet(m_dwAreaFlags, 1) + local m_terrainTable = projectile.m_terrainTable -- Array + local m_coneSize = pHeader.m_coneSize + + local tryToHit, tryToAvoid = {}, {} + + local findObjects = function(aiType) + if EEex_IsBitSet(m_dwAreaFlags, 11) then + -- cone + return scriptRunner.m_pArea:getAllOfTypeInRange(projX, projY, aiType, m_explosionRange, not m_bIgnoreLOS, m_checkForNonSprites, m_terrainTable) + else + -- circle + return targetSprite:getAllOfTypeInRange(aiType, m_explosionRange, not m_bIgnoreLOS, m_checkForNonSprites, m_terrainTable) + end + end + + if EEex_IsBitUnset(m_dwAreaFlags, 6) and EEex_IsBitUnset(m_dwAreaFlags, 7) then + + -- EA-unfriendly, try avoiding friendly fire (see f.i. Fireball, Arrow of Detonation) + -- Assuming target EA is what I want to hit with this spell + + if EEex_GameObject_GetEA(targetSprite) < EEex_Resource_SymbolToIDS("EA", "GOODCUTOFF") then + tryToHit = findObjects(EEex_Object_ParseString("[GOODCUTOFF]")) + tryToAvoid = findObjects(EEex_Object_ParseString("[EVILCUTOFF]")) + elseif EEex_GameObject_GetEA(targetSprite) > EEex_Resource_SymbolToIDS("EA", "EVILCUTOFF") then + tryToHit = findObjects(EEex_Object_ParseString("[EVILCUTOFF]")) + tryToAvoid = findObjects(EEex_Object_ParseString("[GOODCUTOFF]")) + end + + elseif EEex_IsBitSet(m_dwAreaFlags, 6) then + + -- EA-friendly + + if EEex_IsBitSet(m_dwAreaFlags, 7) then + -- Only allies of caster, see f.i. Bless/Haste + if EEex_GameObject_GetEA(scriptRunner) < EEex_Resource_SymbolToIDS("EA", "GOODCUTOFF") then + tryToHit = findObjects(EEex_Object_ParseString("[GOODCUTOFF]")) + elseif EEex_GameObject_GetEA(scriptRunner) > EEex_Resource_SymbolToIDS("EA", "EVILCUTOFF") then + tryToHit = findObjects(EEex_Object_ParseString("[EVILCUTOFF]")) + end + else + -- Only enemies of caster, see f.i. Glitterdust + if EEex_GameObject_GetEA(scriptRunner) < EEex_Resource_SymbolToIDS("EA", "GOODCUTOFF") then + tryToHit = findObjects(EEex_Object_ParseString("[EVILCUTOFF]")) + elseif EEex_GameObject_GetEA(scriptRunner) > EEex_Resource_SymbolToIDS("EA", "EVILCUTOFF") then + tryToHit = findObjects(EEex_Object_ParseString("[GOODCUTOFF]")) + end + end + end + + local toHitWithinRange = 0 + local toAvoidWithinRange = 0 + + --print("--- Projection") + + --[[ + print(string.format("scriptRunner: %s, targetSprite: %s, scriptRunnerPos: (%d,%d), targetSpritePos: (%d,%d), proj: (%d,%d)", + scriptRunner:getName(), targetSprite:getName(), + scriptRunner.m_pos.x, scriptRunner.m_pos.y, + targetSprite.m_pos.x, targetSprite.m_pos.y, + projX, projY + )) + --]] + + --print("trying to hit:") + for _, itrSprite in ipairs(tryToHit) do + --print(string.format(" itrSprite: %s (%d,%d)", itrSprite:getName(), itrSprite.m_pos.x, itrSprite.m_pos.y)) + if EEex_IsBitUnset(m_dwAreaFlags, 11) or ( + itrSprite.m_id ~= scriptRunner.m_id + and EEex_Projectile_TestCone(m_coneSize, projX, projY, targetSprite.m_pos.x, targetSprite.m_pos.y, itrSprite.m_pos.x, itrSprite.m_pos.y) + ) then + --print(" "..itrSprite:getName()) + toHitWithinRange = toHitWithinRange + 1 + end + end + + --print("trying to avoid:") + for _, itrSprite in ipairs(tryToAvoid) do + --print(string.format(" itrSprite: %s (%d,%d)", itrSprite:getName(), itrSprite.m_pos.x, itrSprite.m_pos.y)) + if EEex_IsBitUnset(m_dwAreaFlags, 11) or ( + itrSprite.m_id ~= scriptRunner.m_id + and EEex_Projectile_TestCone(m_coneSize, projX, projY, targetSprite.m_pos.x, targetSprite.m_pos.y, itrSprite.m_pos.x, itrSprite.m_pos.y) + ) then + --print(" "..itrSprite:getName()) + toAvoidWithinRange = toAvoidWithinRange + 1 + end + end + + if math.random(0, toHitWithinRange) > toAvoidWithinRange then + toReturn = true + end + end, + function() projectile:virtual_Destruct(true) end) + end + + return toReturn +end diff --git a/EEex/copy/EEex_scripts/EEex_Resource.lua b/EEex/copy/EEex_scripts/EEex_Resource.lua index 5ecd3c2..efc704c 100644 --- a/EEex/copy/EEex_scripts/EEex_Resource.lua +++ b/EEex/copy/EEex_scripts/EEex_Resource.lua @@ -163,7 +163,7 @@ Spell_Header_st.getAbility = EEex_Resource_GetSpellAbility function EEex_Resource_GetItemAbility(itemHeader, abilityIndex) if itemHeader.abilityCount <= abilityIndex then return end - return EEex_PtrToUD(EEex_UDToPtr(itemHeader) + itemHeader.abilityOffset + Item_Header_st.sizeof * abilityIndex, "Item_ability_st") + return EEex_PtrToUD(EEex_UDToPtr(itemHeader) + itemHeader.abilityOffset + Item_ability_st.sizeof * abilityIndex, "Item_ability_st") end Item_Header_st.getAbility = EEex_Resource_GetItemAbility @@ -957,6 +957,23 @@ function EEex_Resource_KitSymbolToIDS(kitSymbol) return EEex_Resource_Private_KitSymbolToIDS[kitSymbol] end +EEex_Resource_Private_IDSToSymbol = {} +EEex_Resource_Private_SymbolToIDS = {} + +function EEex_Resource_IDSToSymbol(file, IDS) + return EEex_Resource_Private_IDSToSymbol[file:upper()][IDS] +end + +function EEex_Resource_SymbolToIDS(file, symbol) + return EEex_Resource_Private_SymbolToIDS[file:upper()][symbol] +end + +EEex_Resource_Private_2DA = {} + +function EEex_Resource_2DA(file, rowLabel, columnLabel) + return EEex_Resource_Private_2DA[file:upper()][tostring(rowLabel)][tostring(columnLabel)] +end + EEex_Resource_Private_KitIgnoresMeleeingWithRangedPenaltyForItemCategory = {} EEex_GameState_AddInitializedListener(function() @@ -1005,6 +1022,60 @@ EEex_GameState_AddInitializedListener(function() end) end) + ------------------- + -- All IDS files -- + ------------------- + + -- Fills: + -- [table] EEex_Resource_Private_IDSToSymbol + -- [table] EEex_Resource_Private_SymbolToIDS + + EEex_Utility_NewScope(function() + local idsFileList = Infinity_GetFilesOfType("ids") + -- We need two nested loops in order to get the resref... + for _, temp in ipairs(idsFileList) do + for _, res in pairs(temp) do + EEex_Resource_Private_IDSToSymbol[res:upper()] = {} + EEex_Resource_Private_SymbolToIDS[res:upper()] = {} + -- Fill in the values for this IDS file + local file = EEex_Resource_LoadIDS(res) + file:iterateUnpackedEntries(function(id, symbol, _) + EEex_Resource_Private_IDSToSymbol[res:upper()][id] = symbol + EEex_Resource_Private_SymbolToIDS[res:upper()][symbol] = id + end) + end + end + end) + + ------------------- + -- All 2DA files -- + ------------------- + + -- Fills: + -- [table] EEex_Resource_Private_2DA + + EEex_Utility_NewScope(function() + local ruleTablesFileList = Infinity_GetFilesOfType("2da") + -- We need two nested loops in order to get the resref... + for _, temp in ipairs(ruleTablesFileList) do + for _, res in pairs(temp) do + EEex_Resource_Private_2DA[res:upper()] = {} + -- Fill in the values for this 2DA file + local data = EEex_Resource_Load2DA(res) + local nX, nY = data:getDimensions() + nX = nX - 2 + nY = nY - 1 + -- [!] Everything is stored as strings, so we don't have to worry about type conversion when filling in the table + for rowIndex = 0, nY do + EEex_Resource_Private_2DA[res:upper()][data:getRowLabel(rowIndex)] = {} -- Initialize each row + for columnIndex = 0, nX do + EEex_Resource_Private_2DA[res:upper()][data:getRowLabel(rowIndex)][data:getColumnLabel(columnIndex)] = data:getAtPoint(columnIndex, rowIndex) + end + end + end + end + end) + ------------------ -- X-CLSERG.2DA -- ------------------ diff --git a/EEex/copy/EEex_scripts/EEex_Sprite.lua b/EEex/copy/EEex_scripts/EEex_Sprite.lua index b2d8d93..1b8833a 100644 --- a/EEex/copy/EEex_scripts/EEex_Sprite.lua +++ b/EEex/copy/EEex_scripts/EEex_Sprite.lua @@ -500,6 +500,27 @@ end -- Sprite Details -- -------------------- +-- @bubb_doc { EEex_Sprite_IsPartyMember / instance_name=isPartyMember } +-- +-- @summary: Returns whether the given ``sprite`` is a party member. +-- +-- @self { sprite / type=CGameSprite }: The sprite whose party membership is being checked. +-- +-- @return { type=boolean }: See summary. + +function EEex_Sprite_IsPartyMember(sprite) + for i = 0, 5 do + local partyMember = EEex_Sprite_GetInPortrait(i) -- CGameSprite + if partyMember then -- sanity check + if partyMember.m_id == sprite.m_id then + return true + end + end + end + return false +end +CGameSprite.isPartyMember = EEex_Sprite_IsPartyMember + -- @bubb_doc { EEex_Sprite_GetPortraitIndex / instance_name=getPortraitIndex } -- -- @summary: Returns the given ``sprite``'s portrait index, or ``-1`` if it isn't a party member. @@ -849,6 +870,366 @@ EEex_Sprite_GetKnownInnateSpellsWithAbilityItr = EEex_Sprite_GetKnownInnateSpell CGameSprite.getKnownInnateSpellsWithAbilityIterator = EEex_Sprite_GetKnownInnateSpellsWithAbilityItr CGameSprite.getKnownInnateSpellsWithAbilityItr = EEex_Sprite_GetKnownInnateSpellsWithAbilityItr +function EEex_Sprite_Private_NormalizeSpellResref(spellResRef) + if spellResRef == nil then + EEex_Error("spellResRef required") + end + if spellResRef:find(".", 1, true) ~= nil then + EEex_Error("spellResRef must not include an extension") + end + -- Normalize once so later comparisons can stay exact and case-sensitive. + spellResRef = spellResRef:upper() + if #spellResRef < 1 or #spellResRef > 8 then + EEex_Error("spellResRef length out-of-bounds (expected [1-8])") + end + return spellResRef +end + +function EEex_Sprite_Private_NormalizeSpellType(spellType) + if spellType == 0 or spellType == 1 or spellType == 2 then + return spellType + end + + if type(spellType) == "string" then + return ({ + ["PRIEST"] = 0, + ["WIZARD"] = 1, + ["INNATE"] = 2, + })[spellType:upper()] or EEex_Error(string.format("Unknown spell type: %s", tostring(spellType))) + end + + EEex_Error(string.format("Unknown spell type: %s", tostring(spellType))) +end + +function EEex_Sprite_Private_GetSpellbookInfo(sprite, spellType) + -- Each spellbook is tracked as parallel known / memorized-level / memorized-entry tables. + -- Innate spells do not mirror a derived memorization table, unlike priest and mage books. + local info = ({ + [0] = { + spellType = 0, + maxLevels = 7, + knownLists = sprite.m_knownSpellsPriest, + memorizedLevels = sprite.m_memorizedSpellsLevelPriest, + memorizedLists = sprite.m_memorizedSpellsPriest, + derivedLevels = sprite.m_derivedStats.m_memorizedSpellsLevelPriest, + }, + [1] = { + spellType = 1, + maxLevels = 9, + knownLists = sprite.m_knownSpellsMage, + memorizedLevels = sprite.m_memorizedSpellsLevelMage, + memorizedLists = sprite.m_memorizedSpellsMage, + derivedLevels = sprite.m_derivedStats.m_memorizedSpellsLevelMage, + }, + [2] = { + spellType = 2, + maxLevels = 1, + knownLists = sprite.m_knownSpellsInnate, + memorizedLevels = sprite.m_memorizedSpellsLevelInnate, + memorizedLists = sprite.m_memorizedSpellsInnate, + derivedLevels = nil, + }, + })[spellType] + + if info == nil then + EEex_Error(string.format("Unsupported spell type: %s", tostring(spellType))) + end + return info +end + +function EEex_Sprite_Private_NormalizeSpellLevel(spellLevel, maxLevels) + if type(spellLevel) ~= "number" then + EEex_Error("spellLevel must be a number") + end + spellLevel = math.floor(spellLevel) + if spellLevel < 0 or spellLevel >= maxLevels then + EEex_Error(string.format("Spell level out-of-bounds (expected [0-%d])", maxLevels - 1)) + end + return spellLevel +end + +function EEex_Sprite_Private_NormalizeSpellCount(spellCount) + if spellCount == nil then + return 1 + end + if type(spellCount) ~= "number" then + EEex_Error("spellCount must be a number") + end + return math.floor(spellCount) +end + +function EEex_Sprite_Private_NormalizeMemorizedState(memorized) + if memorized == nil then + return true + end + if type(memorized) ~= "boolean" then + EEex_Error("memorized must be a boolean") + end + return memorized +end + +function EEex_Sprite_Private_ResetMemorizedSpellLevel(memorizedSpellLevel, spellLevel, spellType) + memorizedSpellLevel.m_spellLevel = spellLevel + memorizedSpellLevel.m_baseCount = 0 + memorizedSpellLevel.m_count = 0 + memorizedSpellLevel.m_magicType = spellType + memorizedSpellLevel.m_memorizedStartingSpell = 0 + memorizedSpellLevel.m_memorizedCount = 0 +end + +function EEex_Sprite_Private_IsSpontaneousCaster(sprite, spellType) + local class = EEex_GameObject_GetClass(sprite) + return (spellType == 1 and class == 19) or (spellType == 0 and class == 21) +end + +function EEex_Sprite_Private_CountMatchingMemorizedSpells(memorizedSpellList, spellResRef) + local matchingCount = 0 + local node = memorizedSpellList.m_pNodeHead + while node do + local memorizedSpell = node.data + if memorizedSpell.m_spellId:get():upper() == spellResRef then + matchingCount = matchingCount + 1 + end + node = node.pNext + end + return matchingCount +end + +function EEex_Sprite_Private_GetSpontaneousSpellCopyLimit(sprite, spellbookInfo, spellLevel) + if not EEex_Sprite_Private_IsSpontaneousCaster(sprite, spellbookInfo.spellType) then + return nil + end + + -- Sorcerer-style books cap duplicate memorized entries to the slot count tracked for that level. + local maxCopies = 0 + local memorizedSpellLevel = spellbookInfo.memorizedLevels:get(spellLevel) + if memorizedSpellLevel ~= nil then + maxCopies = math.max(maxCopies, memorizedSpellLevel.m_baseCount, memorizedSpellLevel.m_count) + end + + local derivedLevels = spellbookInfo.derivedLevels + if derivedLevels ~= nil then + local derivedMemorizedSpellLevel = derivedLevels:getReference(spellLevel) + maxCopies = math.max(maxCopies, derivedMemorizedSpellLevel.m_baseCount, derivedMemorizedSpellLevel.m_count) + end + + return maxCopies +end + +function EEex_Sprite_Private_EnsureMemorizedSpellLevel(spellbookInfo, spellLevel) + local memorizedSpellLevel = spellbookInfo.memorizedLevels:get(spellLevel) + if memorizedSpellLevel == nil then + -- These level records are allocated lazily; sparse spellbooks are valid. + memorizedSpellLevel = EEex_PtrToUD(EEex_Malloc(CCreatureFileMemorizedSpellLevel.sizeof), "CCreatureFileMemorizedSpellLevel") + EEex_Memset(EEex_UDToPtr(memorizedSpellLevel), 0, CCreatureFileMemorizedSpellLevel.sizeof) + EEex_Sprite_Private_ResetMemorizedSpellLevel(memorizedSpellLevel, spellLevel, spellbookInfo.spellType) + spellbookInfo.memorizedLevels:set(spellLevel, memorizedSpellLevel) + end + return memorizedSpellLevel +end + +function EEex_Sprite_Private_SyncDerivedMemorizedSpellLevel(spellbookInfo, spellLevel, memorizedSpellLevel) + local derivedLevels = spellbookInfo.derivedLevels + if derivedLevels == nil then + return + end + + local derivedMemorizedSpellLevel = derivedLevels:getReference(spellLevel) + if memorizedSpellLevel == nil then + -- Clearing the base level must also clear the derived mirror the engine reads from. + EEex_Sprite_Private_ResetMemorizedSpellLevel(derivedMemorizedSpellLevel, spellLevel, spellbookInfo.spellType) + return + end + + -- Keep the derived bookkeeping in sync with the base spellbook arrays. + derivedMemorizedSpellLevel.m_spellLevel = memorizedSpellLevel.m_spellLevel + derivedMemorizedSpellLevel.m_baseCount = memorizedSpellLevel.m_baseCount + derivedMemorizedSpellLevel.m_count = memorizedSpellLevel.m_count + derivedMemorizedSpellLevel.m_magicType = memorizedSpellLevel.m_magicType + derivedMemorizedSpellLevel.m_memorizedStartingSpell = memorizedSpellLevel.m_memorizedStartingSpell + derivedMemorizedSpellLevel.m_memorizedCount = memorizedSpellLevel.m_memorizedCount +end + +function EEex_Sprite_Private_SyncMemorizedSpellInfo(spellbookInfo) + local memorizedStartingSpell = 0 + for spellLevel = 0, spellbookInfo.maxLevels - 1 do + local memorizedSpellLevel = spellbookInfo.memorizedLevels:get(spellLevel) + if memorizedSpellLevel ~= nil then + local memorizedSpellList = spellbookInfo.memorizedLists:getReference(spellLevel) + -- The engine expects a flattened starting index into the concatenated per-level lists. + memorizedSpellLevel.m_spellLevel = spellLevel + memorizedSpellLevel.m_magicType = spellbookInfo.spellType + memorizedSpellLevel.m_memorizedStartingSpell = memorizedStartingSpell + memorizedSpellLevel.m_memorizedCount = memorizedSpellList.m_nCount + memorizedStartingSpell = memorizedStartingSpell + memorizedSpellList.m_nCount + end + EEex_Sprite_Private_SyncDerivedMemorizedSpellLevel(spellbookInfo, spellLevel, memorizedSpellLevel) + end +end + +function EEex_Sprite_Private_HasKnownSpell(spellbookInfo, spellLevel, spellType, spellResRef) + local knownSpellList = spellbookInfo.knownLists:getReference(spellLevel) + local node = knownSpellList.m_pNodeHead + while node do + local knownSpell = node.data + if knownSpell.m_spellLevel == spellLevel + and knownSpell.m_magicType == spellType + and knownSpell.m_knownSpellId:get():upper() == spellResRef + then + return true + end + node = node.pNext + end + return false +end + +-- @bubb_doc { EEex_Sprite_AddKnownSpell / instance_name=addKnownSpell } +-- +-- @summary: +-- +-- Adds ``spellResRef`` to the given ``sprite``'s known spell list for ``spellType`` at ``spellLevel``. +-- +-- ``spellResRef`` must be a spell resref without an extension. ``spellLevel`` is zero-based. +-- +-- If the spell is already known at the given level and type, no duplicate entry is added. +-- +-- @self { sprite / usertype=CGameSprite }: The sprite whose known spell list is being modified. +-- +-- @param { spellResRef / type=string }: +-- +-- The spell resref to add. @EOL +-- Must not include an extension. +-- +-- @param { spellLevel / type=number }: +-- +-- The zero-based spell level to add the spell at. @EOL +-- Valid values depend on ``spellType``. +-- +-- @param { spellType / type=number | string }: +-- +-- The spellbook to modify. @EOL +-- Accepts ``0`` / ``PRIEST``, ``1`` / ``WIZARD``, or ``2`` / ``INNATE``. +-- +-- @return { type=boolean }: +-- +-- ``true`` if a new known-spell entry was added, or ``false`` if the spell was already present. + +function EEex_Sprite_AddKnownSpell(sprite, spellResRef, spellLevel, spellType) + local normalizedSpellType = EEex_Sprite_Private_NormalizeSpellType(spellType) + local spellbookInfo = EEex_Sprite_Private_GetSpellbookInfo(sprite, normalizedSpellType) + local normalizedSpellLevel = EEex_Sprite_Private_NormalizeSpellLevel(spellLevel, spellbookInfo.maxLevels) + local normalizedSpellResRef = EEex_Sprite_Private_NormalizeSpellResref(spellResRef) + + -- Known spells are keyed by level + magic type + resref, so avoid duplicate nodes. + if EEex_Sprite_Private_HasKnownSpell(spellbookInfo, normalizedSpellLevel, normalizedSpellType, normalizedSpellResRef) then + return false + end + + local knownSpellList = spellbookInfo.knownLists:getReference(normalizedSpellLevel) + local knownSpell = EEex_PtrToUD(EEex_Malloc(CCreatureFileKnownSpell.sizeof), "CCreatureFileKnownSpell") + EEex_Memset(EEex_UDToPtr(knownSpell), 0, CCreatureFileKnownSpell.sizeof) + knownSpell.m_knownSpellId:set(normalizedSpellResRef) + knownSpell.m_spellLevel = normalizedSpellLevel + knownSpell.m_magicType = normalizedSpellType + knownSpellList:AddTail(knownSpell) + return true +end +CGameSprite.addKnownSpell = EEex_Sprite_AddKnownSpell + +-- @bubb_doc { EEex_Sprite_AddMemorizedSpell / instance_name=addMemorizedSpell } +-- +-- @summary: +-- +-- Adds one or more memorized copies of ``spellResRef`` to the given ``sprite``'s spellbook. +-- +-- The spell is first ensured to exist in the known-spell list for the same ``spellType`` and ``spellLevel``. +-- +-- For spontaneous casters, the number of copies actually added is capped by the available slot count at that level. +-- +-- @self { sprite / usertype=CGameSprite }: The sprite whose memorized spell list is being modified. +-- +-- @param { spellResRef / type=string }: +-- +-- The spell resref to add. @EOL +-- Must not include an extension. +-- +-- @param { spellLevel / type=number }: +-- +-- The zero-based spell level to add the spell at. @EOL +-- Valid values depend on ``spellType``. +-- +-- @param { spellType / type=number | string }: +-- +-- The spellbook to modify. @EOL +-- Accepts ``0`` / ``PRIEST``, ``1`` / ``WIZARD``, or ``2`` / ``INNATE``. +-- +-- @param { spellCount / type=number / default=1 }: +-- +-- The number of memorized entries to append. +-- +-- @param { memorized / type=boolean / default=true }: +-- +-- Determines whether newly added entries start flagged as memorized. +-- +-- @return { type=number }: +-- +-- The number of memorized-spell entries actually added. + +function EEex_Sprite_AddMemorizedSpell(sprite, spellResRef, spellLevel, spellType, spellCount, memorized) + local normalizedSpellType = EEex_Sprite_Private_NormalizeSpellType(spellType) + local spellbookInfo = EEex_Sprite_Private_GetSpellbookInfo(sprite, normalizedSpellType) + local normalizedSpellLevel = EEex_Sprite_Private_NormalizeSpellLevel(spellLevel, spellbookInfo.maxLevels) + local normalizedSpellResRef = EEex_Sprite_Private_NormalizeSpellResref(spellResRef) + local normalizedSpellCount = EEex_Sprite_Private_NormalizeSpellCount(spellCount) + local normalizedMemorized = EEex_Sprite_Private_NormalizeMemorizedState(memorized) + + -- Memorized entries assume the spell is already present in the known-spell list. + EEex_Sprite_AddKnownSpell(sprite, normalizedSpellResRef, normalizedSpellLevel, normalizedSpellType) + if normalizedSpellCount <= 0 then + return 0 + end + + local memorizedSpellList = spellbookInfo.memorizedLists:getReference(normalizedSpellLevel) + local spontaneousSpellCopyLimit = EEex_Sprite_Private_GetSpontaneousSpellCopyLimit(sprite, spellbookInfo, normalizedSpellLevel) + if spontaneousSpellCopyLimit ~= nil then + -- Spontaneous casters are limited by slot count, not by how many identical nodes we would like to append. + local existingSpellCopies = EEex_Sprite_Private_CountMatchingMemorizedSpells(memorizedSpellList, normalizedSpellResRef) + normalizedSpellCount = math.min(normalizedSpellCount, math.max(0, spontaneousSpellCopyLimit - existingSpellCopies)) + if normalizedSpellCount <= 0 then + return 0 + end + end + + local memorizedSpellLevel = EEex_Sprite_Private_EnsureMemorizedSpellLevel(spellbookInfo, normalizedSpellLevel) + local addedCount = 0 + + for _ = 1, normalizedSpellCount do + local memorizedSpell = EEex_PtrToUD(EEex_Malloc(CCreatureFileMemorizedSpell.sizeof), "CCreatureFileMemorizedSpell") + EEex_Memset(EEex_UDToPtr(memorizedSpell), 0, CCreatureFileMemorizedSpell.sizeof) + memorizedSpell.m_spellId:set(normalizedSpellResRef) + memorizedSpell.m_flags = normalizedMemorized and 1 or 0 + memorizedSpellList:AddTail(memorizedSpell) + addedCount = addedCount + 1 + end + + if addedCount > 0 then + local memorizedSpellCount = memorizedSpellList.m_nCount + if not EEex_Sprite_Private_IsSpontaneousCaster(sprite, normalizedSpellType) then + -- Prepared casters store capacity directly on the level record; keep it at least as large as the list. + if memorizedSpellLevel.m_baseCount < memorizedSpellCount then + memorizedSpellLevel.m_baseCount = memorizedSpellCount + end + if memorizedSpellLevel.m_count < memorizedSpellCount then + memorizedSpellLevel.m_count = memorizedSpellCount + end + end + EEex_Sprite_Private_SyncMemorizedSpellInfo(spellbookInfo) + end + + return addedCount +end +CGameSprite.addMemorizedSpell = EEex_Sprite_AddMemorizedSpell + -- Iterator returns function EEex_Sprite_GetSpellButtonDataIteratorFrom2DA(sprite, resref) @@ -948,6 +1329,258 @@ function EEex_Sprite_DisplayTextRef(sprite, text, optionalArgs) end CGameSprite.displayTextRef = EEex_Sprite_DisplayTextRef +function EEex_Sprite_DisplayMessage(sprite, messageStr, messageColor) + + local message = EEex_NewUD("CMessageDisplayText") + + EEex_RunWithStackManager({ + { ["name"] = "messageStr", ["struct"] = "CString", ["constructor"] = {["args"] = {messageStr} } } }, + function(manager) + local id = sprite.m_id + message:Construct( + sprite:GetName(true), + manager:getUD("messageStr"), + CVidPalette.RANGE_COLORS:get(sprite.m_baseStats.m_colors:get(2)), + messageColor == nil and 0xBED7D7 or messageColor, + -1, id, id + ) + end + ) + + EngineGlobals.g_pBaldurChitin.m_cMessageHandler:AddMessage(message, false) + +end +CGameSprite.displayMessage = EEex_Sprite_DisplayMessage + +-- @bubb_doc { EEex_Sprite_GetActiveInactiveClasses / instance_name=getActiveInactiveClasses } +-- @summary: +-- +-- Returns the active class ID for the given sprite. @EOL +-- In case of dual-classed characters, it also returns their original class and whether it is re-activated. +-- +-- @self { sprite / usertype=CGameSprite }: Input sprite. +-- +-- @return { type=table }: See summary. + +function EEex_Sprite_GetActiveInactiveClasses(sprite) + + if not EEex_GameObject_IsSprite(sprite) then + EEex_Error("Expected CGameSprite!") + end + + local flags = sprite.m_baseStats.m_flags + if not EEex_IsAtMostOneBitSet(flags, 0x1F8) then -- isolate bits 3-8 (0x8|0x10|0x20|0x40|0x80|0x100) + EEex_Error("Expected at most one of bits 3-8 to be set in m_flags!") + end + + local class = EEex_GameObject_GetClass(sprite) + local symbol = EEex_Resource_IDSToSymbol("CLASS", class) + local toReturn = { + ["active"] = class, + ["inactive"] = nil, + ["reactivated"] = nil, + } + + local func = function() + + local toReturn = false + local reactivated = EEex_Trigger_ParseConditionalString(string.format("Class(Myself,%d)", class)) + if reactivated:evalConditionalAsAIBase(sprite) then + toReturn = true + end + reactivated:free() + return toReturn + + end + + EEex_Utility_Switch(symbol, { + + -- FIGHTER_MAGE, resolves pair (1 <-> 2) + ["FIGHTER_MAGE"] = function() + if EEex_IsBitSet(flags, 0x3) then -- Original class: Fighter + toReturn["active"] = EEex_Resource_SymbolToIDS("CLASS", "MAGE") + toReturn["inactive"] = EEex_Resource_SymbolToIDS("CLASS", "FIGHTER") + elseif EEex_IsBitSet(flags, 0x4) then -- Original class: Mage + toReturn["active"] = EEex_Resource_SymbolToIDS("CLASS", "FIGHTER") + toReturn["inactive"] = EEex_Resource_SymbolToIDS("CLASS", "MAGE") + end + end, + + -- FIGHTER_CLERIC, resolves pair (2 <-> 3) + ["FIGHTER_CLERIC"] = function() + if EEex_IsBitSet(flags, 0x3) then -- Original class: Fighter + toReturn["active"] = EEex_Resource_SymbolToIDS("CLASS", "CLERIC") + toReturn["inactive"] = EEex_Resource_SymbolToIDS("CLASS", "FIGHTER") + elseif EEex_IsBitSet(flags, 0x5) then -- Original class: Cleric + toReturn["active"] = EEex_Resource_SymbolToIDS("CLASS", "FIGHTER") + toReturn["inactive"] = EEex_Resource_SymbolToIDS("CLASS", "CLERIC") + end + end, + + -- FIGHTER_THIEF, resolves pair (2 <-> 4) + ["FIGHTER_THIEF"] = function() + if EEex_IsBitSet(flags, 0x3) then -- Original class: Fighter + toReturn["active"] = EEex_Resource_SymbolToIDS("CLASS", "THIEF") + toReturn["inactive"] = EEex_Resource_SymbolToIDS("CLASS", "FIGHTER") + elseif EEex_IsBitSet(flags, 0x6) then -- Original class: Thief + toReturn["active"] = EEex_Resource_SymbolToIDS("CLASS", "FIGHTER") + toReturn["inactive"] = EEex_Resource_SymbolToIDS("CLASS", "THIEF") + end + end, + + -- MAGE_THIEF, resolves pair (1 <-> 4) + ["MAGE_THIEF"] = function() + if EEex_IsBitSet(flags, 0x4) then -- Original class: Mage + toReturn["active"] = EEex_Resource_SymbolToIDS("CLASS", "THIEF") + toReturn["inactive"] = EEex_Resource_SymbolToIDS("CLASS", "MAGE") + elseif EEex_IsBitSet(flags, 0x6) then -- Original class: Thief + toReturn["active"] = EEex_Resource_SymbolToIDS("CLASS", "MAGE") + toReturn["inactive"] = EEex_Resource_SymbolToIDS("CLASS", "THIEF") + end + end, + + -- CLERIC_MAGE, resolves pair (1 <-> 3) + ["CLERIC_MAGE"] = function() + if EEex_IsBitSet(flags, 0x5) then -- Original class: Cleric + toReturn["active"] = EEex_Resource_SymbolToIDS("CLASS", "MAGE") + toReturn["inactive"] = EEex_Resource_SymbolToIDS("CLASS", "CLERIC") + elseif EEex_IsBitSet(flags, 0x4) then -- Original class: Mage + toReturn["active"] = EEex_Resource_SymbolToIDS("CLASS", "CLERIC") + toReturn["inactive"] = EEex_Resource_SymbolToIDS("CLASS", "MAGE") + end + end, + + -- CLERIC_THIEF, resolves pair (3 <-> 4) + ["CLERIC_THIEF"] = function() + if EEex_IsBitSet(flags, 0x5) then -- Original class: Cleric + toReturn["active"] = EEex_Resource_SymbolToIDS("CLASS", "THIEF") + toReturn["inactive"] = EEex_Resource_SymbolToIDS("CLASS", "CLERIC") + elseif EEex_IsBitSet(flags, 0x6) then -- Original class: Thief + toReturn["active"] = EEex_Resource_SymbolToIDS("CLASS", "CLERIC") + toReturn["inactive"] = EEex_Resource_SymbolToIDS("CLASS", "THIEF") + end + end, + + -- FIGHTER_DRUID, resolves pair (2 <-> 11) + ["FIGHTER_DRUID"] = function() + if EEex_IsBitSet(flags, 0x3) then -- Original class: Fighter + toReturn["active"] = EEex_Resource_SymbolToIDS("CLASS", "DRUID") + toReturn["inactive"] = EEex_Resource_SymbolToIDS("CLASS", "FIGHTER") + elseif EEex_IsBitSet(flags, 0x7) then -- Original class: Druid + toReturn["active"] = EEex_Resource_SymbolToIDS("CLASS", "FIGHTER") + toReturn["inactive"] = EEex_Resource_SymbolToIDS("CLASS", "DRUID") + end + end, + + -- CLERIC_RANGER, resolves pair (3 <-> 12) + ["CLERIC_RANGER"] = function() + if EEex_IsBitSet(flags, 0x5) then -- Original class: Cleric + toReturn["active"] = EEex_Resource_SymbolToIDS("CLASS", "RANGER") + toReturn["inactive"] = EEex_Resource_SymbolToIDS("CLASS", "CLERIC") + elseif EEex_IsBitSet(flags, 0x8) then -- Original class: Ranger + toReturn["active"] = EEex_Resource_SymbolToIDS("CLASS", "CLERIC") + toReturn["inactive"] = EEex_Resource_SymbolToIDS("CLASS", "RANGER") + end + end, + + }) -- no defaultCase needed: unhandled classes will simply return with inactive = nil, which is the expected value for non-dual-classable classes. + + if toReturn["inactive"] then + toReturn["reactivated"] = func() + end + + return toReturn +end +CGameSprite.getActiveInactiveClasses = EEex_Sprite_GetActiveInactiveClasses + +-- @bubb_doc { EEex_Sprite_GetLevels / instance_name=getLevels } +-- @summary: +-- +-- Returns the base and current (i.e. modified) class levels for the given sprite. @EOL +-- For dual-classed / multi-classed characters, will also return the highest class levels. @EOL +-- For dual-classed characters, the "highest" levels will be identical to the active class levels. +-- +-- @self { sprite / usertype=CGameSprite }: Input sprite. +-- +-- @return { type=table }: See summary. + +function EEex_Sprite_GetLevels(sprite) + + if not EEex_GameObject_IsSprite(sprite) then + EEex_Error("Expected CGameSprite!") + end + + local baseStats = sprite.m_baseStats -- CCreatureFileHeader + local activeStats = EEex_Sprite_GetActiveStats(sprite) -- CDerivedStats + + local class = EEex_GameObject_GetClass(sprite) + local symbol = EEex_Resource_IDSToSymbol("CLASS", class) + + local toReturn = { + ["base"] = { + ["first"] = baseStats.m_level1, + ["second"] = 0, + ["third"] = 0, + ["highest"] = baseStats.m_level1, + }, + ["active"] = { + ["first"] = activeStats.m_nLevel1, + ["second"] = 0, + ["third"] = 0, + ["highest"] = activeStats.m_nLevel1, + }, + } + + local two = function() + toReturn.base.second = baseStats.m_level2 + toReturn.active.second = activeStats.m_nLevel2 + -- + local tbl = EEex_Sprite_GetActiveInactiveClasses(sprite) + if tbl["inactive"] then -- dualclass + if string.find(symbol, EEex_Resource_IDSToSymbol("CLASS", tbl["inactive"]) .. "_", 1, true) then + toReturn.base.highest = baseStats.m_level2 + toReturn.active.highest = activeStats.m_nLevel2 + else + toReturn.base.highest = baseStats.m_level1 + toReturn.active.highest = activeStats.m_nLevel1 + end + else -- true multiclass + toReturn.base.highest = math.max(toReturn.base.first, toReturn.base.second) + toReturn.active.highest = math.max(toReturn.active.first, toReturn.active.second) + end + end + + local three = function() + toReturn.base.second = baseStats.m_level2 + toReturn.active.second = activeStats.m_nLevel2 + toReturn.base.third = baseStats.m_level3 + toReturn.active.third = activeStats.m_nLevel3 + -- + toReturn.base.highest = math.max(toReturn.base.first, toReturn.base.second, toReturn.base.third) + toReturn.active.highest = math.max(toReturn.active.first, toReturn.active.second, toReturn.active.third) + end + + EEex_Utility_Switch(symbol, { + + ["FIGHTER_MAGE"] = two, -- NB: if we write ``two()``, then Lua calls the function immediately at table construction time, and assigns its return value to the key. Since two returns nothing, the return value is ``nil``, and that is what gets assigned to the key, which is not what we want. By writing just ``two``, we are assigning the function itself to the key, and EEex_Utility_Switch will call it when that case is hit. + ["FIGHTER_CLERIC"] = two, + ["FIGHTER_THIEF"] = two, + ["MAGE_THIEF"] = two, + ["CLERIC_MAGE"] = two, + ["CLERIC_THIEF"] = two, + ["FIGHTER_DRUID"] = two, + ["CLERIC_RANGER"] = two, + -- + ["FIGHTER_MAGE_THIEF"] = three, + ["FIGHTER_MAGE_CLERIC"] = three, + + }) -- no defaultCase needed: playable single classes and non-playable classes will simply keep default values. + + return toReturn + +end +CGameSprite.getLevels = EEex_Sprite_GetLevels + ------------------------------ -- / End Instance Functions -- ------------------------------ diff --git a/EEex/copy/EEex_scripts/EEex_Utility.lua b/EEex/copy/EEex_scripts/EEex_Utility.lua index f10ed5e..f41ceb3 100644 --- a/EEex/copy/EEex_scripts/EEex_Utility.lua +++ b/EEex/copy/EEex_scripts/EEex_Utility.lua @@ -102,6 +102,57 @@ function EEex_Utility_GetOrCreateTable(t, key, fillFunc) return default end +-- @bubb_doc { EEex_Utility_PickRandom } +-- @summary: +-- +-- Picks n unique random elements from table t, using a partial Fisher-Yates shuffle. @EOL +-- Every possible combination of n elements has exactly equal probability of being chosen. @EOL +-- The original table is never mutated. @EOL +-- Time complexity is O(n); space complexity is O(#t) due to the full shallow copy of the input. +-- +-- @param { t / type=table }: The source table to pick from. +-- @param { n / type=number }: How many unique elements to pick. Defaults to 1 if not specified. +-- +-- @return { type=table }: A new table containing the n picked elements. + +function EEex_Utility_PickRandom(t, n) + -- default to picking a single element if n is not specified + n = n or 1 + + -- guard against impossible requests (e.g. picking 4 from a 3-element table) + assert(n <= #t, "cannot pick more elements than the table contains") + + -- shallow copy so the original table is never mutated; + -- important when reusing the same list across multiple calls + local pool = {} + for i, v in ipairs(t) do + pool[i] = v + end + + local result = {} + + -- partial Fisher-Yates shuffle: instead of shuffling the entire table, + -- we only run the algorithm for the n elements we actually need, + -- stopping early once we have enough picks — hence "partial". + -- Fisher-Yates works by iterating backwards and swapping each element + -- with a randomly chosen one from the still-unprocessed region [1..i], + -- which guarantees every permutation is equally likely. + for i = #pool, #pool - n + 1, -1 do + + -- pick a random index from the still-unpicked region [1..i] + local j = math.random(i) + + -- swap the randomly chosen element to position i, + -- effectively removing it from the unpicked region + pool[i], pool[j] = pool[j], pool[i] + + -- collect the picked element + result[#result + 1] = pool[i] + end + + return result +end + function EEex_Utility_IterateCPtrList(list, func) local node = list.m_pNodeHead while node do @@ -183,6 +234,56 @@ function EEex_Utility_TryIgnore(func, ...) return select(2, table.unpack(result)) end +-- @bubb_doc { EEex_Utility_GetDistance / EEex_Utility_GetDistanceIsometric } +-- @summary: +-- +-- Gets the (isometric) distance between two points. +-- +-- @param { x1 / type=number }: The x-coordinate of the first point. +-- @param { y1 / type=number }: The y-coordinate of the first point. +-- @param { x2 / type=number }: The x-coordinate of the second point. +-- @param { y2 / type=number }: The y-coordinate of the second point. +-- +-- @return { type=number }: The distance between the two points. + +function EEex_Utility_GetDistance(x1, y1, x2, y2) + return math.floor((((x1 - x2) ^ 2) + ((y1 - y2) ^ 2)) ^ .5) +end + +function EEex_Utility_GetDistanceIsometric(x1, y1, x2, y2) + return math.floor(((x1 - x2) ^ 2 + (4/3 * (y1 - y2)) ^ 2) ^ .5) +end + +-- @bubb_doc { EEex_Utility_DJB2 } +-- @summary: +-- +-- Variant of the djb2 hash algorithm, which is highly efficient for turning a string into a numeric value. +-- +-- @param { str / type=string }: The input string. +-- +-- @return { type=number }: The hash value corresponding to the input string. + +function EEex_Utility_DJB2(str) + -- Initializes the hash value. The number 5381 is just an arbitrary, + -- large prime number chosen as a starting point. Using a non-zero, + -- prime initializer helps ensure a good initial mix of bits, which + -- improves the quality of the final hash + local hash = 5381 + for i = 1, #str do + -- Multiplies the current hash by 33. This + -- multiplication spreads out the bits of the hash and provides strong + -- mixing. (The number 33 is used because it's 32+1, which is fast + -- for computers: it's a left bit shift by 5, plus an addition) + + -- Adds the numeric ASCII/byte value of the current character to the result. + -- This incorporates the unique value of the character into the hash + hash = (hash * 33) + string.byte(str, i) + end + -- Returns the final calculated numeric hash value. + -- This is the unique, predictable number you could use for ``math.randomseed()`` and the like + return hash +end + --------------- -- Iterators -- --------------- diff --git a/EEex/copy/override/M___EEex.lua b/EEex/copy/override/M___EEex.lua index c8f741f..b322db2 100644 --- a/EEex/copy/override/M___EEex.lua +++ b/EEex/copy/override/M___EEex.lua @@ -2,3 +2,105 @@ if not EEex_Active then error("[!] ERROR: EEex not active.\n\nDid you forget to start the game with InfinityLoader.exe?") end + +-- Mandatory module validity tests. +-- +-- PURPOSE: Verify that the Lua standard library modules EEex depends on have not been +-- tampered with since they were snapshotted by EEex_LuaModule.lua during bootstrap. +-- Each test retrieves the module through the read-only verified proxy (which runs identity +-- and dual-oracle C-function checks on every access) and then asserts that specific +-- function members are present and genuine. +-- +-- TIMING: This file runs after EEex_Main.lua has completed (i.e. after all hooks and +-- patch scripts have been installed), so any tampering introduced during the startup +-- sequence will be caught here before user-facing game code executes. +-- +-- FAILURE BEHAVIOUR: Any failed test raises a hard error that halts execution. This is +-- intentional: a missing or corrupted standard library function would cause unpredictable +-- behaviour later, so it is safer to abort early with a clear message. +(function() + -- Idempotency guard: the engine may load override scripts more than once per session. + -- We only need to run these tests once; subsequent calls are no-ops. + if rawget(_G, "__EEex_LuaModule_MandatoryRan") then + return + end + rawset(_G, "__EEex_LuaModule_MandatoryRan", true) + + local function fail(msg) + error("[!] ERROR: LuaModule mandatory tests: " .. msg, 0) + end + + -- Locate the getter installed by EEex_LuaModule.lua. + -- We try the standalone global first, then fall back to EEex.GetLuaModule. + -- If neither is available, startup was incomplete — fail immediately. + local getter = rawget(_G, "EEex_GetLuaModule") + if type(getter) ~= "function" then + if type(EEex) == "table" and type(EEex.GetLuaModule) == "function" then + getter = EEex.GetLuaModule + else + fail("getter not installed; expected EEex_LuaModule.lua to run during startup") + end + end + + -- requireMember: assert that a specific key on the proxy is a function. + -- Because the proxy's __index runs identity + C-oracle checks on every access, + -- this also implicitly validates that the member is still a genuine C function. + local function requireMember(moduleProxy, moduleName, memberName) + local member = moduleProxy[memberName] + if type(member) ~= "function" then + fail("module '" .. moduleName .. "' missing function '" .. memberName .. "'") + end + end + + -- Runtime detection: check whether we are running under LuaJIT by probing the "jit" + -- global, which LuaJIT always exposes with a "version" string field. + -- This determines which bit-manipulation module to test: + -- LuaJIT provides "bit" (Mike Pall's bitop library, compatible with Lua 5.1 semantics) + -- Lua 5.2 provides "bit32" (standard library introduced in 5.2) + local isLuaJIT = type(rawget(_G, "jit")) == "table" + and type(rawget(rawget(_G, "jit"), "version")) == "string" + + -- Module expectation table. + -- Each entry: { name = , required = { } } + -- The listed functions are the minimum set that EEex relies on being genuine C functions. + local moduleExpectations = { + -- debug: used for stack introspection and the dual-oracle trust chain itself. + { name = "debug", required = { "getinfo", "traceback", "getupvalue" } }, + -- math: used pervasively for numeric operations. + { name = "math", required = { "abs", "floor", "ceil", "max", "min", "random", "randomseed", "sqrt", "acos" } }, + -- string: used for text processing; string.dump is Oracle 1 in the trust chain. + { name = "string", required = { "byte", "char", "dump", "find", "format", "gsub", "sub", "match", "len", "lower", "upper" } }, + -- table: used for list and map operations throughout EEex. + { name = "table", required = { "concat", "insert", "remove", "sort" } }, + -- io: used for file stream operations (open/read/write/close) in scripts that interact with disk. + -- Optional (disabled by default): only the bit32, debug, math, string, and table modules are normally available. + -- Uncomment if you want startup to fail when core io APIs are unavailable or tampered. + -- { name = "io", required = { "open", "close", "read", "write" } }, + -- os: used for runtime clock/time/date utilities and environment-level operations. + -- Optional (disabled by default): only the bit32, debug, math, string, and table modules are normally available. + -- Uncomment if you require strict validation of core os APIs at startup. + -- { name = "os", required = { "clock", "date", "difftime", "time" } }, + } + + if isLuaJIT then + -- bit: LuaJIT's bitwise operations library (replaces bit32 from Lua 5.2). + moduleExpectations[#moduleExpectations + 1] = { name = "bit", required = { "band", "bor", "bxor", "lshift", "rshift" } } + -- jit: LuaJIT control API; "status" confirms the JIT compiler is operational. + moduleExpectations[#moduleExpectations + 1] = { name = "jit", required = { "status" } } + else + -- bit32: Lua 5.2 standard bitwise operations library. + moduleExpectations[#moduleExpectations + 1] = { name = "bit32", required = { "band", "bor", "bxor", "lshift", "rshift" } } + end + + -- Run all tests. getLuaModule performs snapshot + identity + C-oracle checks internally; + -- requireMember then confirms that each listed function is accessible and function-typed. + for _, expectation in ipairs(moduleExpectations) do + local moduleProxy = getter(expectation.name) + if type(moduleProxy) ~= "table" then + fail("module '" .. expectation.name .. "' did not return a proxy table") + end + for _, memberName in ipairs(expectation.required) do + requireMember(moduleProxy, expectation.name, memberName) + end + end +end)() From de49a688a6d78fe038b58500aea37ac786f354d7 Mon Sep 17 00:00:00 2001 From: 4Luke4 Date: Sat, 2 May 2026 12:24:11 +0200 Subject: [PATCH 2/4] Update EEex_Sprite.lua --- EEex/copy/EEex_scripts/EEex_Sprite.lua | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/EEex/copy/EEex_scripts/EEex_Sprite.lua b/EEex/copy/EEex_scripts/EEex_Sprite.lua index 1b8833a..c9b5527 100644 --- a/EEex/copy/EEex_scripts/EEex_Sprite.lua +++ b/EEex/copy/EEex_scripts/EEex_Sprite.lua @@ -1497,8 +1497,8 @@ CGameSprite.getActiveInactiveClasses = EEex_Sprite_GetActiveInactiveClasses -- @summary: -- -- Returns the base and current (i.e. modified) class levels for the given sprite. @EOL --- For dual-classed / multi-classed characters, will also return the highest class levels. @EOL --- For dual-classed characters, the "highest" levels will be identical to the active class levels. +-- For dual-classed / multi-classed characters, will also return the highest and average (rounded up) class levels. @EOL +-- For dual-classed characters, the "highest" and "average" levels will be identical to the active class levels. -- -- @self { sprite / usertype=CGameSprite }: Input sprite. -- @@ -1522,12 +1522,14 @@ function EEex_Sprite_GetLevels(sprite) ["second"] = 0, ["third"] = 0, ["highest"] = baseStats.m_level1, + ["average"] = baseStats.m_level1, }, ["active"] = { ["first"] = activeStats.m_nLevel1, ["second"] = 0, ["third"] = 0, ["highest"] = activeStats.m_nLevel1, + ["average"] = activeStats.m_nLevel1, }, } @@ -1540,13 +1542,22 @@ function EEex_Sprite_GetLevels(sprite) if string.find(symbol, EEex_Resource_IDSToSymbol("CLASS", tbl["inactive"]) .. "_", 1, true) then toReturn.base.highest = baseStats.m_level2 toReturn.active.highest = activeStats.m_nLevel2 + -- + toReturn.base.average = baseStats.m_level2 + toReturn.active.average = activeStats.m_nLevel2 else toReturn.base.highest = baseStats.m_level1 toReturn.active.highest = activeStats.m_nLevel1 + -- + toReturn.base.average = baseStats.m_level1 + toReturn.active.average = activeStats.m_nLevel1 end else -- true multiclass toReturn.base.highest = math.max(toReturn.base.first, toReturn.base.second) toReturn.active.highest = math.max(toReturn.active.first, toReturn.active.second) + -- + toReturn.base.average = math.ceil((toReturn.base.first + toReturn.base.second) / 2) + toReturn.active.average = math.ceil((toReturn.active.first + toReturn.active.second) / 2) end end @@ -1558,6 +1569,9 @@ function EEex_Sprite_GetLevels(sprite) -- toReturn.base.highest = math.max(toReturn.base.first, toReturn.base.second, toReturn.base.third) toReturn.active.highest = math.max(toReturn.active.first, toReturn.active.second, toReturn.active.third) + -- + toReturn.base.average = math.ceil((toReturn.base.first + toReturn.base.second + toReturn.base.third) / 3) + toReturn.active.average = math.ceil((toReturn.active.first + toReturn.active.second + toReturn.active.third) / 3) end EEex_Utility_Switch(symbol, { From 0d770a0b4464ceff829f822400af3638d05362bb Mon Sep 17 00:00:00 2001 From: 4Luke4 Date: Sat, 2 May 2026 17:27:17 +0200 Subject: [PATCH 3/4] Update EEex_Sprite.lua --- EEex/copy/EEex_scripts/EEex_Sprite.lua | 34 ++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/EEex/copy/EEex_scripts/EEex_Sprite.lua b/EEex/copy/EEex_scripts/EEex_Sprite.lua index c9b5527..d46b19e 100644 --- a/EEex/copy/EEex_scripts/EEex_Sprite.lua +++ b/EEex/copy/EEex_scripts/EEex_Sprite.lua @@ -1595,6 +1595,40 @@ function EEex_Sprite_GetLevels(sprite) end CGameSprite.getLevels = EEex_Sprite_GetLevels +-- @bubb_doc { EEex_Sprite_GetSelectedWeapon / instance_name=getSelectedWeapon } +-- @summary: +-- +-- Returns information about the currently selected weapon for the given sprite, including: @EOL +-- - The weapon slot (as per ``SLOTS.IDS``) currently selected in the Inventory UI. @EOL +-- - The ``CItem`` userdata for the currently selected weapon. @EOL +-- - The ability index associated with the selected weapon. @EOL +-- - The launcher (``CItem`` userdata) associated with the selected weapon ability, if any. @EOL +-- - The launcher slot (as per ``SLOTS.IDS``) associated with the selected weapon ability, if any. +-- +-- @self { sprite / usertype=CGameSprite }: Input sprite. +-- +-- @return { type=table }: See summary. + +function EEex_Sprite_GetSelectedWeapon(sprite) + + if not EEex_GameObject_IsSprite(sprite) then + EEex_Error("Expected CGameSprite!") + end + + local equipment = sprite.m_equipment -- CGameSpriteEquipment + + local toReturn = { + ["weaponSlot"] = equipment.m_selectedWeapon, -- number + ["weapon"] = equipment.m_items:get(equipment.m_selectedWeapon) or EEex_Error("Expected CItem!"), -- CItem + ["weaponAbility"] = equipment.m_selectedWeaponAbility, -- number + ["launcher"] = sprite:getLauncher(equipment.m_selectedWeaponAbility) or nil, -- CItem + ["launcherSlot"] = sprite:getLauncher(equipment.m_selectedWeaponAbility) and select(2, sprite:getLauncher(equipment.m_selectedWeaponAbility)) or nil, -- number + } + + return toReturn +end +CGameSprite.getSelectedWeapon = EEex_Sprite_GetSelectedWeapon + ------------------------------ -- / End Instance Functions -- ------------------------------ From 58ae8286b3636fe9e88fe0c3291bad45d0f5bc64 Mon Sep 17 00:00:00 2001 From: 4Luke4 Date: Sun, 10 May 2026 17:25:42 +0200 Subject: [PATCH 4/4] Update EEex_Resource.lua --- EEex/copy/EEex_scripts/EEex_Resource.lua | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/EEex/copy/EEex_scripts/EEex_Resource.lua b/EEex/copy/EEex_scripts/EEex_Resource.lua index efc704c..f70e95c 100644 --- a/EEex/copy/EEex_scripts/EEex_Resource.lua +++ b/EEex/copy/EEex_scripts/EEex_Resource.lua @@ -155,6 +155,42 @@ function EEex_Resource_Demand(resref, extension) return demanded end +-- @bubb_doc { EEex_Resource_DecodeSpell } +-- +-- @summary: Returns the spell filename corresponding to ``spellIDS``. +-- +-- @note: The first character of the numeric code identifies the spell prefix: +-- * 1 -> ``SPPR`` +-- * 2 -> ``SPWI`` +-- * 3 -> ``SPIN`` +-- * 4 -> ``SPCL`` +-- * Anything else defaults to ``MARW``. +-- +-- The remaining three characters identify the spell filename, i.e. ``1101`` refers to ``SPPR101``. +-- +-- @param { spellIDS / type=number }: The ID of the spell to decode. +-- +-- @return { type=string }: See summary. + +function EEex_Resource_DecodeSpell(spellIDS) + local prefix + local spellType = math.floor(spellIDS / 1000) + -- + if spellType == 1 then + prefix = "SPPR" + elseif spellType == 2 then + prefix = "SPWI" + elseif spellType == 3 then + prefix = "SPIN" + elseif spellType == 4 then + prefix = "SPCL" + else + prefix = "MARW" + end + -- + return prefix .. string.format("%03d", spellIDS % 1000) +end + function EEex_Resource_GetSpellAbility(spellHeader, abilityIndex) if spellHeader.abilityCount <= abilityIndex then return end return EEex_PtrToUD(EEex_UDToPtr(spellHeader) + spellHeader.abilityOffset + Spell_ability_st.sizeof * abilityIndex, "Spell_ability_st")