Rebuilding a 2017 Team Fortress 2 cheat from a stripped, obfuscated DLL back into readable C++.
Introduction
In mid-2017, NullCore was one of the better-known paid cheats for Team Fortress 2. It shipped as one 32-bit Windows DLL, nullcore.dll. A separate loader injected it into the game client. Inside it had everything a “full” TF2 cheat had at the time: an aimbot with projectile prediction, a triggerbot, ESP, a radar, no-spread and no-recoil, backtracking, crit manipulation, anti-aim with a resolver, a player list, and its own in-game menu with a desktop and taskbar.
This article covers the reverse engineering of that DLL. I started with a single binary, with no symbols and no source, and ended with a complete C++ reconstruction that builds and links cleanly. Every function in the original was either ported or replaced on purpose, and all of them are traced back to their original addresses.
The binary wasn’t written to be easy to read:
- Anti-disassembly junk. The compiler inserted 616 blocks of 20-byte junk code into the functions. Each block does nothing at runtime, but it uses a
call $+6trick to push disassemblers off the real instruction boundaries. Before cleanup, IDA counted 2,736 functions. After the junk was removed, there were 1,812. - Encrypted strings. All 1,082 meaningful strings, including setting names, menu labels, interface names and chat messages, were encrypted with a custom XOR-stream cipher and only decrypted on the stack for the moment they were used.
- Statically linked SDK code. Large parts of Valve’s Source SDK 2013 (tier1, mathlib, KeyValues) were compiled into the DLL. That code had to be identified and separated from the cheat’s own logic.
- Heavy C++. The DLL is built from MSVC 2013 classes with multiple inheritance, vtables, listener base classes and global singleton “components”, all of which had to be identified and named.
What came out of it
| Item | Value |
|---|---|
| Target | nullcore.dll, PE32 x86, Team Fortress 2 client build of ~July 2017 |
| Functions analysed | 1,812 (after junk removal) |
| Strings decrypted | 1,082 |
| Settings recovered | 273 nc_* config variables, with names, ranges and callbacks |
| Engine hooks mapped | 17 vtable hooks + ~14 inline signature detours |
| Reconstructed source | ~56,000 lines of C++17 across ~150 files, builds with MSVC as a 32-bit DLL |
| Modules | 29, all marked finished |
Why do this?
Game cheats are an unusually good subject for reverse engineering practice. They’re compact and self-contained, and they sit very close to the engine they modify. To understand NullCore you need to understand how the Source engine works under the hood: how CreateMove builds a user command, how prediction runs, how network variables are laid out, and how VGUI draws a frame. The cheat is also an obfuscated, real-world production binary, so it’s a realistic test of the whole toolchain: static analysis, cipher recovery, type reconstruction and finally rewriting the code as source.
Scope and ground rules
This is a research and preservation write-up of a binary that is about nine years old. It isn’t a release of a working cheat. Some code paths in the original existed only to dodge anti-cheat: the SMAC shot guard, the bunny-hop limiters tuned to stay under detection thresholds, the anti-debug tick, and the competitive-restrictions bypass. Those were deliberately left out of the reconstruction. Where they appear in this article, they’re described at a high level and not reproduced. Slurs in the original “kill say” strings were redacted.
What’s ahead
The rest of the article follows the order the work was done in:
- First look at the binary: the PE, imports, compiler and what the loader passed in.
- Peeling off the anti-disassembly layer: the 20-byte junk macro, why it breaks tools, and how it was removed.
- Breaking the string encryption: recovering the XOR-stream cipher and decrypting every string back into the IDB.
- Recovering the architecture:
DllMain, the component system, settings and hooks. - Feature by feature: aimbot, accuracy, ESP, radar, crits, triggerbot and more.
- Rebuilding it as C++: compile-time string encryption, keeping the binary’s layout, and checking the reconstruction against the original.
- Lessons learned.
1. First look at the binary
The file
The sample is a single PE32 DLL:
| Property | Value |
|---|---|
| Format | PE32 DLL, x86, image base 0x1A4B0000 |
| Compiler | MSVC 2013 (imports MSVCR120.dll), C++ exceptions (__CxxFrameHandler3), /GS stack cookies |
| Target | 32-bit Team Fortress 2 client, ~July 2017 |
| SHA-256 | ae3ae39b0a1c9f737424285fc7cd5640c418bc9a3f483faf1769f0c809b3e5c6 |
The odd image base, 0x1A4B0000 instead of the default 0x10000000, was the first hint that the DLL was meant to be mapped by a custom loader and not by LoadLibrary.
What the imports give away
Most of the logic was hidden, but the import table wasn’t, and it already sketches the whole program:
tier0(Msg,Warning,ConColorMsg,DevMsg,g_pMemAlloc) andvstdlib(RandomFloat/Int/Seed,KeyValuesSystem). The DLL is built against the Source SDK and talks to the engine like any other game module.steam_api(SteamClient,SteamAPI_RegisterCallback). It looks at Steam friends and users, which later turned out to drive the player list.PSAPI!GetModuleInformation. It reads the size ofclient.dllandengine.dll, which almost always means byte-signature scanning.SHELL32!SHGetFolderPathA+SHLWAPI!PathAppendA. A config file somewhere under the user’s profile.USER32:GetAsyncKeyState, the clipboard API andMessageBoxA.GDI32!EnumFontFamiliesExA: a font picker in the menu.
The interface version strings were also left in plain text: VClient017, VEngineClient014, VClientEntityList003, EngineTraceClient003, VGUI_Surface030 and so on. That pins the target to a specific TF2 build and tells you exactly which virtual-table layouts the code expects.
A leaked build
This copy isn’t the original commercial file. It’s a leaked, cracked build, and the crack changes how it starts up. Normally the loader passed a block of data to DllMain, which the cheat stores at offset +0x2544 in its core object. The crack points that field at a static code-cave buffer instead, disables the “close the game when the loader exits” check, and swaps in a different licensee name. One side effect shows up much later: the table of other NullCore users is filled by the loader, so in this build it’s always empty.
First impressions in IDA
The initial auto-analysis was a mess:
- 2,736 functions, many of them a few bytes long and starting in the middle of other functions;
- “sp-analysis failed” and “positive sp value” warnings everywhere;
- almost no readable strings beyond module names, interface versions and a few ConCommand names.
The first two problems come from the same cause, which was the first thing to deal with.
2. Peeling off the anti-disassembly layer
The pattern
When I lined up the strange call sites, one 5-byte sequence kept coming back:
E8 01 00 00 00 call $+6
That’s a call with a displacement of 1. It “calls” one byte past its own end, skips a single byte, and lands on a pop. It occurs 931 times in .text. Each time it sits inside a fixed-size, self-contained block:
51 push ecx ; save a scratch register
E8 01 00 00 00 call $+6 ; push return address, skip next byte
49 dec ecx ; never executed
59 pop ecx ; throw away the pushed return address
01 E1 add ecx, esp ; junk ALU op on the saved register
E8 01 00 00 00 call $+6
49 dec ecx ; never executed
59 pop ecx
09 C9 or ecx, ecx ; junk
59 pop ecx ; restore the original ecx
Every block follows the same rules:
- it is exactly 20 bytes long;
- it saves one general-purpose register first and restores it last (never
esp); - the stack stays balanced, because every
callpush is matched by apop; - in between there are random ALU ops,
inc/dec,nops and shortjmps over garbage bytes, all touching only the saved register.
At runtime it does nothing, apart from trashing the flags. There are 616 of these blocks, and the register choice is almost perfectly uniform across the seven usable registers (83–91 uses each). The blocks were generated randomly for each site, not copied from a template.
Why it breaks disassemblers
Three things go wrong at once:
- Fake function boundaries. IDA treats every
callas a call to a real function and starts a new one at the target, which here is one byte into the middle of the current function. With 600+ of these inside real code, functions get split apart and fake sub-functions pile up. That’s where the 2,736 count came from. - Broken stack tracking. IDA assumes a
callleavesespunchanged once it returns. Here it never returns: the very next instruction pops the return address. IDA sees apopwith nothing pushed, the stack pointer drifts by +4, and local variable recovery fails for the whole function. - Overlapping instructions. The skipped byte after each
call, and the garbage after eachjmp, decode differently depending on where you start. A linear sweep walks straight into them and decodes phantom instructions (FPU ops are a favourite) until it happens to resync.
How it was probably inserted
You can’t prove it from the DLL alone, but one measurement points to a likely mechanism. In the original .text, the compiler’s own nop runs are 1 byte (750 times), 2 bytes (28), 8 bytes (once) and 13 bytes (once). They never reach 20.
So the simplest explanation is a source macro that emits twenty 0x90 bytes through inline __asm _emit. Inline assembly is an optimization barrier, so MSVC leaves the bytes exactly where they were placed. A small post-build script then finds every 20-NOP run and replaces it with a fresh random no-op block of the same size. Because nothing changes size, no relocations or jump targets move. That fits everything observed: no blocks at function entries (0 of 609 recovered), none between functions, all on statement boundaries, and no 20-NOP runs left over.
Removing it
The fix was simple once the grammar was known. I NOP-patched all 616 blocks in the IDA database only and re-created the affected functions. A plain nop has none of the three problems, so IDA re-analysed cleanly and the real function boundaries came back: 2,736 → 1,812 functions, with stack analysis working again.
As a side project I wrote a generator that produces blocks in the same grammar. It assembles them with Keystone, prints a listing via Capstone, and runs each one in Unicorn to check that it really is a no-op. 3,000 out of 3,000 random blocks kept the stack balanced and the register intact.
3. Breaking the string encryption
Finding the cipher
With the junk gone, the next obstacle was strings. Almost every interesting literal was missing. In their place were calls to tiny constructor functions, one for each string length, all leading into the same routine at 0x1A4F0570. I named it NCCrypt_XorStream.
Each encrypted string is stored as a blob:
key[20] | ciphertext[N] | NUL (padded to a multiple of 4)
The code copies the blob to the stack, decrypts it in place, uses the plain text for one call, and then runs the cipher over the stack copy again. The cipher is its own inverse, so the second pass re-encrypts the buffer and the plain text never stays in memory.
Strings show up in three forms:
EncStrN_ctor(&tmp, &blob)thunks, one per string length;- the same logic inlined, as an SSE
_mm_loadu_si128copy of the blob followed by the cipher call; - static
{blob, size}pairs, used for setting names and descriptions, component names and menu labels.
The algorithm
The keystream mixes the first key byte, a rotating key byte and the position, choosing between four formulas based on divisibility tests driven by key bytes 1–3. The output is XOR-ed into the buffer from the end backwards:
for i in 0 .. N-1:
k0 = (int8)key[0]; ki = (int8)key[i % 20]
d1 = (uint32)((int8)key[1] + 2)
if d1 == 0 or i % d1 == 0:
d3 = (uint32)((int8)key[3] + 4)
v = (d3 == 0 or i % d3 == 0) ? k0 - (ki + i) : k0 + (ki - i)
else:
d2 = (uint32)((int8)key[2] + 4)
v = (d2 == 0 or i % d2 == 0) ? k0 + (ki + i) : k0 + (i - ki)
data[N-1-i] ^= (uint8)v
It’s not cryptographically strong, and it doesn’t need to be: the key is stored right next to the data. Its only job is to stop strings and IDA’s string window from revealing anything. Two details made it awkward to get right. The key bytes are sign-extended, while the divisions are unsigned 32-bit. If either is wrong, most strings still decrypt but a few come out as garbage.
Writing the strings back
A short IDAPython pass found every blob through the constructor thunks and the inline pattern, decrypted it, and annotated the database:
- each blob was renamed
enc_<text>and given a repeatable commentENC[len]: "text"; - each code and data reference got a
str: textcomment; - the per-length thunks were renamed
EncStr<N>_ctor_<addr>.
That recovered 1,082 strings. This was the most useful single step of the project, because the strings named everything:
- every component’s
GetName()result (INCAimBot,IESPManager,CNCCritManager, …); - all 273 settings, with names, descriptions and ranges, such as
nc_bhop_auto_strafe, “Automaticaly presses A/D keys depending on your current mouse turn.”; - every menu label and window title;
- error messages, such as “The build is out-of-date, please wait for the new cheat update and re-inject.”;
- and a few surprises:
"SourceMod Anti-Cheat","SMAC Aimbot Detector","sm plugins". These led to the module that checks whether the server runs SMAC (section 5).
4. Recovering the architecture
Start-up
DllMain (0x1A4E1E90) does all of its work synchronously on DLL_PROCESS_ATTACH:
- Point the core object at the loader’s data block (the crack’s static buffer).
- Call
INullCore::Initialize. It waits forclient.dllandengine.dll, resolves 24 engine interfaces and 4 Steam interfaces, initialises mathlib, registers its tier1 ConVars, un-hides everyFCVAR_DEVELOPMENTONLY | FCVAR_HIDDENcommand, removesFCVAR_CHEATfromthirdperson, and sets network rates. - Set the order in which components run (below).
- Install all game hooks. If a signature isn’t found, show the “out-of-date” message box and give up.
- Load the saved config, create fonts, and play a sound to confirm the injection worked.
Reconstructed, it reads like this:
// 0x1A4E1E90
BOOL WINAPI DllMain(HINSTANCE, DWORD fdwReason, LPVOID)
{
if (fdwReason != DLL_PROCESS_ATTACH)
return TRUE;
if (!g_pNullCore->Initialize()) // vtable slot 11
return FALSE;
components::SetThinkOrder(NCSTR("INullCore;CNCCritManager;CMovementSimulator;"
"CNCPlayerList;IESPManager;INCRadar;INCAimBot;CAutoSMAC;CNCMiscManager"));
components::SetPaintOrder(NCSTR("IESPManager;CAutoSMAC;INCRadar;CNCPlayerList;CNCMenu"));
components::SetInputOrder(NCSTR("INullCore;INCAimBot;INCTriggerBot;CNCMenu;CNCPlayerList"));
if (!Hooks_Install())
{
MessageBoxA(nullptr,
NCSTR("The build is out-of-date, please wait for the new cheat update and re-inject."),
NCSTR("Post Initialization Failure"), MB_ICONERROR);
return FALSE;
}
Config_ApplyCurrent();
FontManager_CreateAll();
g_pNullCore->PostInit();
g_pSurface->PlaySound(g_pNullCore->GetInjectSound());
return TRUE;
}
One practical detail: step 2 blocks until the game modules are ready. That’s only safe because the real loader calls DllMain from its own thread. Loading the DLL with LoadLibrary from the game’s main thread would deadlock.
The component system
Every feature is a global singleton component. Components don’t inherit from one base class. Instead they inherit from up to four small listener base classes: think/CreateMove, paint, input, and level/frame events. The constructor of each base adds this to one of five CUtlVectors inside a large manager object, g_EntityManager (0x1A592540, 114,852 bytes), which also caches per-frame lists of players and other entities.
The order components run in isn’t hard-coded. It’s set by name, from the three semicolon-separated strings in DllMain above, and that’s why decrypting GetName() was so important. Once the base-class vtables were known (0x1A513920, 0x1A513940, …, destructor in slot 2, GetName in slot 1), every component could be found by walking its vtables:
| Component | Instance | Role |
|---|---|---|
INullCore | 0x1A5DD670 | core tick, prediction, view override, user-message filter |
INCAccuracyManager | 0x1A5DC728 | command bracket, no-spread/recoil, fake lag, anti-aim |
INCAimBot | 0x1A5DC7C8 | target selection and aiming |
CMovementSimulator | 0x1A5AF7A0 | player movement and projectile prediction |
INCTriggerBot | 0x1A5DC610 | auto-fire, auto-detonate, airblast, auto-uber |
CNCCritManager | 0x1A5DC900 | crit-bucket mirror and crit panel |
IESPManager | (via g_pESPManager) | ESP framework and renderers |
INCRadar | 0x1A5DFE18 | overhead radar window |
CNCPlayerList | 0x1A5C0C88 | per-player table and priorities |
CNCMiscManager | 0x1A5AF728 | movement helpers, chat, game events |
CAutoSMAC | 0x1A5AE6B0 | detects the SMAC server plugin |
CNCMenu | 0x1A5AF638 | the in-game menu |
Settings
Each setting is a 288-byte CNCVar:
struct CNCVar {
const NCEncEntry* name; // encrypted "nc_aimbot_fov"
const NCEncEntry* desc; // encrypted description
char szValue[256];
int nValue;
float flValue;
bool bValue;
bool bHasMin, bHasMax;
float flMin, flMax;
void (__cdecl* callback)(CNCVar*, const char* oldValue);
};
All 273 are built by dynamic initialisers at the start of .text, and each one adds itself to a global vector. Once that struct was applied in IDA and every instance renamed cv_<name>, the decompiler output went from *(float*)(0x1A5C3B58+268) to cv_nc_aimbot_fov.flValue, and whole modules became readable in one go. The config is stored as tier1 KeyValues in a folder under the user’s profile.
Hooks
The hooking layer is small and conventional:
FindPattern(bytes, len, module)with0x2A(*) as a wildcard byte;Detour_Createwrites anE9 rel32jump and builds a trampoline;VMT_HookAddrswaps a single vtable slot;VMT_GetFuncAddris the clever one. Instead of hard-coding vtable indexes, it takes the address of a compiler-generated virtual-call thunk (mov eax,[ecx]; jmp [eax+N]) and readsNout of its machine code. The slot numbers come from the compiler, so they can’t drift from the SDK headers.
Hooks_Install (0x1A4CD420) sets up 17 vtable hooks (ICvar, CInput, IPrediction, IClientMode, IStudioRender, IViewRender and seven slots on the client DLL interface) and about 14 inline detours found by signature in engine.dll and client.dll (CL_Move, CL_SendMove, S_StartSound, CalcView, the player animation state update, and others). Net-channel and client-state hooks are added later, at runtime.
The centre of it all is the CHLClient::CreateMove hook. It gets the engine’s bSendPacket local by reading the caller’s frame pointer and indexing one byte below it, a classic Source-engine trick. Then it runs prediction, the misc, accuracy, aimbot and triggerbot logic, and closes the command.
5. Feature by feature
With names, types and the component map in place, the features could be read one at a time. This section stays at the level of how they work with the engine; the full per-address tables live in the project’s module notes.
Movement simulator
CMovementSimulator is a client-side copy of Source’s CGameMovement: walking, air movement, stepping, friction, gravity, water and water jumps, all running on a private move state instead of the engine’s. It keeps TF2-specific details too: parachute gravity and fall-speed caps, the swimming curse, and TF’s water movement tweak.
It answers one question for the aimbot: if I fire a projectile now, where will this target be when it arrives? Players are assumed to keep running along their current velocity. Buildings, bosses and stickies move in straight lines. A small fixed-point solver works out flight time and drop for the weapon’s projectile speed, gravity and drag, then adds network latency.
Aimbot
INCAimBot is split into a generic base class and a TF2 subclass that overrides 17 virtual slots (CanAim, GetFireOrigin, GetHull, IsProjectileWeapon, AutoShoot, GetThreat, …). It looks like a design reused from earlier Source games. Each think it builds hitboxes, scans candidates, filters them through the player list’s priority rules, checks visibility with traces, scores them, and then aims in one of several ways. It can write the view angles, write only the command’s angles (silent aim), or steer the mouse through the OverrideMouseInput hook. Projectile weapons aim at the movement simulator’s predicted position.
Accuracy manager
INCAccuracyManager brackets every user command, and it’s the most engine-heavy part of the cheat:
- it stamps the command number and its MD5-derived random seed, and keeps later command numbers increasing when it changes one;
- it works out “can I attack this tick?” for each weapon class (melee swing delay, charge-release weapons, sticky launchers, the medigun);
- no-spread / no-recoil relies on the fact that weapon spread comes from a seed derived from the command number, and recoil from a networked punch angle;
- fake lag and perfect silent aim work by choking outgoing packets through the
bSendPacketflag; - anti-aim and its counterpart, a resolver, learn per player which correction actually hits, using a queue of pending shots resolved by
player_hurtevents.
Crit manager
TF2 random crits are limited per weapon by a server-side “crit bucket”. CNCCritManager mirrors that bookkeeping on the client. For each attack it replays the server’s logic (checks, requests, bucket cost and refill, capped by the game’s own cvars), so it knows whether a crit is available right now. It can then force a crit with a key, and it draws a small “Crit Panel” with the bucket and cost bars.
Triggerbot
INCTriggerBot turned out to be more than “fire when the crosshair is on someone”. It also handles auto-detonating stickies, auto-airblast of incoming projectiles for the Pyro, and auto-uber. Each one has its own target scan, and they’re gated on whether the secondary attack is usable this tick.
ESP and radar
The ESP is a small framework: a manager with a draw list, a layout-exact renderer base class (0x154 bytes), and four renderers for players, buildings, NPCs and projectiles. It draws boxes, labels, health bars, pickup icons and a stencil-buffer outline pass, with its own copy of Source’s CMeshBuilder for 3D lines.
The radar renders the map’s overhead overview into a render target. While it does that it temporarily overrides a few ConVars, then draws entity icons on top in its own desktop window.
Player list, misc and the UI
- Player list: one row per player with name, SteamID, team and Steam-friend flag, plus a per-player priority (Ignore / Normal / Rage / Full Rage) that every targeting feature respects. Priorities are saved per Steam account. It also tags other NullCore users from the loader-supplied table, which is empty in this build.
- Misc: bunny hop and auto-strafe, chat spam and “kill say”, name stealer, voice-command spam, and re-disguise after a backstab, all driven by
player_hurt/player_deathevents. - AutoSMAC: asks the server for its SourceMod plugin list and parses the console reply looking for SMAC. Other modules check the result before doing anything risky.
- The UI: NullCore draws its own small “desktop” on the VGUI paint pass, with draggable windows (
CNCWindow), a taskbar, an info panel, and a tree-style menu generated from the settings table. That menu is one 21 KB function that builds every page.
Statically linked library code
Around 184 functions turned out to be plain Source SDK code: mathlib (including the SSE and 3DNow! variants), CRC32, MD5, tier1 ConVar/CCommand, string tools, KeyValues, CUtlBuffer, bf_read/bf_write, and UTF-8/16 conversion. Recognising these early, partly thanks to the RTTI the tier1 classes still carry, removed a large share of the function count from the actual analysis.
6. Rebuilding it as C++

The rules
The aim was a faithful reconstruction, not a rewrite “in the spirit of” the original:
- C++17, MSVC, 32-bit only, CMake. There’s no external SDK.
src/sdkis a minimal Source SDK subset whose vtable indexes match the 2017 build. - Every reconstructed function has a
// 0xADDRESScomment pointing back to the binary. Control flow and constants are kept as they were. Uncertain interpretations are marked// RE-NOTE:. - No new features. The anti-disassembly junk isn’t reproduced as code.
- Whatever was encrypted in the binary stays encrypted in the source.
Compile-time string encryption
To keep that last rule, the reconstruction implements the same cipher at compile time. NCSTR("text") builds the blob in a constexpr function, with a per-site key seeded from __FILE__, __TIME__, __COUNTER__ and __LINE__. It returns a stack object that decrypts in its constructor and re-encrypts in its destructor, just like the original thunks:
template <std::size_t N>
class EncStr
{
public:
explicit EncStr(const detail::Blob<N>& blob)
{
for (std::size_t i = 0; i < 20 + N; ++i)
m_buf[i] = static_cast<char>(blob.data[i]);
XorStream(m_buf + 20, m_buf, N); // decrypt in place
m_buf[20 + N] = '\0';
}
~EncStr() { XorStream(m_buf + 20, m_buf, N); } // wipe after use
operator const char*() const { return m_buf + 20; }
private:
char m_buf[20 + N + 1];
};
NCENTRY("text") produces the static {blob, size} pairs used by settings and component names. The built DLL ends up with the same string layout as the original.
Faithful where it matters, idiomatic where it doesn’t
Some things had to match the binary exactly, and others were better rewritten:
- Kept exact: object layouts that the engine or other code reads by offset (the
CNCVarlayout, the ESP renderer, the entity manager caches), vtable slot numbers on engine interfaces, and every constant. - Replaced on purpose: the four listener base classes became a single
CComponentwith a phase bitmask;CUtlVectors private to the cheat becamestd::vector; the 17 virtual-call thunks became a singleSlot(iface, index)helper; encrypted-string thunks becameNCSTR. - Generated: the 273
CNCVarglobals are generated from the extracted settings table, and the 21 KB menu builder is generated from its decompiled code with the decrypted labels.
How the work was organised
The 1,812 functions were split into 29 modules by address range. The linker had laid the code out roughly one source file at a time, so address ranges matched modules well. Several AI agents worked on the modules in parallel, all connected to the same live IDA database through an MCP bridge, and I acted as lead. The coordination rules were what kept it consistent:
- Headers first: each module published its public API and a short API doc before implementing, so other modules could build against it.
- The IDA database is the shared source of truth: whoever reconstructs a function renames it in the IDB (
Class__Method), and everyone else calls it by that name. - Each module has clear owners and a per-address table, so every function is accounted for as ported, replaced, or excluded with a reason.
