Skip to content

Handoff: T3SDK status (2026-10-02) ​

Goal ​

A modding SDK for Thief: Deadly Shadows (Steam, T3Main.exe), used against the local game install. It should support ambitious mods, multiplayer first among them. Around it: a launcher for players and map makers, a Godot map workflow (export, edit, repack), and a public matching decompilation worked mostly by Claude agents under the strict gate in tools/agent/, listed on decomp.dev. What we identify gets its name in symbols.txt and its notes in docs/engine.md. Matched source is public (src/, include/); raw decompiler output stays local (build/, ghidra/), and the repository never holds game files or data (see CONTRIBUTING.md).

State of the machine ​

  • The game is closed. System/ holds an SDK build from before frame pacing and the loading-screen curtain (manifest build/sdk/deployed.json): deploy the current build before testing those. System/T3SDK.log is appended to on every run.
  • GitHub: Veradictus/Thief3-Decomp, Conventional Commits (see CONTRIBUTING.md). Agents commit on main and never push: the user pushes. Commits and PRs carry no session links.
  • The Ghidra database in ghidra/ is analysed and has the names from symbols.txt applied (the export round-trips byte for byte).
  • The asset exporter turns all 32 maps into a Godot 4.7 project in build/assets/godot/, tested against the user's Godot 4.7.2. Exports are stamped with the export format (tools/assets/formats.json) and apply the level's saved edits, so Map Studio exports outdated maps again by itself before opening them; exports made before the stamp count as outdated.
  • On the real install: t3pack.py roundtrip --all reports all 55 packages identical, and apply works on Inn (a dry run moved and scaled one fence; every other object stayed byte-identical). No patched map has been loaded in the game yet. The launcher builds and runs on Windows (yarn tauri dev); it needs Rust's MSVC toolchain, which yarn tauri selects on its own (see launcher.md).
  • Matching covers Ion Storm's game code only (CONTRIBUTING.md, "Game code only"). config/PC_20040610/categories.txt, written by tools/classify.py from evidence in the exe (native class registrations with their packages, vtables, strings), says whose each function is: 2.6 MB of game code, 0.8 MB of Epic's engine, 1.4 MB of libraries and 0.22 MB still unclassified (decomp-dev.md, "Whose code it is"). The queue offers game code only, integrate.py publishes nothing else, and progress_report.py check enforces it. Epic's engine code (the UObject natives) was removed from src/, and with it 414 matched functions the classifier does not call game code (244 Epic's, 53 library, 117 unclassified); include/Core/Core.h keeps the declarations the game code compiles against. Matching runs as the agent workflow: a swarm of up to 12 sub-agents at a time (Sonnet on functions under 160 bytes, Opus on bigger ones), from the one-file protocol tools/agent/worker.md. src/Game holds 5,454 matched functions in 2,543 units, one per auto unit of the split (Unsorted_<start>.cpp, chunks of up to 64 KB; _2, _3, ... hold functions whose classes clash with their unit's). decomp.dev shows the committed report progress/PC_20040610/report.json, whose headline is the game code (5,616 of 12,029 functions at 100%, 10.9% of its bytes; hidden from decomp.dev's list below 0.5% matched): regenerate it after integrating, see next step 2.
  • The 266 native classes are in config/PC_20040610/classes.txt (size, super class, flags, vtable; tools/classify.py write), and the 64 classes known only as Class_<vtable> carry their names (AGarrett, AT3PlayerController, UT3GameEngine, ...). tools/assets/t3classes.py lays out every native class from the game's script declarations, and all 191 with a script come out at their registered size: the members are in include/<Package>/<Package>Classes.h (engine.md, "Class layouts"), and context packets show a function's class from them.

What exists ​

PieceWhereStatus
Loader: dinput8.dll proxy, entry-point patch, MinHooksdk/loader/dllmain.cppworks
Start-up, frame/exit hooks, settings, guarded mod callbackssdk/loader/sdk.cppworks
Engine access, lifecycle (Ready/Exiting), layout validation, log hooksdk/loader/engine.cppworks
Mod loading: System/mods/load-order.txt (packages), then System/mods/*.dllsdk/loader/mods.cpploose DLLs work; the load order is not yet built or run (see Next steps 1)
Crash reporter (vectored handler, logs location/registers/stack)sdk/loader/crash.cppworks
Fixes: skip intro moviessdk/loader/fixes.cppworks
Display: native resolutions, borderless window (cursor, VSync, focus, running in the background), frame pacing, widescreen UIsdk/loader/display.cppworks (details below); frame pacing and VSync not yet tried in the game
Level-change curtain (keeps the loading screen up while the game restarts)sdk/loader/curtain.cppa black version worked; the loading-screen version is not yet tried in the game
Main-menu version label, menu input diagnosticssdk/loader/menu.cppworks
Public C API (T3SdkApi v1), C++ engine headersdk/include/t3sdk/
Example modsdk/mods/hello/hello.cppworks
Build/deploy/run/screenshot/click/keys/close tooltools/sdk.pyworks
Ghidra scripts: Decompile, Disassemble, ImportNames, ExportSymbolstools/ghidra/work
Map and asset export to Godot 4.7; Godot map viewertools/assets/, tools/assets/godot/works for all 32 maps (static geometry, lights, actor data)
Godot editor plugin: move/rotate/scale actors, edit gamesys values, save <Level>.edits.jsontools/assets/godot/addons/t3_map_editor/headless tests pass; not yet used by hand on a real map
Map writer: byte-exact round trip, apply edits, install/restore with backuptools/assets/upkgwrite.py, t3pack.pyround trip identical on the real install; apply checked on Inn; no patched map loaded in the game yet
Texture packs: .ibt writer, DDS to texture resource, list/check/apply/restore with backup, --selfchecktools/assets/ibtwrite.py, t3texpack.pysynthetic tests pass; not run on real bundles
Launcher (Tauri): setup, play, SDK install, mods, T3SDK.ini, Map Studio, task queuelauncher/, launcher.mdruns on Windows (yarn tauri dev) and under Xvfb on Linux; Windows build green in CI
Mod manager: .t3mod install/upgrade/remove, load order, profiles, checks, files/ overlay, texture-pack tasks, mod index browserlauncher/src-tauri/src/mods.rs, launcher/src/pages/Mods.svelte, mods.mdRust tests on temporary game folders and UI tests pass; not run on Windows or a real install
Release bundle (tools, prebuilt SDK, embeddable Python)tools/stage_launcher.pybuilds in CI
Matching harness: queue, context, try, strict gate, integrate, wavestools/agent/, .claude/agents/t3-matcher.md, .claude/skills/t3-match/, matching.mdsynthetic tests pass; runs on the real split (hand-matched functions, a smoke wave and a natives wave)
Swarm workflow, families stamped without a model, worker guard.claude/workflows/t3-swarm.js, tools/agent/clusters.py, .claude/settings.json, agent-workflow.mdsynthetic tests pass and the guard was checked with a live sub-agent (2026-10-02); not yet run on the real split
CI: launcher and SDK builds (artifacts), releases on v* tags, decomp.dev report.github/workflows/launcher/SDK green; the progress job checks and publishes the committed report (decomp-dev.md)

Commands are in sdk.md (SDK) and ../CLAUDE.md. tools/sdk.py click/keys/screenshot make UI tests possible without touching the user's mouse: the main menu reacts to posted clicks.

Display fixes: how they work ​

  • Resolutions: the five-entry table at 0x10E6EDC4/0x10E6EDD8 gets the monitor's own modes, native last (option index 4).
  • Borderless: Direct3DCreate8 is hooked through the import; CreateDevice and Reset get windowed presentation parameters and the window becomes a popup covering its monitor. The focus-loss Reset (WM_ACTIVATEAPP, call at 0x10C8BF6B, parameters 0x10F2C86C) is skipped. It used to fail and freeze the game (in UD3DRenderDevice::Lock) on alt-tab, on a click on another monitor, and on exit.
  • Widescreen UI: [WindowManager] AssumedUIScreenWidth is answered as 480 x aspect (852 on 16:9), which keeps proportions. Window::PlacedPosition (0x10A52530) is hooked. Children of a full-width window inside a modal window (every menu, popup and briefing) are moved into a centered 640-wide frame: LEFT and absolute positions +106, RIGHT -106, full-width windows with an x offset +106, centered windows unchanged. The HUD is not modal and keeps its screen-edge anchors. Verified on the main menu and the options screen. UILayoutTrace=1 logs every window's placement. Two exceptions: windows flush against the left or right edge (Pos_X 0, like the main menu's version line) stay at the screen edge, and the Inputs key table's [KeyboardLayoutWindow] width ratios are scaled back to the 640-wide frame.
  • Running in the background: UWindowsViewport::ViewportWndProc is hooked; after its WM_ACTIVATEAPP(FALSE) handling the SDK sets GIsAppActive again and restores the saved pause state (PauseInBackground=1 keeps the game's own pause).
  • Cursor: the device's cursor calls and user32's ShowCursor/SetCursor are hooked; the game's cursor image becomes one scaled Windows cursor, rebuilt only when the image changes.
  • Level changes: ShellExecuteExA (the call that starts Ion Launcher.exe in RelaunchForLevelChange) raises the curtain: the outgoing game copies its monitor, which shows the next level's loading screen, into a section a rundll32 helper inherits and shows topmost. The incoming game's LoadingScreen::Begin hook lifts it with a posted message, and the helper hands that game the foreground. Fallbacks: the new game's window covering the monitor for 3 s, a click or key, 20 s.
  • Frame pacing: SmoothFrames patches the TimeManager constructor's minimum step from 10 ms to 1 ms (see engine.md, Clock). With VSynch on, the windowed device uses COPY_VSYNC. MaxFPS waits before Present (a high-resolution waitable timer, then a short spin).

Test results (2026-09-27, tools/sdk.py run) ​

  • Start-up: intros skipped, the menu comes up about 4 s after launch.
  • Main menu and options screen centered on 2560x1440; no faults logged.
  • Simulated focus loss (posted WM_ACTIVATEAPP 0 then 1): the reset is skipped, the game keeps rendering and responding.
  • Exit through WM_CLOSE: 1.7 s, exit code 0xC0000005. Vanilla crashes the same way; the crash reporter puts the fault at 0x1098A466 (reads address 0 during shutdown).

Later the same day, played by the user:

  • The scaled cursor replaced the tiny, flickering one (confirmed by the user).
  • Losing focus no longer pauses the game (log: "focus lost; the game keeps running").
  • After New Game the next game window came to the front on its own; the first, black curtain covered the gap. The loading-screen curtain replaced it and has not been tried yet.
  • FrameStats: 200 to 1255 fps in the menu, about 180 in Inn, Present 0.3 ms. The user found the game choppy "as if stuck at 60 fps", which led to the TimeManager's 10 ms minimum step (SmoothFrames; not tried yet).

Next steps ​

  1. Verify on the real install (ask the user before deploying or launching; close the game after each test):
    • Deploy the current SDK. New Game should go from the menu straight to Inn's loading screen with no black gap. FrameStats=1 should show about the monitor's refresh rate with VSynch on, and motion should be smooth. Watch for anything that behaves differently with more than 100 world updates a second (physics objects, jumping, mantling, rope arrows); SmoothFrames=0 turns it off.
    • The map edit: in Map Studio, open Inn in Godot, move a prop near the New Game start (PlayerStart__1, for example the iron fence StaticMeshActor__364), Save T3 edits, Repack, Install, New Game, Restore. Unknown: whether collision and baked lighting follow a moved static mesh.
    • Install the launcher from the latest release (v0.2.0 is out; v0.2.1 is next, see releasing.md) and run setup, Install T3SDK, Play, Remove.
    • Texture packs (mods.md, textures/; assets.md, section 3): tools/assets/t3texpack.py --selfcheck must pass on every bundle; note the mip padding rule it prints and any layout statement marked NO. Then make a one-texture pack with an obvious change (a list name used in a small level), check and apply it, load the level and look at the texture, and restore. A level that fails to load or shows the old texture means the engine checks the 20-byte values (or reads the padding differently).
    • Mod manager and loader (mods.md, launcher.md): build the SDK (the loader's load-order.txt code has only been syntax-checked with clang), deploy it, then from the launcher install a code package built from templates/mod/ and a second one that needs it. T3SDK.log must show the packages first, in the page's order and named by folder, then the loose DLLs; a helper DLL next to a packaged DLL must load; a load-order.txt line with .. must be skipped with a log line. Then a content overlay: a files/ mod that replaces a loading screen (Content/T3/Bitmaps) must show in the game, the original must sit in System/mods/originals/, and switching the mod off must put it back. With a texture pack on, a files/ mod that places an .ibt must queue restore, place the bundle, then apply.
  2. decomp.dev: CI publishes progress/PC_20040610/report.json, which python tools/progress_report.py write makes from a local build (the exe stays local) and CI checks against src/ (decomp-dev.md). After each integration: write it and commit it with the source. On decomp.dev, set the project's default category to main (owner).
  3. Matching (game code only):
    • Matching runs as a swarm (agent-workflow.md), now best as the t3-swarm workflow: bands mixed, slots refilled by the script, families stamped between workers. Drain it for a checkpoint (sweep, review, naming pass, integrate.py, dtors.py, progress_report.py write, commit). Integration and retry.py check in parallel, so a checkpoint takes minutes. About 5,650 game functions are still queued: 145 under 80 bytes, 1,870 of 80 to 159, 3,640 of 160 and more. Batch w7 ended (2026-10-03) with 425 deferrals and 47 accepted functions integrate.py cannot place (their classes clash with their unit's, in overflow units too); the renames and models its workers asked for are in the lead's local build/agent/lead-backlog.md.
    • First run on the real split after the 2026-10-02 changes (research/ue2-decomps.md, section 3): build the family index (clusters.py list, which also shows how many open functions are in families), clusters.py stamp --dry-run then stamp on the families that already have an accepted member, then a smoke run (t3-swarm with smoke: true) and the guard check. Then compare the first batch's sweep.py numbers (matches by attempt number, priced tokens per match) with the swarms above, and tune --patience and the bands from them.
    • Unreal-specific naming that unblocks many functions at once (research/ue2-decomps.md, sections 1 and 4): the class registration functions are Epic's static-link macros (GetPrivateStaticClass<C>, InitializePrivateStaticClass<C>, InternalConstructor): check one getter's decorated name against the exe, then name all of classes.txt's and add DECLARE_CLASS and IMPLEMENT_CLASS to Core.h; confirm UObject's 16 unknown virtuals against Republic Commando's and UT2004's order; dump the script natives from the running game with the SDK.
    • Native class registration is matched (engine.md, "Native class registration"): Core.h's DECLARE_CLASS and the generated headers declare every class's StaticClass(), and classreg.py matched 271 of the 272 getters and initializers of Ion Storm's classes. Left: 21 whose addresses categories.txt gives Epic or nobody (apply the backlog's rule that a registration belongs to its class, then integrate them), UBitfieldEnum's initializer (needs GetInitialized defined in its unit) and the report's pairing of 0x10961960 (its static constructor is inside a mis-split function). Workers can now write any inlined StaticClass() (worker.md).
    • UObject's vtable: workers build classes on Core.h's UObject (IsA, ConditionalDestroy, Cast<T>, the destructor in slot 2), and a function that stores a vtable has every slot UObject declares compared. vtables.py named each native class's slot functions and destructor after the class that introduces them, and the generated class headers declare those overrides: a source that includes them stores the exe's vtables (all but two DECLARE_CLASS destructors are matched). Next: about 70 name-blocked deferrals still declare their classes by hand with placeholder chains (Class_10B7C000 for UObject); rewrite them against the generated headers, and their constructors too. Classes outside classes.txt (placeholders named by vtable) still need their slots named the same way. vtables.py also names each native class's own vtable ??_7C@@6B@, which objdiff's report pairs by name.
    • Library code in src/: 29 STL members integrated by workers who did not recognize them were found and taken out (basic_string::assign, hash_map's constructor, list and vector internals: destructors, clear, _Buynode, _Tidy, _Ufill, node destructors). Two checks find them: the families of excluded library functions, and the functions excluded library code calls on the container or a node (an element's destructor, called for each element, is the game's). Run both over every library exclusion at each checkpoint, not only the new ones: seven std::list members that workers, a stamp and retries had matched as plain code stayed in src/ until the scan covered the older exclusions.
    • Naming passes (agent-workflow.md, "Name conflicts"): a blocked function's candidate names its callee, and the old guess stays as an alias (type:alias) so callers in src/ keep matching. Passes so far unblocked about 230 functions; the latest also take the names unwind code needs (an unwind funclet's callee is a destructor) and, as aliases, the names a vtable slot needs when the linker folded one function into several tables. Run tools/agent/retry.py after any naming pass.
    • Known debt: about 130 accepted constructors are written as methods that store their vtable by hand (symbols.txt named them ?FUN_x@C@@QAEPAV1@XZ before anything showed they were constructors); a pass like dtors.py should make them real constructors. Native classes' InternalConstructors are written as Unreal does (new ((EInternal*)X) T()), and their constructors carry ??0T@@QAE@XZ.
    • After each batch's integration, run tools/agent/dtors.py: it plans the deleting destructors of every class whose vtable src/ emits (19 so far; about 600 are excluded until their class's constructor or destructor is matched). Left by hand: classes whose deleting destructor inlines the destructor (it needs the destructor's definition in the same unit) and classes matched with a non-virtual destructor. dtors.py native does native classes, whose deleting destructors call UObject::operator delete with the size (Core.h): 108 are in src/. Left: 11 classes whose destructor is more than ConditionalDestroy() (members to destroy, AGarrett, APlayerPawn) and about 40 accepted functions that declare native classes by hand and clash with the generated headers in every unit (rewrite them).
    • An implicit constructor or destructor is accepted only with the game's function that emits it (--with): one made-up constructor was caught at review (0x10AB0180, excluded until 0x10AB0050 matches).
    • One address can carry several names (type:alias in symbols.txt, tools/cc.py): give a folded function's other callers' names as aliases instead of rewriting them.
    • Use the generated class headers in matching: units still declare their own classes (Virtual0()... placeholders, Unknown34 fields); a shared header per class needs the virtual functions too, which the scripts do not give (only their names, for script events). gruntz paid over a thousand cleanup commits and a crash for placeholder classes: move to one header per class with virtuals from the slot map (inherited, override, new; the packet's base-slot line shows which), a count of placeholders that may only fall, and a whole-project regression check on every header edit.
    • symbols.txt: retype the remaining globals recorded as int or void* that hold objects (DAT_10f31b88, DAT_10ff66ac, DAT_10ff708c, DAT_10ff7098). Headers for TimeManager and Window (matches are waiting on one declaration of each).
    • Shrink the unclassified 0.3 MB (tools/classify.py stats): most of it sits where Epic's and Ion Storm's files meet (the launcher, the ends of the Engine and Core packages, Window and Havok). More evidence (strings of stock UE2 files, non-UObject vtables) moves it to a side; the 117 functions taken out of src/ as unclassified can come back once it is game code.
    • objdiff's report counts what the gate matched, but for 83 of the 5,454 functions in src/: a static local's guard and $E destructor stub keep names only their object file knows, one global is read through the second half of an 8-byte symbol, 0x10C68010 starts inside FUN_10c67f90 in symbols.txt, and some constructors and destructors score 95% to 99.8% (about 20 of the destructors that share a folded EH handler stub). A switch's tables no longer do: the labels are folded on both sides (tools/cc.py, tools/split.py). integrate.py names the vtables functions store (??_7), and tools/split.py relocates fs:[0]. Both name each function's exception tables after it, so its handler stub and funclets pair.
    • Review every accepted file before integrating: workers stand in for what the header lacks (local types, Shim subclasses to reach undeclared members, DAT_ slices of tables); add the real declarations to include/, redo those functions and accept them again. integrate.py skips library code and Epic's classes (classes.txt; an Unreal-style name it does not list counts as Epic's).
    • Still unchecked on the real split: a switch table and the data ruler on float literals (matching.md). Pin the compiler flags with varied functions (/G6 vs /G7, /GS) before large waves outside the natives.
  4. SDK generator (roadmap 2 below): the generated class headers give every native class's members, offsets checked against the exe; the runtime generator would add script functions and confirm the offsets in the running game.
  5. Check the HUD in a level on a wide screen (alt-tab and clicks on other monitors are confirmed).
  6. SDK options in the game's settings (user request). The launcher's SDK settings page covers it outside the game; in-game leads: the options names table 0x10E6ED70, the A/V row refresh 0x10B72DD0, T3UI.ini (TitleOptionsWindow, AVOptionsWindow, UniqueOptionsWindow), and UILayoutTrace.
  7. 3D field of view for widescreen (Hor+): not found yet. The [WindowManager] FOV is the UI camera's; 0x10A39230 is a camera-overlay FOV. The community's T3FovPatch.exe binary patch has not been reverse engineered.
  8. Shutdown crash at 0x1098A466 (vanilla bug): fix it, so the game exits cleanly.
  9. High-DPI displays: the game is DPI-unaware, so on a monitor scaled above 100% Windows stretches the borderless window (blurry), and the curtain helper works in the same scaled coordinates. Making both DPI-aware needs a tester with a scaled display.
  10. Map editor (see docs/assets.md, sections 6 and 8):
    • skins and other struct values in t3pack.py; adding (copies) and removing actors works in the tools and the plugin, but no patched map has been loaded in the game yet: check that one with new and removed actors loads, and that a copy moved far from its original is lit and drawn (it keeps the original's zone and BSP leaf);
    • some materials show a noise texture as their colour: the exporter's choice of texture stage needs a look;
    • characters, animation, physics hulls, particles and sounds are not exported yet.
  11. Name and document as we go: every function the SDK touches gets its name in symbols.txt and an entry in docs/engine.md.

Roadmap towards multiplayer ​

  1. Stabilise M1: done (lifecycle gating, SEH around mod callbacks, clean exit parity with vanilla).
  2. SDK generator: walk each UStruct's Children (UField::Next) and UProperty fields (Offset, ElementSize, ArrayDim, PropertyFlags). Detect their offsets at runtime the way SuperField is detected. Emit C++ headers for classes, structs, enums and functions, and an API call that dumps them.
  3. Script events: find UObject::ProcessEvent (the eventXxx thunks call FindFunctionChecked then ProcessEvent). Hook it for pre/post callbacks per function, and add CallFunction for mods and a console-command hook.
  4. World access: ULevel::SpawnActor (anchored by its error strings), GEngine and the current level, the player pawn and its transforms, destroying actors.
  5. Overlay and input: the device is already hooked (display.cpp); add Present/EndScene for an in-game UI. Sneaky Upgrade users run a d3d8 wrapper, so keep hooking through the import, not by replacing d3d8.dll.
  6. Multiplayer prototype as a mod: a UDP transport (for example ENet), local player transform sync, puppet pawns for remote players (look at PlayerPawnPuppet), then world events (doors, items, AI state). There is no engine net layer to reuse.

Useful facts ​

  • The MSVC 7.1 bundle, wibo and objdiff-cli run in a Linux cloud container (tools/download_tool.py), so matching can run in the cloud too, given the exe from a private source.

  • objdiff's relocation rulers cannot tell MSVC COMDAT constants apart (every one sits at offset 0), and report generate ignores callees unless functionRelocDiffs is pinned (it now is). accept.py resolves both sides to addresses instead (matching.md).

  • MSVC 7.1 names unwind funclets $Lnnn, not __unwindfunclet$..., and /O2 inlines same-file functions even when defined after the caller.

  • T3SDK_BUILD_DIR moves every tool's output (the launcher sets it for its bundled tools: %LOCALAPPDATA%\org.t3sdk.launcher\build).

  • Godot 4.7.2 runs headless for the plugin and viewer tests: godot_check.py --editor-selftest, --viewer.

  • Decompiled outputs from this work are in build/re_*.c (regenerate with Decompile.java).

  • Other mods: Sneaky Upgrade uses d3d8.dll (d3d8to9, dgVoodoo2, DXVK), so dinput8.dll is free for our loader.

  • tools/sdk.py run starts the game through Steam (steam.exe -applaunch 6980), which runs runme.exe → t3.exe → T3Main.exe. The SDK loads into T3Main.exe only.

  • The main menu plays the attract-mode intro after 30 s without input (Attract_Mode_Intro_Timeout); a click skips it.

  • clangd uses .clangd → build/sdk/compile_commands.json with --target=i686-pc-windows-msvc. Without a build it shows false 64-bit errors.

A fan project, not affiliated with or endorsed by Ion Storm, Eidos or the owners of the Thief series. Buy the game.