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 (manifestbuild/sdk/deployed.json): deploy the current build before testing those.System/T3SDK.logis appended to on every run. - GitHub:
Veradictus/Thief3-Decomp, Conventional Commits (see CONTRIBUTING.md). Agents commit onmainand never push: the user pushes. Commits and PRs carry no session links. - The Ghidra database in
ghidra/is analysed and has the names fromsymbols.txtapplied (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 --allreports all 55 packages identical, andapplyworks 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, whichyarn tauriselects 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 bytools/classify.pyfrom 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, andprogress_report.py checkenforces it. Epic's engine code (theUObjectnatives) was removed fromsrc/, and with it 414 matched functions the classifier does not call game code (244 Epic's, 53 library, 117 unclassified);include/Core/Core.hkeeps 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 protocoltools/agent/worker.md.src/Gameholds 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 reportprogress/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 asClass_<vtable>carry their names (AGarrett,AT3PlayerController,UT3GameEngine, ...).tools/assets/t3classes.pylays 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 ininclude/<Package>/<Package>Classes.h(engine.md, "Class layouts"), and context packets show a function's class from them.
What exists
| Piece | Where | Status |
|---|---|---|
Loader: dinput8.dll proxy, entry-point patch, MinHook | sdk/loader/dllmain.cpp | works |
| Start-up, frame/exit hooks, settings, guarded mod callbacks | sdk/loader/sdk.cpp | works |
Engine access, lifecycle (Ready/Exiting), layout validation, log hook | sdk/loader/engine.cpp | works |
Mod loading: System/mods/load-order.txt (packages), then System/mods/*.dll | sdk/loader/mods.cpp | loose 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.cpp | works |
| Fixes: skip intro movies | sdk/loader/fixes.cpp | works |
| Display: native resolutions, borderless window (cursor, VSync, focus, running in the background), frame pacing, widescreen UI | sdk/loader/display.cpp | works (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.cpp | a black version worked; the loading-screen version is not yet tried in the game |
| Main-menu version label, menu input diagnostics | sdk/loader/menu.cpp | works |
Public C API (T3SdkApi v1), C++ engine header | sdk/include/t3sdk/ | |
| Example mod | sdk/mods/hello/hello.cpp | works |
| Build/deploy/run/screenshot/click/keys/close tool | tools/sdk.py | works |
| Ghidra scripts: Decompile, Disassemble, ImportNames, ExportSymbols | tools/ghidra/ | work |
| Map and asset export to Godot 4.7; Godot map viewer | tools/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.json | tools/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 backup | tools/assets/upkgwrite.py, t3pack.py | round 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, --selfcheck | tools/assets/ibtwrite.py, t3texpack.py | synthetic tests pass; not run on real bundles |
Launcher (Tauri): setup, play, SDK install, mods, T3SDK.ini, Map Studio, task queue | launcher/, launcher.md | runs 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 browser | launcher/src-tauri/src/mods.rs, launcher/src/pages/Mods.svelte, mods.md | Rust 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.py | builds in CI |
| Matching harness: queue, context, try, strict gate, integrate, waves | tools/agent/, .claude/agents/t3-matcher.md, .claude/skills/t3-match/, matching.md | synthetic 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.md | synthetic 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/0x10E6EDD8gets the monitor's own modes, native last (option index 4). - Borderless:
Direct3DCreate8is hooked through the import;CreateDeviceandResetget windowed presentation parameters and the window becomes a popup covering its monitor. The focus-lossReset(WM_ACTIVATEAPP, call at0x10C8BF6B, parameters0x10F2C86C) is skipped. It used to fail and freeze the game (inUD3DRenderDevice::Lock) on alt-tab, on a click on another monitor, and on exit. - Widescreen UI:
[WindowManager] AssumedUIScreenWidthis answered as480 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=1logs every window's placement. Two exceptions: windows flush against the left or right edge (Pos_X0, 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::ViewportWndProcis hooked; after itsWM_ACTIVATEAPP(FALSE)handling the SDK setsGIsAppActiveagain and restores the saved pause state (PauseInBackground=1keeps the game's own pause). - Cursor: the device's cursor calls and user32's
ShowCursor/SetCursorare hooked; the game's cursor image becomes one scaled Windows cursor, rebuilt only when the image changes. - Level changes:
ShellExecuteExA(the call that startsIon Launcher.exeinRelaunchForLevelChange) raises the curtain: the outgoing game copies its monitor, which shows the next level's loading screen, into a section arundll32helper inherits and shows topmost. The incoming game'sLoadingScreen::Beginhook 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:
SmoothFramespatches the TimeManager constructor's minimum step from 10 ms to 1 ms (see engine.md, Clock). With VSynch on, the windowed device usesCOPY_VSYNC.MaxFPSwaits beforePresent(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_ACTIVATEAPP0 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 at0x1098A466(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,Present0.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
- 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=1should 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=0turns 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 fenceStaticMeshActor__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 --selfcheckmust 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 (alistname used in a small level),checkandapplyit, load the level and look at the texture, andrestore. 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.txtcode has only been syntax-checked with clang), deploy it, then from the launcher install a code package built fromtemplates/mod/and a second one that needs it.T3SDK.logmust 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; aload-order.txtline with..must be skipped with a log line. Then a content overlay: afiles/mod that replaces a loading screen (Content/T3/Bitmaps) must show in the game, the original must sit inSystem/mods/originals/, and switching the mod off must put it back. With a texture pack on, afiles/mod that places an.ibtmust queue restore, place the bundle, then apply.
- Deploy the current SDK. New Game should go from the menu straight to Inn's loading screen with no black gap.
- decomp.dev: CI publishes
progress/PC_20040610/report.json, whichpython tools/progress_report.py writemakes from a local build (the exe stays local) and CI checks againstsrc/(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). - Matching (game code only):
- Matching runs as a swarm (agent-workflow.md), now best as the
t3-swarmworkflow: 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 andretry.pycheck 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 functionsintegrate.pycannot 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 localbuild/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-runthenstampon the families that already have an accepted member, then a smoke run (t3-swarmwithsmoke: true) and the guard check. Then compare the first batch'ssweep.pynumbers (matches by attempt number, priced tokens per match) with the swarms above, and tune--patienceand 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 ofclasses.txt's and addDECLARE_CLASSandIMPLEMENT_CLASStoCore.h; confirmUObject'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'sDECLARE_CLASSand the generated headers declare every class'sStaticClass(), andclassreg.pymatched 271 of the 272 getters and initializers of Ion Storm's classes. Left: 21 whose addressescategories.txtgives Epic or nobody (apply the backlog's rule that a registration belongs to its class, then integrate them),UBitfieldEnum's initializer (needsGetInitializeddefined in its unit) and the report's pairing of0x10961960(its static constructor is inside a mis-split function). Workers can now write any inlinedStaticClass()(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 slotUObjectdeclares compared.vtables.pynamed 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 twoDECLARE_CLASSdestructors are matched). Next: about 70 name-blocked deferrals still declare their classes by hand with placeholder chains (Class_10B7C000forUObject); rewrite them against the generated headers, and their constructors too. Classes outsideclasses.txt(placeholders named by vtable) still need their slots named the same way.vtables.pyalso 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,listandvectorinternals: 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: sevenstd::listmembers that workers, a stamp and retries had matched as plain code stayed insrc/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 insrc/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. Runtools/agent/retry.pyafter 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@XZbefore anything showed they were constructors); a pass likedtors.pyshould 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 vtablesrc/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 nativedoes native classes, whose deleting destructors callUObject::operator deletewith the size (Core.h): 108 are insrc/. Left: 11 classes whose destructor is more thanConditionalDestroy()(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 until0x10AB0050matches). - One address can carry several names (
type:aliasin 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,Unknown34fields); 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
intorvoid*that hold objects (DAT_10f31b88,DAT_10ff66ac,DAT_10ff708c,DAT_10ff7098). Headers forTimeManagerandWindow(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 ofsrc/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$Edestructor stub keep names only their object file knows, one global is read through the second half of an 8-byte symbol,0x10C68010starts insideFUN_10c67f90insymbols.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), andtools/split.pyrelocatesfs:[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,
Shimsubclasses to reach undeclared members,DAT_slices of tables); add the real declarations toinclude/, redo those functions and accept them again.integrate.pyskips 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 (
/G6vs/G7,/GS) before large waves outside the natives.
- Matching runs as a swarm (agent-workflow.md), now best as the
- 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.
- Check the HUD in a level on a wide screen (alt-tab and clicks on other monitors are confirmed).
- 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 refresh0x10B72DD0,T3UI.ini(TitleOptionsWindow,AVOptionsWindow,UniqueOptionsWindow), andUILayoutTrace. - 3D field of view for widescreen (Hor+): not found yet. The
[WindowManager]FOV is the UI camera's;0x10A39230is a camera-overlay FOV. The community'sT3FovPatch.exebinary patch has not been reverse engineered. - Shutdown crash at
0x1098A466(vanilla bug): fix it, so the game exits cleanly. - 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.
- 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.
- skins and other struct values in
- Name and document as we go: every function the SDK touches gets its name in
symbols.txtand an entry indocs/engine.md.
Roadmap towards multiplayer
Stabilise M1: done (lifecycle gating, SEH around mod callbacks, clean exit parity with vanilla).- SDK generator: walk each
UStruct's Children (UField::Next) andUPropertyfields (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. - Script events: find
UObject::ProcessEvent(theeventXxxthunks callFindFunctionCheckedthen ProcessEvent). Hook it for pre/post callbacks per function, and addCallFunctionfor mods and a console-command hook. - World access:
ULevel::SpawnActor(anchored by its error strings),GEngineand the current level, the player pawn and its transforms, destroying actors. - 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 replacingd3d8.dll. - 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 generateignores callees unlessfunctionRelocDiffsis pinned (it now is).accept.pyresolves both sides to addresses instead (matching.md).MSVC 7.1 names unwind funclets
$Lnnn, not__unwindfunclet$..., and/O2inlines same-file functions even when defined after the caller.T3SDK_BUILD_DIRmoves 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 withDecompile.java).Other mods: Sneaky Upgrade uses
d3d8.dll(d3d8to9, dgVoodoo2, DXVK), sodinput8.dllis free for our loader.tools/sdk.py runstarts the game through Steam (steam.exe -applaunch 6980), which runsrunme.exe→t3.exe→T3Main.exe. The SDK loads intoT3Main.exeonly.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.jsonwith--target=i686-pc-windows-msvc. Without a build it shows false 64-bit errors.