diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/networking/openssl_ffi.lua b/networking/openssl_ffi.lua index fb1c473..b61b53f 100644 --- a/networking/openssl_ffi.lua +++ b/networking/openssl_ffi.lua @@ -130,112 +130,168 @@ local function load_openssl() end local ok, err = pcall(function() - -- Build list of names to try: short names first, then absolute paths - local ssl_names = { - 'libssl.dll', - 'libssl-3-x64.dll', - 'libssl-3.dll', - 'libssl-1_1-x64.dll', - 'libssl-1_1.dll', - 'ssl.dll', - 'ssl-3-x64.dll', - 'ssl-3.dll', - 'libssl-3-x64', - 'libssl-3', - 'libssl', - 'libssl-1_1-x64', - 'libssl-1_1', - 'ssl-3-x64', - 'ssl-3', - 'ssl', - } - local crypto_names = { - 'libcrypto.dll', - 'libcrypto-3-x64.dll', - 'libcrypto-3.dll', - 'libcrypto-1_1-x64.dll', - 'libcrypto-1_1.dll', - 'crypto.dll', - 'crypto-3-x64.dll', - 'crypto-3.dll', - 'libcrypto-3-x64', - 'libcrypto-3', - 'libcrypto', - 'libcrypto-1_1-x64', - 'libcrypto-1_1', - 'crypto-3-x64', - 'crypto-3', - 'crypto', - } - - -- Discover base paths to search for DLLs - local search_dirs = { '' } -- empty string = default search path - - -- love.filesystem.getSource() may return the exe path (e.g. - -- "Z:\...\Balatro\Balatro.exe"); strip the filename to get the dir. - if love and love.filesystem then - local src = love.filesystem.getSource() - if src then - -- Strip trailing filename if it looks like an exe/file - local dir = src:match('^(.+)[/\\][^/\\]+%.[^/\\]+$') or src - search_dirs[#search_dirs + 1] = dir .. '/' - -- Also try with backslash (Wine paths) - search_dirs[#search_dirs + 1] = dir .. '\\' + -- Build list of names to try, per-platform. ffi.os is one of + -- 'Windows', 'OSX', 'Linux', 'BSD', etc. The library *names* differ by + -- OS (.dll / .dylib / .so) but every binding below is identical. + local ssl_names, crypto_names + if ffi.os == 'OSX' then + -- macOS: we ship OpenSSL 3 as versioned .dylib files in the mod and + -- load them by absolute path (see the search-dir block below). Apple's + -- own /usr/lib/libssl.dylib is LibreSSL (deprecated, missing symbols), + -- so we never load by bare name. + ssl_names = { 'libssl.3.dylib' } + crypto_names = { 'libcrypto.3.dylib' } + elseif ffi.os == 'Windows' then + ssl_names = { + 'libssl.dll', + 'libssl-3-x64.dll', + 'libssl-3.dll', + 'libssl-1_1-x64.dll', + 'libssl-1_1.dll', + 'ssl.dll', + 'ssl-3-x64.dll', + 'ssl-3.dll', + 'libssl-3-x64', + 'libssl-3', + 'libssl', + 'libssl-1_1-x64', + 'libssl-1_1', + 'ssl-3-x64', + 'ssl-3', + 'ssl', + } + crypto_names = { + 'libcrypto.dll', + 'libcrypto-3-x64.dll', + 'libcrypto-3.dll', + 'libcrypto-1_1-x64.dll', + 'libcrypto-1_1.dll', + 'crypto.dll', + 'crypto-3-x64.dll', + 'crypto-3.dll', + 'libcrypto-3-x64', + 'libcrypto-3', + 'libcrypto', + 'libcrypto-1_1-x64', + 'libcrypto-1_1', + 'crypto-3-x64', + 'crypto-3', + 'crypto', + } + else + -- Linux / BSD: shared objects with .so soname suffixes. + ssl_names = { + 'libssl.so', + 'libssl.so.3', + 'libssl.so.1.1', + 'ssl', + } + crypto_names = { + 'libcrypto.so', + 'libcrypto.so.3', + 'libcrypto.so.1.1', + 'crypto', + } + end + + -- Discover base paths to search for the libraries. + local search_dirs = {} + + if ffi.os == 'OSX' then + -- We ship libssl.3.dylib + libcrypto.3.dylib in the mod's networking/ + -- folder and load them by ABSOLUTE path. A bare name is not an option + -- on macOS: it resolves to Apple's /usr/lib LibreSSL and aborts the + -- process with "loading libcrypto in an unsafe way" (Abort trap: 6). + -- + -- We can't read the mod path from MPAPI here (the MQTT worker runs in + -- its own Lua state with no MPAPI), but we don't need to: core.lua + -- adds "/networking/?.lua" to package.path, and that string is + -- forwarded to the worker's setup, so the absolute networking/ dir is + -- already available wherever this runs. Pull it back out of there. + local dir + for entry in (package.path or ''):gmatch('[^;]+') do + dir = entry:match('^(.-[/\\]networking[/\\])%?') + if dir then + break + end + end + -- Fallback to MPAPI.path on the main thread if the entry isn't present. + if not dir and MPAPI and MPAPI.path then + dir = (MPAPI.path:gsub('/*$', '')) .. '/networking/' end - local save = love.filesystem.getSaveDirectory() - if save then - search_dirs[#search_dirs + 1] = save .. '/' + if dir then + search_dirs[#search_dirs + 1] = dir + end + elseif ffi.os == 'Windows' then + search_dirs[#search_dirs + 1] = '' -- default DLL search path + else + -- Linux/BSD: the system OpenSSL is real OpenSSL (not LibreSSL), so the + -- default search path and standard lib dirs are safe to use. + search_dirs[#search_dirs + 1] = '' -- default ffi.load search path + local nix_dirs = { + '/usr/lib/', + '/usr/lib/x86_64-linux-gnu/', + '/usr/lib64/', + '/usr/local/lib/', + '/lib/', + } + for _, d in ipairs(nix_dirs) do + search_dirs[#search_dirs + 1] = d end end - -- Also try CWD-relative - search_dirs[#search_dirs + 1] = './' + -- Search game-relative locations discovered via love.filesystem. This is + -- really for Windows, where OpenSSL DLLs are dropped next to the game and + -- getSource() returns that folder. On Linux it's just a harmless extra + -- fallback (the real libs come from the system path / nix_dirs above). + -- macOS is skipped entirely: its libs are bundled in the mod folder + -- (found via package.path above), so getSource()/save/'./' only point at + -- the wrong places here -- and a bare './name' could even reach the + -- system LibreSSL and abort the process. + if ffi.os ~= 'OSX' then + -- love.filesystem.getSource() may return the exe path (e.g. + -- "Z:\...\Balatro\Balatro.exe"); strip the filename to get the dir. + if love and love.filesystem then + local src = love.filesystem.getSource() + if src then + -- Strip trailing filename if it looks like an exe/file + local dir = src:match('^(.+)[/\\][^/\\]+%.[^/\\]+$') or src + search_dirs[#search_dirs + 1] = dir .. '/' + -- Also try with backslash (Wine paths) + search_dirs[#search_dirs + 1] = dir .. '\\' + end + local save = love.filesystem.getSaveDirectory() + if save then + search_dirs[#search_dirs + 1] = save .. '/' + end + end - ssl_log('Search directories:') - for i, dir in ipairs(search_dirs) do - ssl_log(' [' .. i .. '] ' .. (dir == '' and '(default ffi.load path)' or dir)) + -- Also try CWD-relative + search_dirs[#search_dirs + 1] = './' end - -- Load crypto FIRST (libssl depends on libcrypto) - for _, dir in ipairs(search_dirs) do - for _, name in ipairs(crypto_names) do - local path = dir .. name - local ok2, lib = pcall(ffi.load, path) - if ok2 then - crypto_lib = lib - ssl_log('Loaded crypto library: ' .. path) - break - else - ssl_log(' tried crypto: ' .. path .. ' -> ' .. tostring(lib)) + -- Try every (dir, name) combination; return the first lib that loads. + local function try_load(names, label) + for _, dir in ipairs(search_dirs) do + for _, name in ipairs(names) do + local path = dir .. name + local ok2, lib = pcall(ffi.load, path) + if ok2 then + ssl_log('Loaded ' .. label .. ' library: ' .. path) + return lib + end + ssl_log(' tried ' .. label .. ': ' .. path .. ' -> ' .. tostring(lib)) end end - if crypto_lib then - break - end end + -- crypto first: libssl depends on libcrypto already being loaded. + crypto_lib = try_load(crypto_names, 'crypto') if not crypto_lib then ssl_warn('Could not load crypto library (ssl may still work if system-provided)') end - -- Now try loading SSL library (depends on crypto being loaded) - for _, dir in ipairs(search_dirs) do - for _, name in ipairs(ssl_names) do - local path = dir .. name - local ok2, lib = pcall(ffi.load, path) - if ok2 then - ssl_lib = lib - ssl_log('Loaded SSL library: ' .. path) - break - else - ssl_log(' tried ssl: ' .. path .. ' -> ' .. tostring(lib)) - end - end - if ssl_lib then - break - end - end - + ssl_lib = try_load(ssl_names, 'ssl') if not ssl_lib then error('Could not load OpenSSL SSL library') end diff --git a/networking/steam.lua b/networking/steam.lua index f6be81c..3f430d8 100644 --- a/networking/steam.lua +++ b/networking/steam.lua @@ -60,8 +60,18 @@ if ffi_ok then return ok end - safe_cdef('void* GetModuleHandleA(const char* lpModuleName);') - safe_cdef('void* GetProcAddress(void* hModule, const char* lpProcName);') + local IS_WINDOWS = (ffi.os == 'Windows') + + if IS_WINDOWS then + -- Win32: resolve symbols from the already-loaded steam_api64.dll + safe_cdef('void* GetModuleHandleA(const char* lpModuleName);') + safe_cdef('void* GetProcAddress(void* hModule, const char* lpProcName);') + else + -- POSIX (macOS/Linux): dlopen/dlsym live in libc / libSystem, which is + -- always loaded, so they are reachable through the default ffi.C namespace. + safe_cdef('void* dlopen(const char* filename, int flag);') + safe_cdef('void* dlsym(void* handle, const char* symbol);') + end safe_cdef('typedef struct ISteamUser ISteamUser;') safe_cdef('typedef uint32_t HAuthTicket;') @@ -82,26 +92,72 @@ if ffi_ok then local _get_auth_ticket_fn = nil local _cancel_auth_ticket_fn = nil + -- Candidate names for the loaded Steam library, per platform. The leaf name + -- is enough: the game/luasteam has already pulled it into the process, so + -- dlopen/GetModuleHandleA resolve to the existing image without reloading. + local steam_module_names + if IS_WINDOWS then + steam_module_names = { 'steam_api64.dll', 'steam_api64', 'steam_api.dll', 'steam_api' } + elseif ffi.os == 'OSX' then + steam_module_names = { 'libsteam_api.dylib', 'steam_api', 'libsteam_api' } + else + steam_module_names = { 'libsteam_api.so', 'steam_api', 'libsteam_api' } + end + + -- RTLD_LAZY is 0x1 on both macOS and Linux. RTLD_DEFAULT (search all loaded + -- images) is the last-resort fallback if dlopen-by-leaf-name fails. + local RTLD_LAZY = 0x1 + local RTLD_DEFAULT = (ffi.os == 'OSX') and ffi.cast('void*', -2) or ffi.cast('void*', 0) + + -- Returns (handle, name) for the loaded Steam library, or nil on failure. + -- `name` doubles as the success flag so we never test a possibly-NULL handle + -- pointer for truthiness (RTLD_DEFAULT is NULL on Linux). + local function get_steam_module() + for _, name in ipairs(steam_module_names) do + local ok, h + if IS_WINDOWS then + ok, h = pcall(function() + return ffi.C.GetModuleHandleA(name) + end) + else + ok, h = pcall(function() + return ffi.C.dlopen(name, RTLD_LAZY) + end) + end + if ok and h ~= nil then + return h, name + end + end + if not IS_WINDOWS then + -- Symbols are already in the process even if dlopen couldn't find the + -- file on disk; let dlsym search every loaded image. + return RTLD_DEFAULT, 'RTLD_DEFAULT' + end + return nil + end + + -- Resolve a named symbol from a module handle, cross-platform. The symbol + -- name is the plain C name on both sides (dlsym prepends the Mach-O '_'). + local function get_proc(handle, name) + if IS_WINDOWS then + return ffi.C.GetProcAddress(handle, name) + else + return ffi.C.dlsym(handle, name) + end + end + local function resolve_game_steam() if _steam_user_ptr then return true end - -- Get the game's loaded steam_api64.dll handle - local ok, hModule = pcall(function() - return ffi.C.GetModuleHandleA('steam_api64.dll') - end) - if not ok or not hModule or hModule == nil then - -- Try without extension - ok, hModule = pcall(function() - return ffi.C.GetModuleHandleA('steam_api64') - end) - end - if not ok or not hModule or hModule == nil or not hModule then - warn('GetModuleHandleA failed — steam_api64.dll not loaded in process') + -- Get a handle to the game's already-loaded Steam library. + local hModule, modName = get_steam_module() + if not modName then + warn('Could not locate loaded Steam library (steam_api64.dll / libsteam_api.dylib) in process') return false end - log("Got game's steam_api64.dll module handle") + log("Got game's Steam library handle via " .. modName) -- Resolve the ISteamUser accessor (try version strings) local accessor_names = { @@ -114,10 +170,10 @@ if ffi_ok then local user_ptr = nil for _, name in ipairs(accessor_names) do local proc_ok, proc = pcall(function() - return ffi.C.GetProcAddress(hModule, name) + return get_proc(hModule, name) end) if proc_ok and proc ~= nil and proc then - log('Resolved accessor via GetProcAddress: ' .. name) + log('Resolved Steam accessor: ' .. name) local accessor = ffi.cast('SteamUserAccessor_t', proc) local call_ok, ptr = pcall(accessor) if call_ok and ptr ~= nil and ptr then @@ -131,7 +187,7 @@ if ffi_ok then end if not user_ptr then - warn("Could not get ISteamUser from game's DLL") + warn("Could not get ISteamUser from the game's Steam library") return false end @@ -139,7 +195,7 @@ if ffi_ok then -- Resolve GetAuthSessionTicket local gat_ok, gat_proc = pcall(function() - return ffi.C.GetProcAddress(hModule, 'SteamAPI_ISteamUser_GetAuthSessionTicket') + return get_proc(hModule, 'SteamAPI_ISteamUser_GetAuthSessionTicket') end) if gat_ok and gat_proc ~= nil and gat_proc then _get_auth_ticket_fn = ffi.cast('GetAuthSessionTicket_t', gat_proc) @@ -148,7 +204,7 @@ if ffi_ok then -- Resolve CancelAuthTicket local cat_ok, cat_proc = pcall(function() - return ffi.C.GetProcAddress(hModule, 'SteamAPI_ISteamUser_CancelAuthTicket') + return get_proc(hModule, 'SteamAPI_ISteamUser_CancelAuthTicket') end) if cat_ok and cat_proc ~= nil and cat_proc then _cancel_auth_ticket_fn = ffi.cast('CancelAuthTicket_t', cat_proc) @@ -162,7 +218,7 @@ if ffi_ok then --- Get a Steam auth session ticket as a hex string. function steam.get_auth_ticket() if not resolve_game_steam() then - return nil, 'Could not resolve Steam auth functions from game DLL' + return nil, 'Could not resolve Steam auth functions from the game Steam library' end local max_ticket = 1024