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.
Files changed (84) hide show
  1. prrevers/__init__.py +118 -0
  2. prrevers/adapters/__init__.py +302 -0
  3. prrevers/adapters/custom.py +177 -0
  4. prrevers/adapters/gamemaker.py +57 -0
  5. prrevers/adapters/godot.py +46 -0
  6. prrevers/adapters/unity.py +172 -0
  7. prrevers/adapters/unreal.py +44 -0
  8. prrevers/adapters/web.py +123 -0
  9. prrevers/analyzers/__init__.py +92 -0
  10. prrevers/analyzers/citations.py +150 -0
  11. prrevers/analyzers/llm.py +262 -0
  12. prrevers/analyzers/prompts/__init__.py +122 -0
  13. prrevers/analyzers/prompts/analyze_system_v1.txt +29 -0
  14. prrevers/analyzers/prompts/analyze_system_v2.txt +29 -0
  15. prrevers/analyzers/prompts/analyze_user_v1.txt +13 -0
  16. prrevers/analyzers/prompts/analyze_user_v2.txt +18 -0
  17. prrevers/analyzers/prompts/trace_system_v1.txt +35 -0
  18. prrevers/analyzers/prompts/trace_user_v1.txt +19 -0
  19. prrevers/cache.py +191 -0
  20. prrevers/citation.py +174 -0
  21. prrevers/cli.py +979 -0
  22. prrevers/compare.py +334 -0
  23. prrevers/constants.py +200 -0
  24. prrevers/detectors/__init__.py +74 -0
  25. prrevers/detectors/container.py +294 -0
  26. prrevers/detectors/engine.py +44 -0
  27. prrevers/detectors/platform.py +49 -0
  28. prrevers/dynamic/__init__.py +79 -0
  29. prrevers/dynamic/doc.py +261 -0
  30. prrevers/dynamic/hooks.py +511 -0
  31. prrevers/dynamic/runner.py +335 -0
  32. prrevers/dynamic/trace.py +238 -0
  33. prrevers/errors.py +147 -0
  34. prrevers/extractors/__init__.py +155 -0
  35. prrevers/extractors/apk.py +57 -0
  36. prrevers/extractors/asar.py +463 -0
  37. prrevers/extractors/assets.py +389 -0
  38. prrevers/extractors/base.py +168 -0
  39. prrevers/extractors/ghidra.py +363 -0
  40. prrevers/extractors/il2cpp.py +180 -0
  41. prrevers/extractors/ilspy.py +103 -0
  42. prrevers/extractors/mono.py +111 -0
  43. prrevers/extractors/unreal.py +365 -0
  44. prrevers/extractors/zip.py +249 -0
  45. prrevers/ghidra_scripts/PrReversDecompileExport.py +185 -0
  46. prrevers/llm.py +1146 -0
  47. prrevers/mcp_server.py +107 -0
  48. prrevers/mcp_tools.py +397 -0
  49. prrevers/models.py +321 -0
  50. prrevers/oracles/__init__.py +70 -0
  51. prrevers/oracles/asset.py +230 -0
  52. prrevers/oracles/base.py +93 -0
  53. prrevers/oracles/binfmt.py +174 -0
  54. prrevers/oracles/bytematch.py +182 -0
  55. prrevers/oracles/citation.py +179 -0
  56. prrevers/oracles/comparison.py +291 -0
  57. prrevers/oracles/cs_check.py +71 -0
  58. prrevers/oracles/detection.py +109 -0
  59. prrevers/oracles/extraction.py +101 -0
  60. prrevers/oracles/level.py +439 -0
  61. prrevers/oracles/manifest.py +133 -0
  62. prrevers/oracles/roundtrip.py +313 -0
  63. prrevers/oracles/trace.py +187 -0
  64. prrevers/oracles/tscn.py +146 -0
  65. prrevers/pipeline.py +778 -0
  66. prrevers/repl.py +271 -0
  67. prrevers/session.py +243 -0
  68. prrevers/synthesizers/__init__.py +78 -0
  69. prrevers/synthesizers/base.py +64 -0
  70. prrevers/synthesizers/csharp.py +231 -0
  71. prrevers/synthesizers/design_doc.py +84 -0
  72. prrevers/synthesizers/gdscript.py +200 -0
  73. prrevers/synthesizers/levels.py +792 -0
  74. prrevers/synthesizers/shaders.py +355 -0
  75. prrevers/synthesizers/textutil.py +28 -0
  76. prrevers/tools.py +423 -0
  77. prrevers/utils/__init__.py +8 -0
  78. prrevers/utils/logging.py +19 -0
  79. prrevers/utils/paths.py +55 -0
  80. prrevers-1.1.0.dist-info/METADATA +220 -0
  81. prrevers-1.1.0.dist-info/RECORD +84 -0
  82. prrevers-1.1.0.dist-info/WHEEL +4 -0
  83. prrevers-1.1.0.dist-info/entry_points.txt +10 -0
  84. 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