Wolfenstein Modloader Buy me a coffeeKaffee spendieren

Wiki / Game systemsSpielsysteme

Ghidra: What to Extract from the EXEs — and What Not

ReferenceNachschlagewerk Original page in the GitHub wikiOriginalseite im GitHub-Wiki

In plain wordsIn einfachen Worten

This page describes running the reverse-engineering tool Ghidra on the program files of Wolfenstein: The New Order and Wolfenstein II: The New Colossus. The actual game code is encrypted by Steam's copy protection in both games, so it cannot be read on disk. What can be read are the function boundaries and the class structure, which a custom parser extracted; Ghidra's own class analyzer found nothing. Questions about what the code actually does can only be answered while the game runs.

  • Game code is encrypted by SteamStub in both games and looks like random data.
  • Function boundaries: 50,884 in TNO and 121,682 in TNC.
  • Class model: 2,368 classes in TNO and 5,627 in TNC, read by a direct RTTI parser.
  • Ghidra's built-in class analyzer found 0 classes on these files; this is measured, not a setup error.
  • No unpacker is built here, since that would mean removing copy protection.

Diese Seite beschreibt den Einsatz des Reverse-Engineering-Werkzeugs Ghidra auf die Programmdateien von Wolfenstein: The New Order und Wolfenstein II: The New Colossus. Der eigentliche Spielcode ist in beiden Spielen durch Steams Kopierschutz verschlüsselt und auf der Festplatte nicht lesbar. Lesbar sind die Funktionsgrenzen und die Klassenstruktur, die ein eigener Parser ausgelesen hat; Ghidras eigene Klassenanalyse fand nichts. Fragen dazu, was der Code tatsächlich tut, lassen sich nur beantworten, während das Spiel läuft.

  • Der Spielcode ist in beiden Spielen durch SteamStub verschlüsselt und sieht aus wie Zufallsdaten.
  • Funktionsgrenzen: 50.884 in TNO und 121.682 in TNC.
  • Klassenmodell: 2.368 Klassen in TNO und 5.627 in TNC, gelesen mit einem eigenen RTTI-Parser.
  • Ghidras eingebaute Klassenanalyse fand 0 Klassen; das ist gemessen, kein Bedienfehler.
  • Ein Entpacker wird hier bewusst nicht gebaut, da das Kopierschutz entfernen hieße.

Technical detailsTechnische Details

Der technische Teil ist auf Englisch – so, wie er im Wiki steht. Unterstrichene Begriffe haben eine Erklärung auf Deutsch.

Tool: tools/ghidra_map.py. Results: docs/ghidra/.

Summary

The code section .text is encrypted by SteamStub in both games and is statically unanalyzable. Ghidra finds nothing there, and this is not a user error.

What lies beside it is fully readable and evaluated:

TNO TNC
Function boundaries from .pdata 50 884 121 682
RTTI classes with inheritance and vtable 2 368 5 627

Ghidra's own RTTI analyzer delivered zero of these — why, see below under "What Ghidra Contributed". The class hierarchy comes from a direct parser over the MSVC-RTTI structures in .data/.rdata.

Setup

Ghidra and a JDK as ZIP files only; no installer, no privilege escalation, nothing added to PATH or Registry:

$root = "%USERPROFILE%\tools"; mkdir -Force $root\_download | Out-Null
# curl.exe has been part of Windows since Win10 and can resume interrupted downloads.
curl.exe -L -C - --fail -o $root\_download\ghidra.zip `
  https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_12.1.4_build/ghidra_12.1.4_PUBLIC_20260921.zip
curl.exe -L -C - --fail -o $root\_download\jdk21.zip `
  https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_windows_hotspot_21.0.12.1_1.zip
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory("$root\_download\jdk21.zip", $root)
[System.IO.Compression.ZipFile]::ExtractToDirectory("$root\_download\ghidra.zip", $root)
@{ jdk="$root\jdk-21.0.12.1+1"; ghidra="$root\ghidra_12.1.4_PUBLIC"
   headless="$root\ghidra_12.1.4_PUBLIC\support\analyzeHeadless.bat" } |
  ConvertTo-Json | Out-File "$root\pfade.json" -Encoding ascii

# PyGhidra into its own venv -- the Wheels are in the Ghidra package, offline.
python -m venv $root\pyghidra-venv
& $root\pyghidra-venv\Scripts\python.exe -m pip install --no-index `
  --find-links "$root\ghidra_12.1.4_PUBLIC\Ghidra\Features\PyGhidra\pypkg\dist" pyghidra

ghidra_map.py reads %USERPROFILE%\tools\pfade.json, sets JAVA_HOME and GHIDRA_INSTALL_DIR per invocation, and restarts itself for the Ghidra mode within the PyGhidra venv. Nothing is changed globally.

Pitfalls this Setup Has Hit

  • Invoke-WebRequest cannot resume downloads. An interrupted 543-MB download leaves a ruin that a "already exists" check will skip on the next run. curl.exe -C - can do it.
  • Expand-Archive takes a very long time for 6695 entries; [System.IO.Compression.ZipFile]::ExtractToDirectory is orders of magnitude faster.
  • & native.exe 2>&1 turns every stderr line into a NativeCommandError in PowerShell 5.1 and terminates the script with Exit 1 — even though the program returned 0. java -version writes to stderr. Do not redirect it.
  • Ghidra 12 removed Jython. analyzeHeadless.bat accepts -preScript / -postScript with .py files, but crashes with Ghidra was not started with PyGhidra. Python is not available — and only after the complete analysis, so the run proceeds without the desired settings and you barely notice it. Either use PyGhidra or write scripts in Java.
  • A left-over Ghidra project gets reused. Then you measure the analysis results of the previous run, not those of the configured analyzer. ghidra_map.py deletes the project directory before each run.
  • AutoAnalysisManager reads analyzer switches when opening the program. Whoever sets them afterwards must call initializeOptions() afterward.

Invocation

python tools/ghidra_map.py pdata # Function map, seconds, without Ghidra python tools/ghidra_map.py rtti # Class model, ~1 s, without Ghidra python tools/ghidra_map.py ghidra # Ghidra import of both EXEs python tools/ghidra_map.py alles python tools/ghidra_map.py --selbsttest

The EXEs are read-only. For the Ghidra import, a copy is placed in the Scratchpad (%TEMP%\wolf_ghidra); the Ghidra project also resides there and not in the repo.

What does not work: .text

Shannon entropy of raw bytes per section (docs/ghidra/pdata_*.json, 8,000 = indistinguishable from random):

Section TNO TNC
.text 8,000 8,000
.bind 7,998 7,945
.pdata 6,756 6,995
.rdata 5,473 4,659
.data 3,055 3,179

.bind is the SteamStub loader; it decrypts .text only at startup in memory. For static analysis this means:

  • No disassembly, no decompilation, no function names from code. Ghidra's Decompiler has nothing to do here.
  • No call graphs, no cross-references of code to data. Consequently, every mapping "which function belongs to which class" is missing — the vtables below provide addresses, but the code behind them remains opaque.
  • All analyzers that touch code are disabled in ghidra_map.py. They would burn hours on noise and generate phantom functions. They are disabled via a whitelist (everything except two), not via a blacklist — analyzer names change between versions, and a misspelled name is silently ignored.

An unpacker for this will not be built or used in this project: that would be removing copy protection. The encrypted .text is the limit of static analysis.

Function map from .pdata

The exception table lists every function for x64 with a RUNTIME_FUNCTION entry: 12 bytes consisting of start RVA, end RVA, and pointers to Unwind Info. It is unencrypted — function boundaries are open, even if the code within them is not readable.

docs/ghidra/pdata_tno.csv, docs/ghidra/pdata_tnc.csv: va_start,va_end,size,rva_start,unwind_rva.

TNO TNC
Functions 50 884 121 682
Size Median 90 B 101 B
Size Max 27 952 B 51 617 B
Covered Bytes 11 438 970 29 274 718
Share of Code Section 91.9 % 91.9 %
ImageBase 0x140000000 0x140000000

No overlaps, no invalid ranges. In TNO, 11 functions lie in a second, small code section named text (0x1FB8000, 12 KB, next to .nvFatBi — NVIDIA accessory), while the remaining 50 873 are in .text. The missing ~8 % are alignment padding and jump tables, not missing entries.

What it is useful for: an address from a crash log or runtime trace can be mapped to "Function No. N, Start 0x…, Length L" without knowing the code. Two runs of the same game become comparable in this way.

Class model from MSVC RTTI

docs/ghidra/klassen_tno.csv, docs/ghidra/klassen_tnc.csv: class,bases,vtables,cols (bases and vtables separated by ;). Fully with raw names in rtti_tno.json / rtti_tnc.json.

The structure chain is the standard MSVC one and lies completely outside of .text:

TypeDescriptor (.data)        vfptr | spare | ".?AVidPlayer@@"
CompleteObjectLocator (.rdata) sig=1 | offset | cdOffset | →TD | →CHD | →self
ClassHierarchyDescriptor       sig | attr | numBases | →BaseClassArray
BaseClassArray                 → BaseClassDescriptor per base class → TD
vtable                         the 8 bytes before point to the COL

Under x64, each COL carries its own RVA in the last field. This is a filter without heuristics: a hit is either a COL or nothing. Therefore, the numbers below are not an estimate.

TNO TNC
Type names (.?AV…@@) in .data 2 626 7 167
Valid TypeDescriptors of these 2 541 7 105
Complete Object Locators 2 369 5 783
Classes 2 368 5 627
With base classes 2 181 5 172
With vtable address 2 368 5 627
Total vtables 2 369 5 783
Root classes (no bases) 187 455
With multiple inheritance 1 115
Longest base list 8 8

Class distribution:

Prefix TNO TNC
id* (Engine/Game) 1 898 2 882
hk*/hcl* (Havok) 0 1 391
Template instances (raw) 271 960
Others 199 394

TNO contains no Havok RTTI; TNC includes 1 391 Havok classes. 893 class names appear in both games, 1 475 only in TNO, and 4 734 only in TNC.

The Core Chain

idClass
└── idEventReceiver
    └── idEntity
        ├── idAnimatedEntity
        │   ├── idActor
        │   │   ├── idPlayer
        │   │   └── idAI2
        │   └── idProp → idProp_Usable → idProp_Static → …
        └── …
idResource
└── idDecl
    └── idDeclTypeInfo
        └── idDeclGameModeSP
idInventoryItem (: idEventReceiver : idClass)
└── idWeapon

In TNC, idManagedClass sits additionally between idEventReceiver and idClass — a genuine engine difference between the two games, not a parsing error.

Example vtables (TNO / TNC): idPlayer 0x1410297D8 / 0x1429496B0, idEntity 0x140F77598 / 0x1428EAE30, idDeclGameModeSP 0x140F56098 / 0x1426BE720.

What is intentionally missing here

  • idCVar has no RTTI. .?AVidCVar@@ does not appear in either EXE; idCVar appears only as a plain text string (14 times in TNO, 15 times in TNC). The class is apparently not polymorphic. Therefore, the class model does not help with Cvar work — instead, rely on the string tables from docs/cvars-tnc.md.
  • Template names remain raw. .?AU?$MemberSlot@V…@@ would require a separate demangler to resolve cleanly; the raw names stand unchanged in the CSV.
  • No method names. The vtables provide addresses in .text, which is encrypted. It is statically impossible to determine which function occupies slot n.
  • No field names/offsets. RTTI describes types and inheritance, not the memory layout of members.

What Ghidra contributed

The Ghidra run (ghidra_map.py ghidra, results in docs/ghidra/ghidra_tno.json / ghidra_tnc.json) imports both EXEs — TNO in 21 s, TNC in 24 s, each with 12 memory blocks and 386 or 545 import symbols respectively — and yields 0 RTTI classes, 0 TypeDescriptors, 0 vtables.

This is measured, not suspected. The step-by-step diagnosis:

  1. RttiUtil.findTypeInfoVftableAddress() finds the anchor correctly: 0x14109F568 in TNO. All 2 541 TypeDescriptors point to exactly this address; the data is intact.
  2. RttiAnalyzer.canAnalyze(program) still returns False. The analyzer does not even appear in the program's analysis options — it cannot be enabled because it considers itself irrelevant.
  3. If one manually calls RttiAnalyzer.added(), it places exactly one symbol (type_info::vftable) and subsequently finds zero RTTI0 entries. The MessageLog remains empty.

Conclusion: not a usability error, but the analyzer fails to process these binaries. The direct parser in ghidra_map.py rtti reads the same structures completely for both EXEs in 1–2 seconds.

Incidental finding: Ghidra reports 34 167 (TNO) or 78 325 (TNC) "detected functions", even though all code analyzers are disabled and .text is encrypted. These numbers come from the PE loader itself and convey nothing about actually analyzed code — they are also significantly lower than the 50 884 / 121 682 real entries from .pdata. Do not read these as analysis results.

What the Ghidra run still provides: a section/block overview, the import table with library mapping, and a reliable negative finding. It remains within the tool so that it can be verified.

Open Questions and Runtime Path

Static exhaustion covers: function boundaries, types, inheritance, vtable addresses, imports, strings. Not answerable statically and only reachable at runtime:

  • which code resides at which vtable address,
  • which function reads which CVar,
  • field offsets within objects,
  • everything that the execution of .text presupposes.

For these questions, a debugger running on the game is the only way — then .text is decrypted in memory, and the vtable addresses from this document are exactly the entry point for that. This is a separate task and was explicitly not part of this work.