Files
InstanceBrick--nightcap/scripts/roblox_abi_solver.py
T

403 lines
17 KiB
Python

#!/usr/bin/env python3
# Copyright 2026 Mocktail Project Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Evidence-scoring solver that finds HostAbi addresses in a new libroblox.
Each function in both builds is indexed by `roblox_function_index.py`. A
reference function is matched through independent clues:
- the same instruction shape, unique in both builds
- callers and callees that are already matched
- the strings it references
- its data references, shifted by the distance mapped for those addresses
- its place between matched neighbours
A match is accepted only with a score of at least ACCEPT_SCORE and a lead of
at least ACCEPT_LEAD over the runner-up. `roblox_abi_targets.py` turns the
matches into a profile with the same shape as the one
`derive_roblox_host_abi_profile.py` writes, `roblox_abi_vr.py` into the VR
device bridge, and `roblox_abi_check.py` compares the two on one update.
"""
from __future__ import annotations
import bisect
import collections
from dataclasses import dataclass
import difflib
import importlib.util
from pathlib import Path
import sys
from typing import Any, Callable, Sequence
def _load_sibling(name: str, file_name: str):
if name in sys.modules:
return sys.modules[name]
path = Path(__file__).resolve().with_name(file_name)
specification = importlib.util.spec_from_file_location(name, path)
if specification is None or specification.loader is None:
raise RuntimeError(f"cannot load {file_name}")
module = importlib.util.module_from_spec(specification)
sys.modules[name] = module
specification.loader.exec_module(module)
return module
INDEX = _load_sibling("mocktail_roblox_function_index", "roblox_function_index.py")
TARGETS = _load_sibling("mocktail_roblox_abi_targets", "roblox_abi_targets.py")
VR = _load_sibling("mocktail_roblox_abi_vr", "roblox_abi_vr.py")
DERIVER = INDEX.DERIVER
FunctionIndex = INDEX.FunctionIndex
Instruction = INDEX.Instruction
AnalyzerError = DERIVER.AnalyzerError
format_rva = DERIVER.format_rva
check_inputs = TARGETS.check_inputs
validate_fmod_source = TARGETS.validate_fmod_source
ACCEPT_SCORE = 0.6
ACCEPT_LEAD = 0.2
# Candidates that get the full, disassembling score after the cheap one.
FULL_SCORE_LIMIT = 64
# A data address used by more functions than this is too common to narrow
# the candidates by itself.
XREF_FANOUT_LIMIT = 64
# Scores are float sums; a lead that equals ACCEPT_LEAD on paper may be
# a few ulps short.
_TOLERANCE = 1e-9
def choose_candidate(
scored: Sequence[tuple[float, int]], tie_breaker: int | None = None
) -> int | None:
"""Returns the leading candidate, or None when no lead is clear.
`tie_breaker` picks among the candidates that clear ACCEPT_SCORE and sit
within ACCEPT_LEAD of the best; it cannot lift or overrule any other.
"""
if not scored:
return None
ranked = sorted(scored, key=lambda item: item[0], reverse=True)
best_score, best = ranked[0]
runner_up = ranked[1][0] if len(ranked) > 1 else 0.0
if best_score >= ACCEPT_SCORE and best_score - runner_up >= ACCEPT_LEAD - _TOLERANCE:
return best
tied = {
rva for value, rva in ranked
if value >= ACCEPT_SCORE and best_score - value < ACCEPT_LEAD - _TOLERANCE
}
return tie_breaker if tie_breaker in tied else None
def align_instructions(
reference_shapes: Sequence[str], candidate_shapes: Sequence[str]
) -> dict[int, int]:
"""Maps reference instruction indexes to candidate indexes of equal shape."""
matcher = difflib.SequenceMatcher(
None, reference_shapes, candidate_shapes, autojunk=False
)
mapping = {}
for block in matcher.get_matching_blocks():
for offset in range(block.size):
mapping[block.a + offset] = block.b + offset
return mapping
@dataclass(frozen=True)
class Match:
rva: int
score: float
lead: float
# "score" when the score alone decided, "vtable" when a slot broke a tie.
decided_by: str
def _share(expected: Sequence[Any], present: set[Any]) -> float | None:
if not expected:
return None
return sum(1 for item in expected if item in present) / len(expected)
def _weighted(parts: Sequence[tuple[float | None, float]]) -> float:
present = [(value, weight) for value, weight in parts if value is not None]
return sum(value * weight for value, weight in present) / sum(
weight for _, weight in present
)
class Solver(TARGETS.TargetDerivation, VR.VrTargets):
def __init__(self, reference: Any, candidate: Any, cache_dir: Path | None = None):
self._start(FunctionIndex(reference, cache_dir), FunctionIndex(candidate, cache_dir))
@classmethod
def from_indexes(cls, reference: Any, candidate: Any) -> "Solver":
solver = cls.__new__(cls)
solver._start(reference, candidate)
return solver
def _start(self, reference: Any, candidate: Any) -> None:
self.reference = reference
self.candidate = candidate
self.pairs: dict[int, int] = {}
self._owners: dict[int, int] = {}
for start, other in self._shape_anchors().items():
self._pair(start, other)
self._propagate_calls()
self.data_map = self._map_data_references()
self.matches: dict[str, Match] = {}
self._claimed: dict[int, int] = {}
def _pair(self, start: int, other: int) -> None:
"""Pairs two functions, dropping any pair that held either one."""
owner = self._owners.get(other)
if owner is not None and owner != start:
del self.pairs[owner]
previous = self.pairs.get(start)
if previous is not None and previous != other:
del self._owners[previous]
self.pairs[start] = other
self._owners[other] = start
def _shape_anchors(self) -> dict[int, int]:
reference_hashes = collections.defaultdict(list)
candidate_hashes = collections.defaultdict(list)
for start, info in self.reference.functions.items():
reference_hashes[info.shape_hash].append(start)
for start, info in self.candidate.functions.items():
candidate_hashes[info.shape_hash].append(start)
pairs = {}
for shape_hash, starts in reference_hashes.items():
others = candidate_hashes.get(shape_hash, ())
if len(starts) == 1 and len(others) == 1:
pairs[starts[0]] = others[0]
return pairs
def _propagate_calls(self) -> None:
# Matched functions with as many calls pair their callees in order.
reference, candidate = self.reference.functions, self.candidate.functions
pending = list(self.pairs.items())
while pending:
discovered = []
for reference_start, candidate_start in pending:
reference_calls = reference[reference_start].calls
candidate_calls = candidate[candidate_start].calls
if len(reference_calls) != len(candidate_calls):
continue
for callee, other in zip(reference_calls, candidate_calls):
if (
callee in self.pairs
or other in self._owners
or callee not in reference
or other not in candidate
):
continue
size = reference[callee].size
if abs(size - candidate[other].size) <= max(16, size // 4):
self._pair(callee, other)
discovered.append((callee, other))
pending = discovered
def _map_data_references(self) -> dict[int, int]:
votes: dict[int, collections.Counter] = collections.defaultdict(collections.Counter)
for reference_start, candidate_start in self.pairs.items():
reference_refs = self.reference.functions[reference_start].refs
candidate_refs = self.candidate.functions[candidate_start].refs
if len(reference_refs) == len(candidate_refs):
for reference_target, candidate_target in zip(reference_refs, candidate_refs):
votes[reference_target][candidate_target] += 1
return {target: counter.most_common(1)[0][0] for target, counter in votes.items()}
def _gap_candidates(self, start: int) -> list[int]:
starts = self.reference.starts
index = bisect.bisect_left(starts, start)
below = next(
(self.pairs[starts[i]] for i in range(index - 1, -1, -1) if starts[i] in self.pairs),
self.candidate.text.address,
)
above = next(
(self.pairs[starts[i]] for i in range(index + 1, len(starts)) if starts[i] in self.pairs),
self.candidate.text.address + self.candidate.text.size,
)
if above <= below:
return []
low = bisect.bisect_left(self.candidate.starts, below)
high = bisect.bisect_right(self.candidate.starts, above)
return self.candidate.starts[low:high]
def _neighbour_candidates(self, start: int) -> set[int]:
found = set()
for callee in self.reference.functions[start].calls:
if callee in self.pairs:
found.update(self.candidate.callers.get(self.pairs[callee], ()))
for caller in self.reference.callers.get(start, ()):
if caller in self.pairs:
found.update(
target
for target in self.candidate.functions[self.pairs[caller]].calls
if target in self.candidate.functions
)
return found
def _pattern_candidates(self, start: int) -> set[int]:
# Data a function touches usually moves as one block, so its
# references shifted by a mapped distance find it in the candidate.
refs = set(self.reference.functions[start].refs)
distances = collections.Counter(
self.data_map[target] - target for target in refs if target in self.data_map
)
# A common address adds a vote but cannot bring in a candidate alone.
votes = collections.Counter()
found_by_rare = set()
for target in refs:
users = set()
for distance, _ in distances.most_common(3):
found = self.candidate.data_users.get(target + distance, ())
users.update(found)
if len(found) <= XREF_FANOUT_LIMIT:
found_by_rare.update(found)
votes.update(users)
needed = max(1, len(refs) // 2)
return {user for user in found_by_rare if votes[user] >= needed}
def _graph_score(self, start: int, other: int) -> float:
reference = self.reference.functions[start]
candidate = self.candidate.functions[other]
size = min(reference.size, candidate.size) / max(reference.size, candidate.size)
calls = [self.pairs[c] for c in reference.calls if c in self.pairs]
callers = [self.pairs[c] for c in self.reference.callers.get(start, ()) if c in self.pairs]
data = [self.data_map[t] for t in reference.refs if t in self.data_map]
return _weighted(
(
(size, 1.0),
(_share(calls, set(candidate.calls)), 2.0),
(_share(callers, set(self.candidate.callers.get(other, ()))), 2.0),
(_share(data, set(candidate.refs)), 2.0),
)
)
def _full_score(self, start: int, other: int, graph: float) -> float:
similarity = difflib.SequenceMatcher(
None, self.reference.shapes(start), self.candidate.shapes(other), autojunk=False
).ratio()
strings = [
text
for text in map(self.reference.string_at, self.reference.functions[start].refs)
if text is not None
]
candidate_strings = set(
map(self.candidate.string_at, self.candidate.functions[other].refs)
)
return _weighted(
(
(similarity, 3.0),
(_share(strings, candidate_strings), 2.0),
(graph, 5.0),
)
)
def match_function(
self,
name: str,
start: int,
contract: Callable[[Any, int], bool] | None = None,
vtable_slot: int | None = None,
) -> int:
"""Matches one reference function.
A contract drops candidates outright. `vtable_slot` is the candidate
that sits in the matching slot of an already located vtable; it only
breaks a tie between candidates that pass on their own evidence.
"""
if start not in self.reference.functions:
raise AnalyzerError(f"reference {name} is not a function start")
if name in self.matches:
return self.matches[name].rva
candidates = set(self._gap_candidates(start))
candidates |= self._neighbour_candidates(start)
candidates |= self._pattern_candidates(start)
if start in self.pairs:
candidates.add(self.pairs[start])
if vtable_slot in self.candidate.functions:
candidates.add(vtable_slot)
size = self.reference.functions[start].size
candidates = {
other
for other in candidates
if self._claimed.get(other, start) == start
and self.candidate.functions[other].size <= 8 * size + 64
and (contract is None or contract(self.candidate.image, other))
}
graph = {other: self._graph_score(start, other) for other in candidates}
shortlist = sorted(candidates, key=graph.__getitem__, reverse=True)[:FULL_SCORE_LIMIT]
if vtable_slot in candidates and vtable_slot not in shortlist:
shortlist.append(vtable_slot)
scored = [(self._full_score(start, other, graph[other]), other) for other in shortlist]
chosen = choose_candidate(scored)
decided_by = "score"
if chosen is None and vtable_slot is not None:
chosen = choose_candidate(scored, tie_breaker=vtable_slot)
decided_by = "vtable"
if chosen is None:
ranked = sorted(scored, reverse=True)[:2]
raise AnalyzerError(
f"{name} has no clear candidate: "
+ ", ".join(f"{format_rva(rva)}={value:.2f}" for value, rva in ranked)
)
chosen_score = next(value for value, other in scored if other == chosen)
runner_up = max((value for value, other in scored if other != chosen), default=0.0)
self.matches[name] = Match(chosen, chosen_score, chosen_score - runner_up, decided_by)
self._claimed[chosen] = start
self._pair(start, chosen)
return chosen
def _aligned(self, name: str, start: int) -> tuple[list[Instruction], list[Instruction], dict[int, int]]:
other = self.match_function(name, start)
reference = self.reference.instructions(start)
candidate = self.candidate.instructions(other)
mapping = align_instructions(
[item.shape for item in reference], [item.shape for item in candidate]
)
return reference, candidate, mapping
def map_instruction(self, name: str, rva: int) -> int:
start = self.reference.containing(rva)
if start is None:
raise AnalyzerError(f"reference {name} is outside every function")
reference, candidate, mapping = self._aligned(f"{name} function", start)
index = next((i for i, item in enumerate(reference) if item.address == rva), None)
if index is None or index not in mapping:
raise AnalyzerError(f"{name} instruction has no counterpart")
return candidate[mapping[index]].address
def map_data(self, name: str, function_name: str, start: int, target: int) -> int:
reference, candidate, mapping = self._aligned(function_name, start)
found = set()
for index, item in enumerate(reference):
if target not in item.rip_targets:
continue
if index not in mapping:
raise AnalyzerError(f"{name} reference instruction has no counterpart")
other = candidate[mapping[index]]
if len(other.rip_targets) != len(item.rip_targets):
raise AnalyzerError(f"{name} RIP-relative operand count changed")
found.update(
other.rip_targets[position]
for position, value in enumerate(item.rip_targets)
if value == target
)
if len(found) != 1:
raise AnalyzerError(f"{name} has no unique mapped data address")
return found.pop()