noita-mapcap/files/modification.lua

396 lines
18 KiB
Lua
Raw Normal View History

-- Copyright (c) 2022-2023 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
-- Noita settings/configuration modifications.
-- We try to keep persistent modifications to a minimum, but some things have to be changed in order for the mod to work correctly.
-- There are 4 ways Noita can be modified by code:
-- - `config.xml`: These are persistent, and Noita needs to be force closed when changed from inside a mod.
-- - `magic_numbers.xml`: Persistent per world, can only be applied at mod startup.
-- - Process memory: Volatile, can be modified at runtime. Needs correct memory addresses to function.
-- - File patching: Volatile, can only be applied at mod startup.
--------------------------
-- Load library modules --
--------------------------
2022-07-28 11:27:02 +00:00
local CameraAPI = require("noita-api.camera")
local Coords = require("coordinates")
local ffi = require("ffi")
local Memory = require("memory")
local NXML = require("luanxml.nxml")
local Utils = require("noita-api.utils")
local Vec2 = require("noita-api.vec2")
local DebugAPI = require("noita-api.debug")
----------
-- Code --
----------
2022-07-29 13:29:15 +00:00
---Reads the current config from `config.xml` and returns it as table.
---@return table<string, string> config
function Modification.GetConfig()
local configFilename = Utils.GetSpecialDirectory("save-shared") .. "config.xml"
-- Read and modify config.
local f, err = io.open(configFilename, "r")
if not f then error(string.format("failed to read config file: %s", err)) end
local xml = NXML.parse(f:read("*a"))
f:close()
return xml.attr
end
---Will update Noita's `config.xml` with the values in the given table.
---
---This will force close Noita!
---@param config table<string, string> -- List of `config.xml` attributes that should be changed.
function Modification.SetConfig(config)
local configFilename = Utils.GetSpecialDirectory("save-shared") .. "config.xml"
-- Read and modify config.
local f, err = io.open(configFilename, "r")
if not f then error(string.format("failed to read config file: %s", err)) end
local xml = NXML.parse(f:read("*a"))
for k, v in pairs(config) do
xml.attr[k] = v
end
f:close()
-- Write modified config back.
local f, err = io.open(configFilename, "w")
if not f then error(string.format("failed to create config file: %s", err)) end
f:write(tostring(xml))
f:close()
-- We need to force close Noita, so it doesn't have any chance to overwrite the file.
os.exit(0)
end
---Will update Noita's `magic_numbers.xml` with the values in the given table.
---
---Should be called on mod initialization only.
---@param magic table<string, string> -- List of `magic_numbers.xml` attributes that should be changed.
function Modification.SetMagicNumbers(magic)
local xml = NXML.new_element("MagicNumbers", magic)
-- Write magic number file.
local f, err = io.open("mods/noita-mapcap/files/magic-numbers/generated.xml", "w")
if not f then error(string.format("failed to create config file: %s", err)) end
f:write(tostring(xml))
f:close()
ModMagicNumbersFileAdd("mods/noita-mapcap/files/magic-numbers/generated.xml")
end
---Changes some options directly by manipulating process memory.
---
---Related issue: https://github.com/Dadido3/noita-mapcap/issues/14.
---@param memory table
function Modification.SetMemoryOptions(memory)
-- Lookup table with the following hierarchy:
-- DevBuild -> OS -> BuildDate -> Option -> ModFunc.
local lookup = {
[true] = {
Windows = {
{_Offset = 0x00F77B0C, _BuildString = "Build Apr 23 2021 18:36:55", -- GOG dev build.
2023-07-27 08:40:41 +00:00
mPostFxDisabled = function(value) ffi.cast("char*", 0x010E3B6C)[0] = value end, -- Can be found by using Cheat Engine while toggling options in the F7 menu.
mGuiDisabled = function(value) ffi.cast("char*", 0x010E3B6D)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x010E3B6E)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010E3B6F)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x010E3B70)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010E3B71)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010E3B72)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x010E3B73)[0] = value end,
},
{_Offset = 0x00F80384, _BuildString = "Build Apr 23 2021 18:40:40", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x010EDEBC)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x010EDEBD)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x010EDEBE)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010EDEBF)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x010EDEC0)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010EDEC1)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010EDEC2)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x010EDEC3)[0] = value end,
},
{_Offset = 0x00F8A7B4, _BuildString = "Build Mar 11 2023 14:05:19", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x010F80EC)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x010F80ED)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x010F80EE)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010F80EF)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x010F80F0)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010F80F1)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010F80F2)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x010F80F3)[0] = value end,
},
{_Offset = 0x00F8A8A4, _BuildString = "Build Jun 19 2023 14:14:52", -- Steam dev build.
2023-07-27 08:40:41 +00:00
mPostFxDisabled = function(value) ffi.cast("char*", 0x010F810C)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x010F810D)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x010F810E)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010F810F)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x010F8110)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010F8111)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010F8112)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x010F8113)[0] = value end,
},
2023-07-27 08:40:41 +00:00
{_Offset = 0x00F82464, _BuildString = "Build Jul 26 2023 23:06:16", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x010E9A5C)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x010E9A5D)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x010E9A5E)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010E9A5F)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x010E9A60)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010E9A61)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010E9A62)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x010E9A63)[0] = value end,
},
{_Offset = 0x00FA654C, _BuildString = "Build Dec 19 2023 18:34:31", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x011154BC)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x011154BD)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x011154BE)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x011154BF)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x011154C0)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x011154C1)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x011154C2)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x011154C3)[0] = value end,
},
{_Offset = 0x00F8A9DC, _BuildString = "Build Dec 21 2023 00:07:29", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x010F814C)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x0010F814D)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x0010F814E)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x0010F814F)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x010F8150)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010F8151)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010F8152)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x010F8153)[0] = value end,
},
},
},
2022-08-08 00:54:48 +00:00
[false] = {
Windows = {
{_Offset = 0x00E1C550, _BuildString = "Build Apr 23 2021 18:44:24", -- Steam build.
enableModDetection = function(value)
2023-07-27 08:40:41 +00:00
local ptr = ffi.cast("char*", 0x0063D8AD) -- Can be found by searching for the pattern C6 80 20 01 00 00 >01< 8B CF E8 FB 1D. The pointer has to point to the highlighted byte.
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
2022-08-08 00:54:48 +00:00
},
{_Offset = 0x00E22E18, _BuildString = "Build Mar 11 2023 14:09:24", -- Steam build.
enableModDetection = function(value)
2023-07-27 08:40:41 +00:00
local ptr = ffi.cast("char*", 0x006429ED)
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
},
{_Offset = 0x00E22E18, _BuildString = "Build Jun 19 2023 14:18:46", -- Steam build.
enableModDetection = function(value)
2023-07-27 08:40:41 +00:00
local ptr = ffi.cast("char*", 0x006429ED)
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
},
{_Offset = 0x00E146D4, _BuildString = "Build Jul 26 2023 23:10:16", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x0064390D)
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
},
{_Offset = 0x00E333F4, _BuildString = "Build Dec 19 2023 18:38:23", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x00624C5D)
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
},
{_Offset = 0x00E23EC4, _BuildString = "Build Dec 21 2023 00:11:06", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x0064246D)
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
},
2022-08-08 00:54:48 +00:00
},
},
}
-- Look up the tree and set options accordingly.
local level1 = lookup[DebugGetIsDevBuild()]
level1 = level1 or {}
local level2 = level1[ffi.os]
level2 = level2 or {}
2023-03-15 22:57:17 +00:00
local level3 = {}
for _, v in ipairs(level2) do
if ffi.string(ffi.cast("char*", v._Offset)) == v._BuildString then
level3 = v
break
end
end
for k, v in pairs(memory) do
local modFunc = level3[k]
if modFunc ~= nil then
modFunc(v)
else
Message:ShowModificationUnsupported("processMemory", k, v)
end
end
end
---Applies patches to game files based on in the given table.
---
---Should be called on mod initialization only.
---@param patches table
function Modification.PatchFiles(patches)
-- Change constants in post_final.frag.
if patches.PostFinalConst then
local postFinal = ModTextFileGetContent("data/shaders/post_final.frag")
for k, v in pairs(patches.PostFinalConst) do
2022-07-29 14:57:45 +00:00
postFinal = postFinal:gsub(string.format(" %s%%s+=[^;]+;", k), string.format(" %s = %s;", k, tostring(v)), 1)
end
ModTextFileSetContent("data/shaders/post_final.frag", postFinal)
end
end
---Returns tables with user requested game configuration changes.
---@return table config -- List of `config.xml` attributes that should be changed.
---@return table magic -- List of `magic_number.xml` attributes that should be changed.
---@return table memory -- List of options in RAM of this process that should be changed.
---@return table patches -- List of patches that should be applied to game files.
function Modification.RequiredChanges()
local config, magic, memory, patches = {}, {}, {}, {}
-- Does the user request a custom resolution?
local customResolution = (ModSettingGet("noita-mapcap.custom-resolution-live") and ModSettingGet("noita-mapcap.capture-mode") == "live")
or (ModSettingGet("noita-mapcap.custom-resolution-other") and ModSettingGet("noita-mapcap.capture-mode") ~= "live")
if customResolution then
config["window_w"] = tostring(Vec2(ModSettingGet("noita-mapcap.window-resolution")).x)
config["window_h"] = tostring(Vec2(ModSettingGet("noita-mapcap.window-resolution")).y)
config["internal_size_w"] = tostring(Vec2(ModSettingGet("noita-mapcap.internal-resolution")).x)
config["internal_size_h"] = tostring(Vec2(ModSettingGet("noita-mapcap.internal-resolution")).y)
config["backbuffer_width"] = config["window_w"]
config["backbuffer_height"] = config["window_h"]
magic["VIRTUAL_RESOLUTION_X"] = tostring(Vec2(ModSettingGet("noita-mapcap.virtual-resolution")).x)
magic["VIRTUAL_RESOLUTION_Y"] = tostring(Vec2(ModSettingGet("noita-mapcap.virtual-resolution")).y)
-- Set virtual offset to prevent/reduce not correctly drawn pixels at the window border.
magic["GRID_RENDER_BORDER"] = "3" -- This will widen the right side of the virtual rectangle. It also shifts the world coordinates to the right.
magic["VIRTUAL_RESOLUTION_OFFSET_X"] = "-3"
magic["VIRTUAL_RESOLUTION_OFFSET_Y"] = "0"
else
-- Reset some values if there is no custom resolution requested.
config["internal_size_w"] = "1280"
config["internal_size_h"] = "720"
magic["VIRTUAL_RESOLUTION_X"] = "427"
magic["VIRTUAL_RESOLUTION_Y"] = "242"
magic["GRID_RENDER_BORDER"] = "2"
magic["VIRTUAL_RESOLUTION_OFFSET_X"] = "-1"
magic["VIRTUAL_RESOLUTION_OFFSET_Y"] = "-1"
end
-- Always expect a fullscreen mode of 0 (windowed).
-- Capturing will not work in fullscreen.
config["fullscreen"] = "0"
2022-08-27 12:32:01 +00:00
-- Also disable screen shake.
2022-07-29 13:29:15 +00:00
config["screenshake_intensity"] = "0"
2022-07-28 17:42:43 +00:00
magic["DRAW_PARALLAX_BACKGROUND"] = ModSettingGet("noita-mapcap.disable-background") and "0" or "1"
-- These magic numbers seem only to work in the dev build.
2022-07-28 17:42:43 +00:00
magic["DEBUG_PAUSE_GRID_UPDATE"] = ModSettingGet("noita-mapcap.disable-physics") and "1" or "0"
magic["DEBUG_PAUSE_BOX2D"] = ModSettingGet("noita-mapcap.disable-physics") and "1" or "0"
magic["DEBUG_DISABLE_POSTFX_DITHERING"] = ModSettingGet("noita-mapcap.disable-postfx") and "1" or "0"
if ModSettingGet("noita-mapcap.disable-postfx") then
patches.PostFinalConst = {
ENABLE_REFRACTION = false,
ENABLE_LIGHTING = false,
ENABLE_FOG_OF_WAR = false,
ENABLE_GLOW = false,
ENABLE_GAMMA_CORRECTION = false,
ENABLE_PATH_DEBUG = false,
2022-07-29 14:57:45 +00:00
FOG_FOREGROUND = "vec4(0.0,0.0,0.0,1.0)",
FOG_BACKGROUND = "vec3(0.0,0.0,0.0)",
FOG_FOREGROUND_NIGHT = "vec4(0.0,0.0,0.0,1.0)",
FOG_BACKGROUND_NIGHT = "vec3(0.0,0.0,0.0)",
}
end
if ModSettingGet("noita-mapcap.disable-shaders-gui-ai") and DebugAPI.IsDevBuild() then
memory["mPostFxDisabled"] = 1
memory["mGuiDisabled"] = 1
memory["mFreezeAI"] = 1
memory["mTrailerMode"] = 1 -- Is necessary for chunks to correctly load when DEBUG_PAUSE_GRID_UPDATE is enabled.
end
if ModSettingGet("noita-mapcap.disable-mod-detection") and not DebugAPI.IsDevBuild() then
2022-08-08 00:54:48 +00:00
memory["enableModDetection"] = 0
else
-- Don't actively (re)enable mod detection.
--memory["enableModDetection"] = 1
2022-08-08 00:54:48 +00:00
end
2022-07-29 15:37:58 +00:00
-- Disables or hides most of the UI.
-- The game is still somewhat playable this way.
if ModSettingGet("noita-mapcap.disable-ui") then
magic["INVENTORY_GUI_ALWAYS_VISIBLE"] = "0"
magic["UI_BARS2_OFFSET_X"] = "100"
else
-- Reset to default.
magic["INVENTORY_GUI_ALWAYS_VISIBLE"] = "1"
magic["UI_BARS2_OFFSET_X"] = "-40"
end
return config, magic, memory, patches
end
2022-07-28 11:27:02 +00:00
---Sets the camera free if required by the mod settings.
---@param force boolean|nil -- If true, the camera will be set free regardless.
function Modification.SetCameraFree(force)
if force ~= nil then CameraAPI.SetCameraFree(force) return end
local captureMode = ModSettingGet("noita-mapcap.capture-mode")
local spiralOrigin = ModSettingGet("noita-mapcap.capture-mode-spiral-origin")
-- Allow free roaming when in spiral mode with origin being the current position.
if captureMode == "spiral" and spiralOrigin == "current" then
CameraAPI.SetCameraFree(true)
return
end
CameraAPI.SetCameraFree(false)
end
---Will change the game settings according to `Modification.RequiredChanges()`.
---
---This will force close Noita!
function Modification.AutoSet()
local config, magic = Modification.RequiredChanges()
Modification.SetConfig(config)
end
---Will reset all persistent settings that may have been changed by this mod.
---
---This will force close Noita!
function Modification.Reset()
local config = {
window_w = "1280",
window_h = "720",
internal_size_w = "1280",
internal_size_h = "720",
backbuffer_width = "1280",
backbuffer_height = "720",
2022-07-29 13:29:15 +00:00
screenshake_intensity = "0.7",
}
Modification.SetConfig(config)
end