14 KiB
aidem_media_playground
Narzędzie do analizy różnicowej silników gier Aidem Media (Piklib / BlooMoo).
Cel: katalog gier (z ISO/ZIP), wersje silnika z hashami, oraz ekstrakcja i porównywanie
"powierzchni silnika" — typów, metod, eventów i pól klas CMC_* — między wersjami.
Status
Pełny łańcuch działa: binarka → (Ghidra worker) → snapshot.json → katalog (DB) → diff → UI.
Ekstraktor pokrywa 5 osi (typy / metody / eventy / pola skryptowe / ciała metod) + bonus layout C++,
zwalidowany na golden pair PIKLIB8 (MSVC6) ↔ bloomoo (MSVC8). Nad tym: FastAPI + katalog (SQLite/Postgres),
diff z normalizacją ciał i miarą „podobnych wersji", Command Center UI, pipeline akwizycji ISO/ZIP
i pełny stack docker compose z workerem Ghidry. Zobacz Quickstart niżej.
Architektura (docelowa)
Modularny monolit + worker. Backend Python/FastAPI tylko zleca, wersjonuje i diffuje.
Cała ekstrakcja żyje w workerze = Ghidra headless + ten skrypt, bo wymaga dostępu
do call-grafu, referencji i vtable. Worker emituje snapshot.json, monolit go konsumuje.
Front (centrum dowodzenia) ─ FastAPI (katalog/hashe/diff) ─ PostgreSQL
│ kolejka
Worker: Ghidra headless + extract_engine_surface.py
Quickstart
1. Lokalnie, bez Dockera (działa od ręki — golden snapshoty są w repo)
python -m venv .venv && source .venv/bin/activate
pip install -e ".[api,dev]"
# zasiej katalog dwoma wersjami z golden pair
python -m ams.api.importer --game "Reksio i UFO" snapshots/PIKLIB8.dll.snapshot.json
python -m ams.api.importer --game "Reksio i Kapitan Nemo" snapshots/bloomoodll.dll.snapshot.json
uvicorn ams.api.app:create_app --factory # → http://127.0.0.1:8000/
Diff też z CLI: python -m ams OLD.json NEW.json --owner CMC_Animo --only dispatch.
2. Pełny stack w Dockerze (Ghidra w workerze) — upload ISO/ZIP → snapshot
docker compose up --build # db(Postgres) + redis + api + worker(Ghidra)
Pierwszy build workera ściąga Ghidrę (~1 GB) + JDK 21 (wolny, ale cache'owany). Potem wgraj grę przyciskiem + wgraj w UI (http://localhost:8000) albo z CLI:
curl -F file=@"/sciezka/do/gra.iso" -F game="Reksio i Czarodzieje" http://localhost:8000/jobs
curl http://localhost:8000/jobs/1 # status: queued → started → finished
Worker rozpakowuje (bsdtar: ISO/ZIP), content-based znajduje DLL silnika, hashuje, odpala
Ghidra headless + extract_engine_surface.py → snapshot → import do Postgresa → UI.
3. Ghidra w obrazie — wersja / troubleshooting
Worker (docker/worker.Dockerfile, eclipse-temurin:21-jdk) pobiera Ghidrę i ustawia
GHIDRA_HOME=/opt/ghidra. Wersja jest przypięta w ARG GHIDRA_URL. Jeśli build padnie na pobieraniu,
nadpisz URL realnym wydaniem z releases NSA
(nazwa pliku: ghidra_<wer>_PUBLIC_<data>.zip).
Domyślnie wymaga Ghidry ≤ 11.3.x. Ekstraktor to skrypt Pythona (
.py), który Ghidra w headless uruchamia przez wbudowanego Jythona. Ghidra 11.4+ / 12.x usunęły Jythona — tam.pyheadless przezanalyzeHeadlessnie ruszy (Ghidra was not started with PyGhidra...): analiza przejdzie, ale post-skrypt nie wyemituje snapshotu. DomyślnyGHIDRA_URLceluje w 11.2.1.
Wariant Jython (domyślny, ≤ 11.3.x):
docker compose build worker \
--build-arg GHIDRA_URL=https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_11.2.1_build/ghidra_11.2.1_PUBLIC_20241105.zip
docker compose up
Wariant PyGhidra (Ghidra 11.4+ / 12.x): obraz workera ma już pyghidra; ten sam skrypt leci
przez CPython (pyghidra.run_script, bez zmian w kodzie). Zbuduj z nowszą Ghidrą i włącz przełącznik:
docker compose build worker \
--build-arg GHIDRA_URL=https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_11.4.2_build/ghidra_11.4.2_PUBLIC_20250826.zip
AMS_USE_PYGHIDRA=1 docker compose up
Pod spodem worker woła ams.acquire.ghidra.run_extractor_pyghidra (uruchamia Ghidrę in-process,
importuje + analizuje binarkę, odpala nasz GhidraScript z getScriptArgs()=[out_path]).
4. Ekstrakcja ręcznie w GUI Ghidry (alternatywa, bez Dockera)
Script Manager → Manage Script Directories → wskaż ghidra_scripts/, otwórz program (DLL),
uruchom extract_engine_surface.py. Snapshot ląduje w snapshots/<nazwa>.snapshot.json,
potem zaimportuj go przez python -m ams.api.importer ….
Zasada ekstrakcji
Ekstrakcja stoi na kotwicach semantycznych (cele wywołań, referowane literały
stringów, immediaty PUSH), a nie na tekście dekompilatu. Dzięki temu jeden skrypt
działa na MSVC6 (Piklib) i MSVC8 (BlooMoo) mimo różnego kodu wynikowego.
| Co | Kotwica w Ghidrze | Status |
|---|---|---|
| Typy | CMC_ObjectsContainer::resolve (8.x/BlooMoo) lub CMC_Scene::resolve (Piklib 6.1/7.1): operator==("NAME") → operator_new(SIZE) → ctor |
✅ (8.x inline; 6.1 tag-based = nazwy bez ctora) |
| Metody | CMC_*_Runner::prepareMthHashSet: CInteger(id) + CStringHashCode("NAME") + CHashtable::put |
✅ (+ method_inheritance) |
| Eventy | CMC_*::getBehavioursList: lista literałów CXString |
✅ (lista per klasa, bez dziedziczenia) |
| Pola (skryptowe) | ctory CMC_*: literały czytane przez CMElement::getProperty<T>Value → nazwa + typ pola (FPS, PRELOAD, VISIBLE…) |
✅ (+ field_inheritance) |
| Layout C++ (bonus) | ctory CMC_*: store'y this+offset przez P-code (rozmyte, is_vtable) |
✅ pod struct_layout |
| Ciała metod | CMC_*_Runner::run: switch(id) (vtable slot 17) → per case kotwice CALL (Klasa::metoda / vtbl+0xNN), rozwinięcie wrapperów |
✅ pod method_dispatch (id→impl_addr+calls) |
Uruchomienie ekstraktora
W GUI Ghidry (najszybciej do walidacji): skopiuj ghidra_scripts/extract_engine_surface.py
do swojego katalogu skryptów (Script Manager → Manage Script Directories), otwórz program,
uruchom skrypt. Wynik trafi do <NazwaProgramu>.snapshot.json w katalogu roboczym Ghidry.
Headless (tryb docelowy):
analyzeHeadless <projDir> <projName> -process PIKLIB8.dll \
-postScript extract_engine_surface.py "$(pwd)/snapshots/PIKLIB8.snapshot.json"
Uruchomienie w kontenerach (docker compose)
Pełny stack — modularny monolit + wydzielony worker Ghidry — czterema usługami:
db (Postgres) ── api (FastAPI + UI) ──┐
├── redis (kolejka)
worker (Ghidra headless) ─────────────┘
docker compose up --build # api na http://localhost:8000
api i worker współdzielą wolumen uploads: API streamuje wgrane archiwum na dysk,
worker czyta je po ścieżce (przez Redisa leci tylko ścieżka, nie bajty). Obraz workera
pobiera Ghidrę (~1 GB) przy pierwszym buildzie — wersję nadpiszesz przez
--build-arg GHIDRA_URL=…. Postgres trzyma katalog trwale (wolumen pgdata).
Asynchroniczna akwizycja przez API (zlecenie → kolejka → worker → snapshot w bazie):
curl -F file=@game.iso -F game="Reksio i UFO" http://localhost:8000/jobs # → 202 {id, status:queued}
curl http://localhost:8000/jobs/1 # poll: queued→started→finished
Endpointy joba: POST /jobs (upload+enqueue), GET /jobs, GET /jobs/{id} (status, snapshot_id, error).
Akwizycja — ISO/ZIP → katalog
Worker, który domyka łańcuch od pliku gry do wpisu w katalogu: rozpakowuje archiwum,
sam znajduje DLL silnika (po markerach w binarce — CMC_ObjectsContainer w RTTI —
więc działa nawet po zmianie nazwy pliku), liczy hashe (sha256 + md5 + opcjonalnie ssdeep),
odpala Ghidrę headless z ekstraktorem i ląduje snapshotem w bazie.
pip install -e ".[api,acquire]" # acquire = ppdeep (fuzzy hash, opcjonalny)
export GHIDRA_HEADLESS=/path/to/ghidra/support/analyzeHeadless # albo GHIDRA_HOME
python -m ams.acquire game.iso --game "Reksio i UFO" # ISO/ZIP/katalog/luźny DLL
python -m ams.acquire dump_dir --game "Reksio i UFO" --sink http --post http://127.0.0.1:8000
python -m ams.acquire PIKLIB8.dll --identify-only # tylko unpack+identify+hash, bez Ghidry
--sink db (domyślnie) importuje wprost do bazy, --sink http POST-uje na /snapshots,
--sink none zostawia sam snapshot. --identify-only to suchy bieg do walidacji bez Ghidry.
Rozpakowywanie stoi na bsdtar (libarchive — czyta i ISO9660, i ZIP); ZIP ma fallback na
czysty Python. Snapshot dostaje doklejony blok binary.acquisition (źródło, nazwa DLL) oraz
binary.fuzzy/md5/size.
Diff engine (CLI)
python -m ams OLD.snapshot.json NEW.snapshot.json [--owner CMC_Animo] \
[--only types,methods,events,fields,layout,dispatch] [--json]
Porównuje dwa snapshoty po osiach (added/removed/changed) + wykrywa metody przeniesione
w hierarchii. Oś dispatch (ciała metod, klucz owner+id) diffuje fingerprint wywołań
każdej metody — wykrywa zmiany ciała między wersjami; jak struct_layout, najczystsza
między wersjami tego samego kompilatora (cross-compiler proste metody i tak się zgadzają,
np. Animo SHOW→vtbl+0xa0 na MSVC6 i MSVC8).
Normalizacja ciał (ams.normalize): każda zmiana w osi dispatch niesie wynik
body = {similarity, added, removed} — podobieństwo 0–100% sekwencji liści (difflib,
po zwinięciu sąsiednich duplikatów = artefaktów codegenu) oraz które wywołania doszły/zniknęły.
Blok dostaje też summary (wspólne / identyczne / zmienione / średnie podobieństwo). Na golden
pair (cross-compiler): 470 wspólnych ciał, 131 identycznych, średnio 66% — a SHOW/HIDE/PAUSE/ RESUME Animo wychodzą 100% mimo MSVC6↔MSVC8. To jest miara „na ile się zmieniło" na poziomie kodu.
Backend (FastAPI + katalog)
Modularny monolit nad SQLAlchemy — domyślnie SQLite (zero setupu), gotowy pod Postgres
przez DATABASE_URL. Pełny snapshot trzymany jest w bazie verbatim; diff czyta go z powrotem
przez ams.diff.
pip install -e ".[api,dev]" # zależności
python -m ams.api.importer --game "Reksio i UFO" snapshots/PIKLIB8.dll.snapshot.json
uvicorn ams.api.app:create_app --factory --reload # serwer
Endpointy: POST/GET /games, POST/GET /snapshots (import deduplikowany po sha256),
GET /diff?old=&new=[&owner=], GET /snapshots/{id}/similar, GET /compat?format=md|json,
POST/GET /jobs, GET /health.
Testy: pytest (28, w tym integracyjne na golden pair).
Podobne wersje
GET /snapshots/{id}/similar[?min=N] rankuje pozostałe wersje w katalogu po overlapie
powierzchni — Jaccard zbiorów tożsamości (te same klucze co diff) per oś, plus pula overall.
Miara jest cross-compiler: golden pair PIKLIB8 (MSVC6) ↔ bloomoodll (MSVC8) wychodzi 85%
(types 95% / methods 87% / events 77% / fields 90%), tam gdzie fuzzy-hash binarki daje 0.
Fuzzy (ssdeep) leci jako sygnał poboczny „prawie ten sam plik", gdy snapshot ma binary.fuzzy.
Tablica kompatybilności (eksport pod dokumentację)
Krosowy widok całego katalogu grupowany po nazwie biblioteki (PIKLIB8.dll, bloomoodll.dll…):
która gra wiezie którą bibliotekę. Haczyk: ta sama nazwa może wystąpić w kilku grach, a mimo to
nie być tym samym silnikiem. Dlatego w obrębie jednej nazwy klastrujemy warianty po powierzchni
silnika (te same zbiory tożsamości co diff/similarity — kompilator-agnostyczne), a każdy
wariant inny niż referencyjny (A) dostaje gwiazdkę * z deltą czym się różni. Byte-różnice
z rekompilacji nie generują gwiazdki — liczy się API skryptowe. Prefiks <sha8>_ doklejany do
nazwy pliku przy składowaniu (np. 333ab0c4_PIKLIB8.dll) jest obcinany przed grupowaniem, więc
wszystkie kopie PIKLIB8.dll lądują w jednym wierszu (pełna nazwa pliku zostaje w polu file JSON-a).
# z plików snapshot (bez DB/serwera); Label= ustawia kolumnę-grę
python -m ams.compat \
"Reksio i UFO=snapshots/PIKLIB8.dll.snapshot.json" \
"Reksio i Kapitan Nemo=snapshots/bloomoodll.dll.snapshot.json" \
--format md -o compat.md # albo --format json
Z katalogu (DB) przez API — pobiera gotowy plik (Content-Disposition: attachment):
curl -OJ "http://127.0.0.1:8000/compat?format=md" # → compat.md
curl -OJ "http://127.0.0.1:8000/compat?format=json" # → compat.json (grupy + warianty + diffy)
W UI: przyciski ⬇ md / ⬇ json w panelu Gry / wersje. Markdown to macierz biblioteka × gra
(✓ = obecna; A/B*/… gdy nazwa ma kilka wariantów powierzchni) plus sekcja Warianty: dla
każdego rozbieżnego wariantu overlap %, zbiorcze types/methods/events/fields +N/-N/~N, a pod tym
nazwany rozkład per klasa — które metody/eventy/pola doszły (+), zniknęły (−) lub zmieniły
się (~), np. CMC_Animo: −PAUSE ~FPS (type int→float). Pełne listy (bez cap-a) są w vs_reference.detail JSON-a.
Front — Command Center
Po starcie serwera otwórz http://127.0.0.1:8000/ (/ → /ui/). Statyczny UI bez build-stepu
(czysty HTML/CSS/JS w ams/api/static/, serwowany przez FastAPI): lista gier/wersji, wybór dwóch
wersji (A/B), wizualny diff po 4 osiach z filtrem klasy i przeglądarka pojedynczej powierzchni.
Przycisk + wgraj w panelu gier otwiera upload ISO/ZIP/DLL → POST /jobs; status zadania
(queued→started→finished/failed) jest odpytywany na żywo, a po zakończeniu lista wersji odświeża się sama.
W przeglądarce pojedynczej wersji widać panel Podobne wersje (pasek overlapu + ⇄ diff ustawiający
A/B i odpalający porównanie).
Format snapshotu
schema_version, binary{name,sha256,engine,compiler,factory_addr}, oraz listy
types / methods / events / fields. Diff = operacje na zbiorach dwóch snapshotów.