prrevers 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prrevers/__init__.py +118 -0
- prrevers/adapters/__init__.py +302 -0
- prrevers/adapters/custom.py +177 -0
- prrevers/adapters/gamemaker.py +57 -0
- prrevers/adapters/godot.py +46 -0
- prrevers/adapters/unity.py +172 -0
- prrevers/adapters/unreal.py +44 -0
- prrevers/adapters/web.py +123 -0
- prrevers/analyzers/__init__.py +92 -0
- prrevers/analyzers/citations.py +150 -0
- prrevers/analyzers/llm.py +262 -0
- prrevers/analyzers/prompts/__init__.py +122 -0
- prrevers/analyzers/prompts/analyze_system_v1.txt +29 -0
- prrevers/analyzers/prompts/analyze_system_v2.txt +29 -0
- prrevers/analyzers/prompts/analyze_user_v1.txt +13 -0
- prrevers/analyzers/prompts/analyze_user_v2.txt +18 -0
- prrevers/analyzers/prompts/trace_system_v1.txt +35 -0
- prrevers/analyzers/prompts/trace_user_v1.txt +19 -0
- prrevers/cache.py +191 -0
- prrevers/citation.py +174 -0
- prrevers/cli.py +979 -0
- prrevers/compare.py +334 -0
- prrevers/constants.py +200 -0
- prrevers/detectors/__init__.py +74 -0
- prrevers/detectors/container.py +294 -0
- prrevers/detectors/engine.py +44 -0
- prrevers/detectors/platform.py +49 -0
- prrevers/dynamic/__init__.py +79 -0
- prrevers/dynamic/doc.py +261 -0
- prrevers/dynamic/hooks.py +511 -0
- prrevers/dynamic/runner.py +335 -0
- prrevers/dynamic/trace.py +238 -0
- prrevers/errors.py +147 -0
- prrevers/extractors/__init__.py +155 -0
- prrevers/extractors/apk.py +57 -0
- prrevers/extractors/asar.py +463 -0
- prrevers/extractors/assets.py +389 -0
- prrevers/extractors/base.py +168 -0
- prrevers/extractors/ghidra.py +363 -0
- prrevers/extractors/il2cpp.py +180 -0
- prrevers/extractors/ilspy.py +103 -0
- prrevers/extractors/mono.py +111 -0
- prrevers/extractors/unreal.py +365 -0
- prrevers/extractors/zip.py +249 -0
- prrevers/ghidra_scripts/PrReversDecompileExport.py +185 -0
- prrevers/llm.py +1146 -0
- prrevers/mcp_server.py +107 -0
- prrevers/mcp_tools.py +397 -0
- prrevers/models.py +321 -0
- prrevers/oracles/__init__.py +70 -0
- prrevers/oracles/asset.py +230 -0
- prrevers/oracles/base.py +93 -0
- prrevers/oracles/binfmt.py +174 -0
- prrevers/oracles/bytematch.py +182 -0
- prrevers/oracles/citation.py +179 -0
- prrevers/oracles/comparison.py +291 -0
- prrevers/oracles/cs_check.py +71 -0
- prrevers/oracles/detection.py +109 -0
- prrevers/oracles/extraction.py +101 -0
- prrevers/oracles/level.py +439 -0
- prrevers/oracles/manifest.py +133 -0
- prrevers/oracles/roundtrip.py +313 -0
- prrevers/oracles/trace.py +187 -0
- prrevers/oracles/tscn.py +146 -0
- prrevers/pipeline.py +778 -0
- prrevers/repl.py +271 -0
- prrevers/session.py +243 -0
- prrevers/synthesizers/__init__.py +78 -0
- prrevers/synthesizers/base.py +64 -0
- prrevers/synthesizers/csharp.py +231 -0
- prrevers/synthesizers/design_doc.py +84 -0
- prrevers/synthesizers/gdscript.py +200 -0
- prrevers/synthesizers/levels.py +792 -0
- prrevers/synthesizers/shaders.py +355 -0
- prrevers/synthesizers/textutil.py +28 -0
- prrevers/tools.py +423 -0
- prrevers/utils/__init__.py +8 -0
- prrevers/utils/logging.py +19 -0
- prrevers/utils/paths.py +55 -0
- prrevers-1.1.0.dist-info/METADATA +220 -0
- prrevers-1.1.0.dist-info/RECORD +84 -0
- prrevers-1.1.0.dist-info/WHEEL +4 -0
- prrevers-1.1.0.dist-info/entry_points.txt +10 -0
- prrevers-1.1.0.dist-info/licenses/LICENSE +21 -0
prrevers/__init__.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""PRRevers — AI-ассистированный тулз реверс-инжиниринга для гейм-девов.
|
|
2
|
+
|
|
3
|
+
Top-level пакет. Большинство пользователей взаимодействует с проектом
|
|
4
|
+
через CLI (``prrevers``) или MCP-сервер. Публичный API поверхность
|
|
5
|
+
намеренно мала на текущем этапе проекта; полный список — в
|
|
6
|
+
docs/architecture.ru.md § «Публичный API».
|
|
7
|
+
|
|
8
|
+
Экспорт публичных имён ленивый (PEP 562 ``__getattr__``): ``import prrevers``
|
|
9
|
+
не тянет rich/pydantic, поэтому ``prrevers version`` и импорт метаданных
|
|
10
|
+
остаются дешёвыми. Первое обращение к ``prrevers.detect`` и др. подгружает
|
|
11
|
+
соответствующий подмодуль.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING: # pragma: no cover — только для тайпчекера
|
|
19
|
+
# Имена объявлены для mypy и перечислены в ``__all__``; в рантайме их
|
|
20
|
+
# поставляет ``__getattr__`` ниже (PEP 562).
|
|
21
|
+
from prrevers.cache import Cache
|
|
22
|
+
from prrevers.citation import Citation
|
|
23
|
+
from prrevers.compare import ComparisonOutcome
|
|
24
|
+
from prrevers.detectors import detect
|
|
25
|
+
from prrevers.extractors import extract
|
|
26
|
+
from prrevers.llm import LLMBackend
|
|
27
|
+
from prrevers.models import (
|
|
28
|
+
AnalysisRequest,
|
|
29
|
+
AnalysisResult,
|
|
30
|
+
ComparisonDimension,
|
|
31
|
+
ComparisonResult,
|
|
32
|
+
DesignDocDiff,
|
|
33
|
+
DetectionResult,
|
|
34
|
+
Engine,
|
|
35
|
+
ExtractionResult,
|
|
36
|
+
GameArtifact,
|
|
37
|
+
GameProfile,
|
|
38
|
+
Platform,
|
|
39
|
+
Verdict,
|
|
40
|
+
)
|
|
41
|
+
from prrevers.pipeline import (
|
|
42
|
+
PipelineConfig,
|
|
43
|
+
PipelineResult,
|
|
44
|
+
run_pipeline,
|
|
45
|
+
)
|
|
46
|
+
from prrevers.session import Session
|
|
47
|
+
|
|
48
|
+
__version__ = "1.1.0"
|
|
49
|
+
|
|
50
|
+
#: Модуль и атрибут для каждого лениво экспортируемого публичного имени.
|
|
51
|
+
_LAZY_EXPORTS: dict[str, tuple[str, str]] = {
|
|
52
|
+
"detect": ("prrevers.detectors", "detect"),
|
|
53
|
+
"extract": ("prrevers.extractors", "extract"),
|
|
54
|
+
"run_pipeline": ("prrevers.pipeline", "run_pipeline"),
|
|
55
|
+
"PipelineConfig": ("prrevers.pipeline", "PipelineConfig"),
|
|
56
|
+
"PipelineResult": ("prrevers.pipeline", "PipelineResult"),
|
|
57
|
+
"Cache": ("prrevers.cache", "Cache"),
|
|
58
|
+
"Citation": ("prrevers.citation", "Citation"),
|
|
59
|
+
"LLMBackend": ("prrevers.llm", "LLMBackend"),
|
|
60
|
+
"Session": ("prrevers.session", "Session"),
|
|
61
|
+
"GameArtifact": ("prrevers.models", "GameArtifact"),
|
|
62
|
+
"AnalysisRequest": ("prrevers.models", "AnalysisRequest"),
|
|
63
|
+
"AnalysisResult": ("prrevers.models", "AnalysisResult"),
|
|
64
|
+
"DetectionResult": ("prrevers.models", "DetectionResult"),
|
|
65
|
+
"ExtractionResult": ("prrevers.models", "ExtractionResult"),
|
|
66
|
+
"Verdict": ("prrevers.models", "Verdict"),
|
|
67
|
+
"Platform": ("prrevers.models", "Platform"),
|
|
68
|
+
"Engine": ("prrevers.models", "Engine"),
|
|
69
|
+
"GameProfile": ("prrevers.models", "GameProfile"),
|
|
70
|
+
"ComparisonResult": ("prrevers.models", "ComparisonResult"),
|
|
71
|
+
"ComparisonDimension": ("prrevers.models", "ComparisonDimension"),
|
|
72
|
+
"DesignDocDiff": ("prrevers.models", "DesignDocDiff"),
|
|
73
|
+
"run_compare": ("prrevers.compare", "run_compare"),
|
|
74
|
+
"ComparisonOutcome": ("prrevers.compare", "ComparisonOutcome"),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"__version__",
|
|
79
|
+
"AnalysisRequest",
|
|
80
|
+
"AnalysisResult",
|
|
81
|
+
"Cache",
|
|
82
|
+
"Citation",
|
|
83
|
+
"ComparisonDimension",
|
|
84
|
+
"ComparisonOutcome",
|
|
85
|
+
"ComparisonResult",
|
|
86
|
+
"DetectionResult",
|
|
87
|
+
"DesignDocDiff",
|
|
88
|
+
"Engine",
|
|
89
|
+
"ExtractionResult",
|
|
90
|
+
"GameArtifact",
|
|
91
|
+
"GameProfile",
|
|
92
|
+
"LLMBackend",
|
|
93
|
+
"PipelineConfig",
|
|
94
|
+
"PipelineResult",
|
|
95
|
+
"Platform",
|
|
96
|
+
"Session",
|
|
97
|
+
"run_compare",
|
|
98
|
+
"Verdict",
|
|
99
|
+
"detect",
|
|
100
|
+
"extract",
|
|
101
|
+
"run_pipeline",
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def __getattr__(name: str) -> Any:
|
|
106
|
+
"""Лениво импортировать и вернуть публичное имя (PEP 562)."""
|
|
107
|
+
try:
|
|
108
|
+
module_name, attr = _LAZY_EXPORTS[name]
|
|
109
|
+
except KeyError as exc:
|
|
110
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
|
|
111
|
+
import importlib
|
|
112
|
+
|
|
113
|
+
module = importlib.import_module(module_name)
|
|
114
|
+
return getattr(module, attr)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def __dir__() -> list[str]:
|
|
118
|
+
return sorted(__all__)
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Адаптеры движков: плагинная per-engine логика экстракции.
|
|
2
|
+
|
|
3
|
+
В v0.0.1 ``detect_engine`` был гигантской цепочкой ``if/elif`` по движкам
|
|
4
|
+
(Unity Mono / Unity IL2CPP / Unreal / Godot / GameMaker). Добавление нового
|
|
5
|
+
движка означало правку ядра. Этот модуль заменяет этот паттерн плагинной
|
|
6
|
+
архитектурой.
|
|
7
|
+
|
|
8
|
+
Каждый адаптер — небольшой класс, который знает всё про ОДИН движок:
|
|
9
|
+
|
|
10
|
+
- ``detect(source, container)`` — похож ли файл на наш движок?
|
|
11
|
+
- ``extract_code(artifact, work_dir)`` — вытащить код (делегирует во
|
|
12
|
+
внешние тулзы через :class:`~prrevers.extractors.Extractor`).
|
|
13
|
+
- ``extract_assets(artifact, work_dir)`` — вытащить ассеты.
|
|
14
|
+
- ``recognize_mechanics(artifact)`` — вернуть список вероятных точек
|
|
15
|
+
входа в механики (имена классов, символы), на которых анализатору
|
|
16
|
+
сфокусироваться. Встроенные адаптеры его НЕ переопределяют: хардкод
|
|
17
|
+
«типичных имён классов» в реальных (часто обфусцированных) играх —
|
|
18
|
+
шум, а не сигнал; пусть анализатор исследует сам.
|
|
19
|
+
|
|
20
|
+
Дискавери
|
|
21
|
+
---------
|
|
22
|
+
Адаптеры регистрируются через entry-point группу ``prrevers.adapters``
|
|
23
|
+
в ``pyproject.toml``. Функция :func:`registry` лениво загружает их
|
|
24
|
+
при первом обращении. Внешние пакеты могут добавлять новые движки без
|
|
25
|
+
правки исходников PRRevers.
|
|
26
|
+
|
|
27
|
+
Это тот же паттерн, что в ``pytest`` (plugins), ``mkdocs`` (themes)
|
|
28
|
+
и ``sphinx`` (extensions). Стандартный способ сделать Python-проект
|
|
29
|
+
расширяемым без форка.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from abc import ABC, abstractmethod
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from importlib.metadata import entry_points
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from prrevers.models import (
|
|
41
|
+
DetectionConfidence,
|
|
42
|
+
DetectionResult,
|
|
43
|
+
Engine,
|
|
44
|
+
ExtractionResult,
|
|
45
|
+
GameArtifact,
|
|
46
|
+
Platform,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"AdapterRegistry",
|
|
51
|
+
"DetectionHint",
|
|
52
|
+
"EngineAdapter",
|
|
53
|
+
"registry",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class DetectionHint:
|
|
59
|
+
"""Один кусок evidence, который адаптер использует, чтобы распознать свой движок.
|
|
60
|
+
|
|
61
|
+
Адаптер возвращает список подсказок; ``detect_engine`` мержит их в
|
|
62
|
+
единый :class:`DetectionResult`. Подсказки человекочитаемы, чтобы
|
|
63
|
+
пользователь видел, *почему* движок был идентифицирован.
|
|
64
|
+
|
|
65
|
+
Поле ``engine`` позволяет одному адаптеру (напр. Unity) репортить
|
|
66
|
+
разные sub-движки (Mono vs IL2CPP) в зависимости от того, что найдено.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
confidence: DetectionConfidence
|
|
70
|
+
message: str
|
|
71
|
+
engine: Engine = Engine.UNKNOWN
|
|
72
|
+
"""Движок для репорта. По умолчанию ``UNKNOWN`` — вызывающий подставит
|
|
73
|
+
поле ``engine`` самого адаптера. Переопределите, когда один адаптер
|
|
74
|
+
обрабатывает несколько sub-движков (напр. Unity IL2CPP vs Mono)."""
|
|
75
|
+
|
|
76
|
+
extra: dict[str, Any] | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class EngineAdapter(ABC):
|
|
80
|
+
"""Плагинная per-engine логика экстракции.
|
|
81
|
+
|
|
82
|
+
Один адаптер на движок. Подклассы устанавливают ``engine`` (значение
|
|
83
|
+
enum, который они обрабатывают) и реализуют ``detect`` + опциональные
|
|
84
|
+
методы экстракции.
|
|
85
|
+
|
|
86
|
+
Базовый класс намеренно минимален. Методы экстракции по умолчанию
|
|
87
|
+
бросают ``NotImplementedError``, потому что не каждый адаптер
|
|
88
|
+
поддерживает каждую операцию (напр. stub-адаптер для нового движка
|
|
89
|
+
может детектить, но ещё не экстрактить).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
engine: Engine = Engine.UNKNOWN
|
|
93
|
+
"""Движок, который этот адаптер обрабатывает. Устанавливается на подклассе."""
|
|
94
|
+
|
|
95
|
+
display_name: str = ""
|
|
96
|
+
"""Человекочитаемое отображаемое имя; fallback на значение enum."""
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def detect(
|
|
100
|
+
self,
|
|
101
|
+
source: Path,
|
|
102
|
+
container: DetectionResult,
|
|
103
|
+
platform: Platform,
|
|
104
|
+
) -> DetectionHint | None:
|
|
105
|
+
"""Инспектировать ``source`` и вернуть подсказку, если это наш движок.
|
|
106
|
+
|
|
107
|
+
Возвращает ``None``, когда этот адаптер уверен, что файл — НЕ его
|
|
108
|
+
движок. Возвращает :class:`DetectionHint` (с HIGH для совпадения
|
|
109
|
+
magic-bytes, MEDIUM для эвристик, LOW для догадок), когда это
|
|
110
|
+
может быть он.
|
|
111
|
+
"""
|
|
112
|
+
raise NotImplementedError
|
|
113
|
+
|
|
114
|
+
def extract_code(
|
|
115
|
+
self, artifact: GameArtifact, work_dir: Path
|
|
116
|
+
) -> ExtractionResult: # pragma: no cover — abstract-ish
|
|
117
|
+
raise NotImplementedError(
|
|
118
|
+
f"адаптер для {self.engine} ещё не реализует extract_code",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def extract_assets(
|
|
122
|
+
self, artifact: GameArtifact, work_dir: Path
|
|
123
|
+
) -> ExtractionResult: # pragma: no cover — abstract-ish
|
|
124
|
+
raise NotImplementedError(
|
|
125
|
+
f"адаптер для {self.engine} ещё не реализует extract_assets",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def recognize_mechanics(self, artifact: GameArtifact) -> list[str]:
|
|
129
|
+
"""Вернуть вероятные имена точек входа (классы, символы) для анализа.
|
|
130
|
+
|
|
131
|
+
Анализатор использует их как стартовый scope. Возврат пустого
|
|
132
|
+
списка (по умолчанию) означает «нет шорткатов — пусть LLM
|
|
133
|
+
исследует свободно».
|
|
134
|
+
"""
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class AdapterRegistry:
|
|
139
|
+
"""Реестр плагинов для :class:`EngineAdapter` инстансов.
|
|
140
|
+
|
|
141
|
+
Загружает адаптеры из entry-point группы ``prrevers.adapters`` при
|
|
142
|
+
первом обращении. Адаптеры также можно регистрировать вручную
|
|
143
|
+
(для тестов).
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
ENTRY_POINT_GROUP = "prrevers.adapters"
|
|
147
|
+
|
|
148
|
+
def __init__(self) -> None:
|
|
149
|
+
self._adapters: dict[Engine, EngineAdapter] = {}
|
|
150
|
+
self._loaded = False
|
|
151
|
+
|
|
152
|
+
def register(self, adapter: EngineAdapter) -> None:
|
|
153
|
+
"""Зарегистрировать адаптер вручную (минуя entry points)."""
|
|
154
|
+
if adapter.engine == Engine.UNKNOWN:
|
|
155
|
+
raise ValueError("адаптер обязан установить non-UNKNOWN engine")
|
|
156
|
+
self._adapters[adapter.engine] = adapter
|
|
157
|
+
|
|
158
|
+
def get(self, engine: Engine) -> EngineAdapter | None:
|
|
159
|
+
"""Вернуть адаптер для ``engine`` или ``None``, если не зарегистрирован."""
|
|
160
|
+
self._ensure_loaded()
|
|
161
|
+
return self._adapters.get(engine)
|
|
162
|
+
|
|
163
|
+
def all(self) -> list[EngineAdapter]:
|
|
164
|
+
"""Вернуть все зарегистрированные адаптеры."""
|
|
165
|
+
self._ensure_loaded()
|
|
166
|
+
return list(self._adapters.values())
|
|
167
|
+
|
|
168
|
+
def detect_engine(
|
|
169
|
+
self,
|
|
170
|
+
source: Path,
|
|
171
|
+
container: DetectionResult,
|
|
172
|
+
platform: Platform,
|
|
173
|
+
) -> DetectionResult:
|
|
174
|
+
"""Запустить ``detect`` каждого адаптера и выбрать сильнейшую подсказку.
|
|
175
|
+
|
|
176
|
+
Evidence сохраняет ВЕСЬ трейл, не только победителя: сообщение
|
|
177
|
+
победителя идёт первым, за ним — строки ``[<engine>] <msg>``
|
|
178
|
+
отклонённых подсказок. Так пользователь видит, почему победил
|
|
179
|
+
именно этот движок и что говорили конкуренты (те самые
|
|
180
|
+
«почему не Unreal» — без этого трейла провал детекции
|
|
181
|
+
необъясним).
|
|
182
|
+
"""
|
|
183
|
+
self._ensure_loaded()
|
|
184
|
+
best_hint: DetectionHint | None = None
|
|
185
|
+
best_engine = Engine.UNKNOWN
|
|
186
|
+
best_adapter_engine = Engine.UNKNOWN
|
|
187
|
+
rejected: list[tuple[Engine, DetectionHint]] = []
|
|
188
|
+
for engine, adapter in self._adapters.items():
|
|
189
|
+
hint = adapter.detect(source, container, platform)
|
|
190
|
+
if hint is None:
|
|
191
|
+
continue
|
|
192
|
+
if best_hint is None or _stronger(hint.confidence, best_hint.confidence):
|
|
193
|
+
if best_hint is not None:
|
|
194
|
+
rejected.append((best_adapter_engine, best_hint))
|
|
195
|
+
best_hint = hint
|
|
196
|
+
best_adapter_engine = engine
|
|
197
|
+
best_engine = hint.engine if hint.engine != Engine.UNKNOWN else engine
|
|
198
|
+
else:
|
|
199
|
+
rejected.append((engine, hint))
|
|
200
|
+
|
|
201
|
+
if best_hint is None:
|
|
202
|
+
return DetectionResult(
|
|
203
|
+
kind=container.kind,
|
|
204
|
+
engine=Engine.UNKNOWN,
|
|
205
|
+
confidence=DetectionConfidence.NONE,
|
|
206
|
+
evidence=["ни один адаптер не распознал этот движок"],
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
evidence = [best_hint.message]
|
|
210
|
+
for engine, hint in rejected:
|
|
211
|
+
evidence.append(
|
|
212
|
+
f"отклонено [{engine.value}, {hint.confidence.value}]: {hint.message}",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
extras: dict[str, Any] = dict(best_hint.extra or {})
|
|
216
|
+
extras["adapter_engine"] = best_adapter_engine.value
|
|
217
|
+
return DetectionResult(
|
|
218
|
+
kind=container.kind,
|
|
219
|
+
engine=best_engine,
|
|
220
|
+
confidence=best_hint.confidence,
|
|
221
|
+
evidence=evidence,
|
|
222
|
+
extra=extras,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def _ensure_loaded(self) -> None:
|
|
226
|
+
if self._loaded:
|
|
227
|
+
return
|
|
228
|
+
self._loaded = True
|
|
229
|
+
# Загружаем встроенные адаптеры явно. Entry points пробуем
|
|
230
|
+
# потом, чтобы сторонние пакеты могли переопределять встроенные.
|
|
231
|
+
_load_builtin_adapters(self)
|
|
232
|
+
_load_entry_point_adapters(self)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
_CONFIDENCE_ORDER = {
|
|
236
|
+
DetectionConfidence.NONE: 0,
|
|
237
|
+
DetectionConfidence.LOW: 1,
|
|
238
|
+
DetectionConfidence.MEDIUM: 2,
|
|
239
|
+
DetectionConfidence.HIGH: 3,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _stronger(a: DetectionConfidence, b: DetectionConfidence) -> bool:
|
|
244
|
+
return _CONFIDENCE_ORDER[a] > _CONFIDENCE_ORDER[b]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _load_builtin_adapters(reg: AdapterRegistry) -> None:
|
|
248
|
+
"""Регистрируем адаптеры, поставляемые с PRRevers."""
|
|
249
|
+
from prrevers.adapters.custom import CustomEngineAdapter
|
|
250
|
+
from prrevers.adapters.gamemaker import GameMakerAdapter
|
|
251
|
+
from prrevers.adapters.godot import GodotAdapter
|
|
252
|
+
from prrevers.adapters.unity import UnityAdapter
|
|
253
|
+
from prrevers.adapters.unreal import UnrealAdapter
|
|
254
|
+
from prrevers.adapters.web import WebAdapter
|
|
255
|
+
|
|
256
|
+
for adapter_cls in (
|
|
257
|
+
UnityAdapter,
|
|
258
|
+
UnrealAdapter,
|
|
259
|
+
GodotAdapter,
|
|
260
|
+
GameMakerAdapter,
|
|
261
|
+
WebAdapter,
|
|
262
|
+
CustomEngineAdapter,
|
|
263
|
+
):
|
|
264
|
+
instance = adapter_cls()
|
|
265
|
+
if instance.engine not in reg._adapters:
|
|
266
|
+
reg.register(instance)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _load_entry_point_adapters(reg: AdapterRegistry) -> None:
|
|
270
|
+
"""Загружаем сторонние адаптеры, зарегистрированные через entry points.
|
|
271
|
+
|
|
272
|
+
На Python ≥ 3.10 ``entry_points(group=...)`` — современный API. На
|
|
273
|
+
более старых версиях ``entry_points()`` возвращал dict-like объект.
|
|
274
|
+
Мы ветвимся на TypeError, чтобы обработать оба, и трактуем legacy-ветку
|
|
275
|
+
как untyped — mypy видит только современную сигнатуру и путается
|
|
276
|
+
в legacy-fallback.
|
|
277
|
+
"""
|
|
278
|
+
try:
|
|
279
|
+
eps = entry_points(group=AdapterRegistry.ENTRY_POINT_GROUP)
|
|
280
|
+
except TypeError: # pragma: no cover — py < 3.10 only
|
|
281
|
+
legacy: Any = entry_points()
|
|
282
|
+
eps = legacy.get(AdapterRegistry.ENTRY_POINT_GROUP, [])
|
|
283
|
+
for ep in eps:
|
|
284
|
+
try:
|
|
285
|
+
adapter_cls = ep.load()
|
|
286
|
+
except Exception:
|
|
287
|
+
continue
|
|
288
|
+
if not isinstance(adapter_cls, type) or not issubclass(adapter_cls, EngineAdapter):
|
|
289
|
+
continue
|
|
290
|
+
instance = adapter_cls()
|
|
291
|
+
if instance.engine not in reg._adapters:
|
|
292
|
+
reg.register(instance)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# Module-level singleton. Тесты могут сконструировать свой AdapterRegistry
|
|
296
|
+
# и передать явно; production-код использует этот.
|
|
297
|
+
_REGISTRY = AdapterRegistry()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def registry() -> AdapterRegistry:
|
|
301
|
+
"""Вернуть process-wide singleton реестра адаптеров."""
|
|
302
|
+
return _REGISTRY
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Адаптер кастомных движков: fingerprinting нативных бинарников (v0.7.0, п.4).
|
|
2
|
+
|
|
3
|
+
«Кастомный C++ движок» — не безымянная тьма: у известных движков без
|
|
4
|
+
собственного magic-формата есть СТРОКОВЫЕ маркеры в бинарнике — имена
|
|
5
|
+
модулей, пути рантайма, неймспейсы. Адаптер сканирует бинарник
|
|
6
|
+
потоково (чанки с перекрытием — маркер может лечь на границу) и
|
|
7
|
+
сверяет с базой фингерпринтов.
|
|
8
|
+
|
|
9
|
+
Честная шкала уверенности
|
|
10
|
+
--------------------------
|
|
11
|
+
|
|
12
|
+
- **HIGH** — два и более РАЗНЫХ маркера одного движка: совпадение двух
|
|
13
|
+
независимых строк случайно практически исключено (магия-эквивалент);
|
|
14
|
+
- **MEDIUM** — один маркер: строка могла попасть в бинарник случайно
|
|
15
|
+
(например, упоминание в комментарии/ресурсе) — честно «похоже, но
|
|
16
|
+
не доказано»;
|
|
17
|
+
- ничего не найдено — ``None`` (не наш движок — дорога остаётся за
|
|
18
|
+
UNKNOWN → нативный Ghidra-анализ пайплайна).
|
|
19
|
+
|
|
20
|
+
``engine`` репортится как :attr:`Engine.CUSTOM
|
|
21
|
+
<prrevers.models.Engine.CUSTOM>` — enum-значений на каждый движок нет
|
|
22
|
+
и не будет (роадмап: «fingerprinting», не «поддержка Ren'Py»);
|
|
23
|
+
фактическое имя движка едет в ``extra['engine_name']`` и в evidence.
|
|
24
|
+
|
|
25
|
+
Экстракции для фингерпринтнутых движков в PRRevers пока нет (Ren'Py
|
|
26
|
+
декомпилируется своим тулзом, RPG Maker — своим) — пайплайн честно
|
|
27
|
+
отправляет их по нативной Ghidra-дороге (``_try_analyze_custom_native``
|
|
28
|
+
принимает CUSTOM наравне с UNKNOWN), а имя движка попадает в вывод —
|
|
29
|
+
пользователь видит, ЧТО искать дальше.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from prrevers.adapters import DetectionHint, EngineAdapter
|
|
38
|
+
from prrevers.models import (
|
|
39
|
+
ArtifactKind,
|
|
40
|
+
DetectionConfidence,
|
|
41
|
+
DetectionResult,
|
|
42
|
+
Engine,
|
|
43
|
+
Platform,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = ["CustomEngineAdapter", "FINGERPRINTS"]
|
|
47
|
+
|
|
48
|
+
#: Размер чанка чтения: 4 MiB — компромисс между числом syscall-ов и
|
|
49
|
+
#: памятью (буфер чанка + перекрытие — константа при любом размере игры).
|
|
50
|
+
_SCAN_CHUNK_BYTES = 4 * 1024 * 1024
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class _Fingerprint:
|
|
55
|
+
"""Один известный кастомный движок и его строковые маркеры."""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
markers: tuple[bytes, ...]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
#: База фингерпринтов: движки без magic-формата, чьи маркеры живут в
|
|
62
|
+
#: нативном бинарнике. Маркеры специфичны (пути рантайма, имена модулей,
|
|
63
|
+
#: неймспейсы) — коротких общих слов («game», «engine») здесь нет
|
|
64
|
+
#: намеренно: ложные срабатывания хуже честного UNKNOWN.
|
|
65
|
+
FINGERPRINTS: tuple[_Fingerprint, ...] = (
|
|
66
|
+
_Fingerprint(
|
|
67
|
+
name="Ren'Py",
|
|
68
|
+
markers=(b"Ren'Py", b"renpy/common/", b"renpy/display/"),
|
|
69
|
+
),
|
|
70
|
+
_Fingerprint(
|
|
71
|
+
name="Cocos2d-x",
|
|
72
|
+
markers=(b"cocos2d-x", b"cocos2d::"),
|
|
73
|
+
),
|
|
74
|
+
_Fingerprint(
|
|
75
|
+
name="MonoGame",
|
|
76
|
+
markers=(b"MonoGame", b"monogame"),
|
|
77
|
+
),
|
|
78
|
+
_Fingerprint(
|
|
79
|
+
name="LÖVE (Love2D)",
|
|
80
|
+
markers=(b"love.filesystem", b"love.event", b"love.graphics"),
|
|
81
|
+
),
|
|
82
|
+
_Fingerprint(
|
|
83
|
+
name="RPG Maker (RGSS)",
|
|
84
|
+
markers=(b"RGSS",),
|
|
85
|
+
),
|
|
86
|
+
_Fingerprint(
|
|
87
|
+
name="Clickteam Fusion",
|
|
88
|
+
markers=(b"Clickteam",),
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
#: Все маркеры одной плоскостью — для потокового сканирования.
|
|
93
|
+
_ALL_MARKERS: tuple[bytes, ...] = tuple(marker for fp in FINGERPRINTS for marker in fp.markers)
|
|
94
|
+
|
|
95
|
+
#: Самый длинный маркер: перекрытие чанков обязано его переживать.
|
|
96
|
+
_MAX_MARKER_LEN = max(len(m) for m in _ALL_MARKERS)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CustomEngineAdapter(EngineAdapter):
|
|
100
|
+
"""Детектит известные кастомные движки по строковым маркерам.
|
|
101
|
+
|
|
102
|
+
Сканирует только нативные бинарники (PE_EXE / PE_DLL / ELF_SO):
|
|
103
|
+
фингерпринты живущих в контейнерах движков покрываются своими
|
|
104
|
+
адаптерами (Unity / Godot / GameMaker / Web).
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
engine = Engine.CUSTOM
|
|
108
|
+
display_name = "Custom engine (fingerprinted)"
|
|
109
|
+
|
|
110
|
+
def detect(
|
|
111
|
+
self,
|
|
112
|
+
source: Path,
|
|
113
|
+
container: DetectionResult,
|
|
114
|
+
platform: Platform,
|
|
115
|
+
) -> DetectionHint | None:
|
|
116
|
+
if container.kind not in {ArtifactKind.PE_EXE, ArtifactKind.PE_DLL, ArtifactKind.ELF_SO}:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
found = _scan_markers(source)
|
|
120
|
+
if not found:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
for fingerprint in FINGERPRINTS:
|
|
124
|
+
hits = [m for m in fingerprint.markers if m in found]
|
|
125
|
+
if not hits:
|
|
126
|
+
continue
|
|
127
|
+
shown = ", ".join(m.decode("ascii", errors="replace") for m in hits)
|
|
128
|
+
if len(hits) > 1:
|
|
129
|
+
return DetectionHint(
|
|
130
|
+
confidence=DetectionConfidence.HIGH,
|
|
131
|
+
message=(
|
|
132
|
+
f"фингерпринт {fingerprint.name}: {len(hits)} независимых "
|
|
133
|
+
f"маркера ({shown})"
|
|
134
|
+
),
|
|
135
|
+
engine=Engine.CUSTOM,
|
|
136
|
+
extra={"engine_name": fingerprint.name, "markers": shown},
|
|
137
|
+
)
|
|
138
|
+
return DetectionHint(
|
|
139
|
+
confidence=DetectionConfidence.MEDIUM,
|
|
140
|
+
message=f"фингерпринт {fingerprint.name}: один маркер ({shown})",
|
|
141
|
+
engine=Engine.CUSTOM,
|
|
142
|
+
extra={"engine_name": fingerprint.name, "markers": shown},
|
|
143
|
+
)
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _scan_markers(source: Path) -> frozenset[bytes]:
|
|
148
|
+
"""Потоково найти маркеры в бинарнике (чанки с перекрытием).
|
|
149
|
+
|
|
150
|
+
Память — константа (один чанк + хвост перекрытия) при любом
|
|
151
|
+
размере игры: мультигигабайтные .exe читаются без разворачивания
|
|
152
|
+
в память (прецедент ``cache_key_from_path``). Маркер, лежащий на
|
|
153
|
+
границе чанков, не теряется: хвост длиной ``_MAX_MARKER_LEN - 1``
|
|
154
|
+
приписывается к следующему чанку.
|
|
155
|
+
"""
|
|
156
|
+
found: set[bytes] = set()
|
|
157
|
+
pending = _ALL_MARKERS
|
|
158
|
+
try:
|
|
159
|
+
with source.open("rb") as fh:
|
|
160
|
+
tail = b""
|
|
161
|
+
while pending:
|
|
162
|
+
chunk = fh.read(_SCAN_CHUNK_BYTES)
|
|
163
|
+
if not chunk:
|
|
164
|
+
break
|
|
165
|
+
data = tail + chunk if tail else chunk
|
|
166
|
+
for marker in pending:
|
|
167
|
+
if marker in data:
|
|
168
|
+
found.add(marker)
|
|
169
|
+
pending = tuple(m for m in pending if m not in found)
|
|
170
|
+
if len(data) >= _MAX_MARKER_LEN:
|
|
171
|
+
tail = data[-(_MAX_MARKER_LEN - 1) :]
|
|
172
|
+
except OSError:
|
|
173
|
+
# Нечитаемый файл — не наш случай: пусть про неём говорят
|
|
174
|
+
# детектор контейнера и его honest-провал, а не пустой hint.
|
|
175
|
+
return frozenset()
|
|
176
|
+
|
|
177
|
+
return frozenset(found)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Адаптер GameMaker.
|
|
2
|
+
|
|
3
|
+
Детектит GameMaker по расширению ``.win`` (Windows) или библиотеке
|
|
4
|
+
``libYoYoGameRunner.so`` (Android). UndertaleModTool — канонический
|
|
5
|
+
экстрактор; интеграция не реализована (не вошла ни в одну закрытую веху
|
|
6
|
+
роадмапа — см. docs/ROADMAP.ru.md).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import zipfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from prrevers.adapters import DetectionHint, EngineAdapter
|
|
15
|
+
from prrevers.constants import GAMEMAKER_ANDROID_LIB, GAMEMAKER_WIN_SUFFIX
|
|
16
|
+
from prrevers.models import (
|
|
17
|
+
ArtifactKind,
|
|
18
|
+
DetectionConfidence,
|
|
19
|
+
DetectionResult,
|
|
20
|
+
Engine,
|
|
21
|
+
Platform,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = ["GameMakerAdapter"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GameMakerAdapter(EngineAdapter):
|
|
28
|
+
"""Детектит GameMaker через .win суффикс или libYoYoGameRunner.so."""
|
|
29
|
+
|
|
30
|
+
engine = Engine.GAMEMAKER
|
|
31
|
+
display_name = "GameMaker"
|
|
32
|
+
|
|
33
|
+
def detect(
|
|
34
|
+
self,
|
|
35
|
+
source: Path,
|
|
36
|
+
container: DetectionResult,
|
|
37
|
+
platform: Platform,
|
|
38
|
+
) -> DetectionHint | None:
|
|
39
|
+
suffix = source.suffix.lower()
|
|
40
|
+
if suffix == GAMEMAKER_WIN_SUFFIX:
|
|
41
|
+
return DetectionHint(
|
|
42
|
+
confidence=DetectionConfidence.HIGH,
|
|
43
|
+
message=f"суффикс {GAMEMAKER_WIN_SUFFIX} → GameMaker",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if container.kind == ArtifactKind.APK:
|
|
47
|
+
try:
|
|
48
|
+
with zipfile.ZipFile(source) as zf:
|
|
49
|
+
names = set(zf.namelist())
|
|
50
|
+
except zipfile.BadZipFile:
|
|
51
|
+
return None
|
|
52
|
+
if any(GAMEMAKER_ANDROID_LIB in n for n in names):
|
|
53
|
+
return DetectionHint(
|
|
54
|
+
confidence=DetectionConfidence.HIGH,
|
|
55
|
+
message=f"APK содержит {GAMEMAKER_ANDROID_LIB}",
|
|
56
|
+
)
|
|
57
|
+
return None
|