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-WebRequestcannot 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-Archivetakes a very long time for 6695 entries;[System.IO.Compression.ZipFile]::ExtractToDirectoryis orders of magnitude faster.& native.exe 2>&1turns every stderr line into aNativeCommandErrorin PowerShell 5.1 and terminates the script with Exit 1 — even though the program returned 0.java -versionwrites to stderr. Do not redirect it.- Ghidra 12 removed Jython.
analyzeHeadless.bataccepts-preScript/-postScriptwith.pyfiles, but crashes withGhidra 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.pydeletes the project directory before each run. AutoAnalysisManagerreads analyzer switches when opening the program. Whoever sets them afterwards must callinitializeOptions()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
idCVarhas no RTTI..?AVidCVar@@does not appear in either EXE;idCVarappears 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 fromdocs/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:
RttiUtil.findTypeInfoVftableAddress()finds the anchor correctly:0x14109F568in TNO. All 2 541 TypeDescriptors point to exactly this address; the data is intact.RttiAnalyzer.canAnalyze(program)still returnsFalse. The analyzer does not even appear in the program's analysis options — it cannot be enabled because it considers itself irrelevant.- 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
.textpresupposes.
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.