Repository navigation
[memhack] Memory Analyzer
The Memory Analyzer is a reverse engineering tool for game structures and understanding memory patterns in Into the Breach. It allows you to capture memory snapshots, compare them across game states, and identify which bytes change under specific conditions.
- Quick API Reference
- Basic Workflow
- Creating an Analyzer
- Capturing Memory
- Comparing Captures
- Adding Value Data
- Filtering Results
- Examples
The typical reverse engineering workflow:
- Create an analyzer with estimated structure size
- Capture memory at known game states
- Compare captures to find changing bytes
- (Optional) Filter results to narrow down specific fields
- (Optional) Analyze value progressions to understand data types
This iterative process helps you map out structures field by field.
| Method | Description |
|---|---|
MemoryAnalyzer.new(id, size, options) |
Create new analyzer |
MemoryAnalyzer.get(id) |
Get existing analyzer |
MemoryAnalyzer.remove(id) |
Remove analyzer |
MemoryAnalyzer.list() |
List all analyzer IDs |
analyzer:enable() |
Enable analyzer |
analyzer:disable() |
Disable analyzer |
analyzer:clear() |
Clear all captures and results |
| Method | Description |
|---|---|
analyzer:capture(baseAddress) |
Capture memory snapshot |
analyzer:getCaptureCount() |
Get number of captures |
analyzer:getCapture(index) |
Get specific capture |
analyzer:removeCapture(index) |
Remove specific capture |
analyzer:listCaptures() |
List all captures |
| Method | Description |
|---|---|
analyzer:getChangedOnce(indices, resultId) |
Find bytes that changed at least once |
analyzer:getChangedEvery(indices, resultId) |
Find bytes that changed every time |
analyzer:getUnchanged(indices, resultId) |
Find bytes that never changed |
analyzer:getCustomChanges(indices, func, resultId) |
Find bytes matching custom criteria |
analyzer:getMatchingPattern(pattern, indices, resultId) |
Find bytes whose values match a pattern across captures |
| Method | Description |
|---|---|
analyzer:addAllCapturesValues(result, ranges) |
Add all value progressions |
analyzer:addChangedCapturesValues(result, ranges) |
Add only changed values |
analyzer:addUniqueCapturesValues(result, ranges) |
Add unique value counts |
| Method | Description |
|---|---|
analyzer:getResult(resultId) |
Get stored result |
analyzer:removeResult(resultId) |
Remove stored result |
analyzer:listResults() |
List all stored result IDs |
analyzer:log(result) |
Log result to console |
analyzer:filterResult(result, filterSpec, resultId) |
Filter result |
{
analyzerId = "test",
alignment = 4,
captureIndices = {1, 2, 3},
filtered = { -- Ranges that matched comparison
{
startOffset = 0x00,
endOffset = 0x03,
size = 4,
data = { -- Only present after add*Values() calls
values = {{100, 80, 60}, ...}, -- Values at each alignment chunk
uniqueCount = 3,
uniqueValues = {100, 80, 60}
}
}
},
unfiltered = { -- Ranges that didn't match
-- Same structure as filtered
}
}Analyzer can be created in the code or via console. Depends on desired use as to which is better or would be easier to use.
local analyzer = memhack.debug.MemoryAnalyzer.new(id, size, options)| Parameter | Type | Description |
|---|---|---|
id |
string | Unique identifier for this analyzer |
size |
number | Size in bytes to capture (must be divisible by alignment) |
options |
table | Configuration options (see below) |
Options table:
| Field | Type | Default | Description |
|---|---|---|---|
baseAddress |
number | nil | Starting address to capture from |
pointerChain |
table | {} | Array of offsets to follow before capturing |
alignment |
number | 4 | Byte alignment for comparisons (1, 2, 4, or 8) |
enabled |
boolean | true | Whether analyzer is active on creation |
rateLimit |
number | 0 | Minimum seconds between captures (0 = unlimited) |
Example:
-- Simple analyzer for direct address
local analyzer = memhack.debug.MemoryAnalyzer.new("pawn", 128, {
baseAddress = pawnAddress,
alignment = 4
})
-- Analyzer without local var (i.e. in console)
-- Reference will be stored in Memory Analyzer for later use
memhack.debug.MemoryAnalyzer.new("pilot", 256)-- Retrieve existing analyzer
local analyzer = memhack.debug.MemoryAnalyzer.get("pawn")
-- List all analyzer IDs
local ids = memhack.debug.MemoryAnalyzer.list() -- Returns: {"pawn", "pilot", ...}
-- Remove analyzer
memhack.debug.MemoryAnalyzer.remove("pawn")
-- Enable/disable analyzer
memhack.debug.MemoryAnalyzer.get("pawn"):disable() -- Prevents captures
analyzer:enable() -- Re-enables capturesOnce you have your analyzer, you can capture the current state for later comparison
-- Capture at configured base address
analyzer:capture()
-- Capture at different address
analyzer:capture(differentAddress)
-- Rate limiting (if configured)
analyzer.rateLimit = 1 -- Max 1 capture per second
analyzer:capture() -- Works, returns non nil
analyzer:capture() -- Logs "Rate limit exceeded" and returns nil-- Get capture count
local count = analyzer:getCaptureCount()
-- Get specific capture
local capture = analyzer:getCapture(1)
-- List all captures
local list = analyzer:listCaptures()
-- Remove specific capture
analyzer:removeCapture(1)
-- Clear all captures and results
analyzer:clear()After you get your captures, you can compare them to search for differences. There are 3 basic comparisons and an additional API that takes a comparison function.
Finds bytes that changed at least once between any two captures.
local result = analyzer:getChangedOnce(captureIndices, resultId)| Parameter | Type | Description |
|---|---|---|
captureIndices |
table/number/nil | Which captures to compare (see below) |
resultId |
string (optional) | ID to store result for later retrieval |
Capture indices options:
-
nil- Compare all captures -
3(number) - Compare last 3 captures -
{1, 3, 5}(table) - Compare specific captures by index
Returns: Result object with:
-
filtered- Ranges that changed at least once -
unfiltered- Ranges that never changed
Example:
-- Compare all captures
local result = analyzer:getChangedOnce()
-- Compare last 3 captures and store result
local result = analyzer:getChangedOnce(3, "health_changes")
-- Compare specific captures
local result = analyzer:getChangedOnce({1, 5, 10})Finds bytes that changed in every capture. If it is unchanged between any capture, this will not include it. If you are not sure, its safer to use getChangedOnce.
local result = analyzer:getChangedEvery(captureIndices, resultId)Useful for finding counters or values that increment/decrement constantly.
Example:
local result = analyzer:getChangedEvery()Finds bytes that never changed across all captures.
local result = analyzer:getUnchanged(captureIndices, resultId)Returns: Result object with:
-
filtered- Ranges that never changed -
unfiltered- Ranges that changed at least once
Useful for finding constants, type IDs, or static configuration.
Example:
-- Find constant fields
local result = analyzer:getUnchanged()Compare captures with custom logic.
local result = analyzer:getCustomChanges(captureIndices, comparatorFunc, resultId)| Parameter | Type | Description |
|---|---|---|
comparatorFunc |
function | function(selectedCaptures, byteIdx) -> bool |
The comparator receives all selected captures and a byte index, and returns true if that byte index should be included in the filtered results.
Example:
-- Find bytes that are always zero
local result = analyzer:getCustomChanges(nil, function(captures, byteIdx)
for _, cap in ipairs(captures) do
if cap.data[byteIdx] ~= 0 then
return false
end
end
return true
end)-
result|nilanalyzer:getMatchingPattern(pattern, captureIndices, resultId)
| Argument name | Type | Description |
|---|---|---|
pattern |
table/string | Pattern describing expected value progression across captures |
captureIndices |
table/nil | Optional subset of capture indices to consider |
resultId |
string/nil | If set, stores the result under this id on the analyzer |
Finds alignment sized values whose progression across captures matches pattern. Returns a result object (filtered / unfiltered) and optionally stores it as resultId.
Example:
local analyzer = memhack.debug.MemoryAnalyzer.get("pilot_xp")
-- 3 captures: exact 0, then any value named $xp, then $xp again (must match)
local result = analyzer:getMatchingPattern({0, "$xp", "$xp"}, nil, "xp_pattern")
if result then
analyzer:log("xp_pattern")
endResults are stored in the Memory Analyzer Object and can be retrieved later from it like Memory Analyzers.
-- Store result with ID
local result = analyzer:getChangedOnce(nil, "damage_test")
-- Later, add data using ID
analyzer:addChangedCapturesValues("damage_test")
analyzer:log("damage_test")
-- Manage stored results
local resultIds = analyzer:listResults() -- Returns: ["damage_test", ...]
local result = analyzer:getResult("damage_test")
analyzer:removeResult("damage_test")After comparison, add value data to see how bytes changed, not just which bytes changed. The last one called/generated are stored in the results.
Shows the value at each byte for every capture regardless of if it changed or not.
result = analyzer:addAllCapturesValues(result, ranges)| Parameter | Type | Default | Description |
|---|---|---|---|
result |
table/string | required | Result object or stored result ID |
ranges |
string | "filtered" | Which ranges to add values to: "filtered", "unfiltered", or "both" |
Example:
local result = analyzer:getChangedOnce()
result = analyzer:addAllCapturesValues(result)
analyzer:log(result)Shows value progression - i.e. only show value changes
result = analyzer:addChangedCapturesValues(result, ranges)Useful for reducing noise when many captures have the same value or when doing alot of captures.
Shows unique value counts for each byte range. Does not preserve order of changes as it counts and displays only unique values.
result = analyzer:addUniqueCapturesValues(result, ranges)Narrow down results by filtering ranges or specific byte patterns. This will store the filtered results in the Memory Analyzer if the resultId is passed.
Filter using a custom filter function
local filtered = analyzer:filterResult(result, filterFunc, resultId)Filter function signature: function(range, captures) -> bool
Example:
-- Keep only ranges larger than 4 bytes
local filtered = analyzer:filterResult(result, function(range, captures)
return range.size > 4
end)Filter by byte values using a custom filter function. This passes each range in alignment size chunks.
local filtered = analyzer:filterResult(result, {
alignment = 4, -- Check 4-byte chunks
filter = function(offset, values) -> bool,
ranges = "filtered" -- Which ranges to check
}, resultId)| Parameter | Type | Description |
|---|---|---|
alignment |
number | Size of chunks to check (1, 2, 4, or 8 bytes) |
filter |
function |
function(offset, values) -> bool where values is array of values at that offset across all captures |
ranges |
string | "filtered" or "unfiltered" |
Example:
-- Keep only chunks where values are between 0 and 100
local filtered = analyzer:filterResult(result, {
alignment = 4,
filter = function(offset, values)
for _, v in ipairs(values) do
if v < 0 or v > 100 then
return false
end
end
return true
end
})memhack.debug.MemoryAnalyzer.new("pilot_xp", 0xff)
-- Capture with pilot
memhack.debug.MemoryAnalyzer.get("pilot_xp"):capture(Game:GetSquadPilots()[1])
-- ... Gain xp ...
-- Recapture
memhack.debug.MemoryAnalyzer.get("pilot_xp"):capture(Game:GetSquadPilots()[1])
-- Find what changed
memhack.debug.MemoryAnalyzer.get("pilot_xp"):getChangedEvery("xp_changes")
memhack.debug.MemoryAnalyzer.get("pilot_xp"):addChangedCapturesValues("xp_changes")
-- Look for a byte that matches the xps we captured with
memhack.debug.MemoryAnalyzer.get("pilot_xp"):log("xp_changes")