Reverse Engineering Co-Op Into a Game That Never Had It
NBA 2K16's MyCareer mode is single-player. One created player, one controller, one career save. The game has local multiplayer for exhibition and blacktop modes, but career mode is locked to a single player by design. Every system in the game (career saves, stat tracking, entity rendering, controller routing) assumes exactly one career player exists.
I wanted two players in career mode. Same team, same games, same progression. The game doesn't support this. There's no hidden setting. There's no config file. The architecture fundamentally assumes one player at every layer of the stack.
Making it work required reverse engineering the career save system, the entity renderer, the input pipeline, and the cutscene engine. The final fix for controller routing turned out to be writing a single integer to a specific memory offset. Finding which integer and which offset took weeks.
The Toolchain
The mod is a DLL proxy. On Windows, when a game loads dinput8.dll (DirectInput 8, the controller API), Windows looks in the game directory first. Drop a custom dinput8.dll there and the game loads your code before its own. Your DLL forwards legitimate DirectInput calls to the real system DLL and also runs your hooks.
The hooking framework is MinHook, which does trampoline-based function patching at runtime. It saves the original function's prologue, replaces it with a jump to your hook function, and gives you a pointer to call the original. The HDE (Hacker's Disassembler Engine) inside MinHook decodes x86_64 instructions to figure out how many bytes to save.
I do all the RE work in Ghidra, cross-compile on Linux with MinGW, and the game runs under Proton. No ASLR on Proton, so the image base is always 0x140000000. Every address in this post is an RVA from that base.
// Typical hook pattern
typedef void (*pfn_Original)(uint64_t, uint64_t);
static pfn_Original orig_ProcessEndOfGame = NULL;
static void hook_ProcessEndOfGame(uint64_t p1, uint64_t p2) {
orig_ProcessEndOfGame(p1, p2); // P1 stats
SwapToP2();
orig_ProcessEndOfGame(p1, p2); // P2 stats via swapped pointer
RestoreToP1();
}The Career Save Problem
The career save is a 256KB struct at a global pointer (DAT_145831608). Every career function reads this pointer to find the active player:
// Decompiled from Ghidra
void* GetCareerPlayerData() {
ushort idx = *(ushort*)GetCareerSaveData(); // first 2 bytes = player index
return GetPlayerDataByIndex(idx);
}The entire game assumes ONE career player identified by a single ushort at offset 0x00 of the save struct. There's no array of career players. There's no per-controller career concept. Every function in the post-game pipeline (stat accumulation, coach satisfaction updates, draft projection changes) chains back to this one global pointer.
The fix is a pointer swap. I allocate a second 256KB buffer (the "shadow struct") for P2's career data. When the post-game pipeline runs:
- Call
orig_ProcessEndOfGame()normally (writes P1 stats through the real pointer) - Swap the global pointer to P2's shadow struct
- Call
orig_ProcessEndOfGame()again (now all the internal functions read P2's data) - Swap back
static void SwapToP2(void) {
if (g_savedP1Data) return; // prevent double-swap corruption
g_savedP1Data = *(BYTE**)ADDR(0x5831608);
*(BYTE**)ADDR(0x5831608) = g_player2CareerData;
}This works because every function in the post-game path reads the global pointer fresh each time. None of them cache it. I verified this by tracing every call from CareerMode_ProcessEndOfGame (0x140118dc0) through BoxScore_GetStatValue (0x1401120b0), PostGame_AccumulateCareerStats (0x1404979b0), and PostGame_FinalizeCareerState (0x140113bd0). All of them call GetCareerSaveData() internally.
Player Creation
Creating a second player required intercepting the dialog system. After P1 finishes the character editor, the game shows a confirmation dialog ("Begin MyCAREER using these settings?"). I hook StandardDialog::Popup (0x140133c60) and the string lookup function (0x140f83e50, which maps hash IDs to strings) to:
- Detect when the confirmation dialog fires (hash
0xc3a42254) - Let P1 confirm normally
- Intercept the creation callback (
0x140888ed0) - Open the editor again for P2
One thing that cost me a day: dialog results aren't 0/1 for No/Yes. They're 2 for Yes and 3 for No. I was checking for result 1 and wondering why nothing happened.
The MyPlayer array uses a stride of 0x688 per slot. A roster ID of -1 at offset +0xC4 means the slot is empty. P2 gets the next empty slot.
Entity Rendering
Getting P2's 3D model to appear was the second major system to crack. The game's entity system uses a linked list per team. Each entity is a 0x1570-byte object:
Entity (0x1570 bytes):
+0x00 vtable (0x142a140e8)
+0x50 position (vec3: X, Y=0, Z)
+0xC4 rosterID (short)
+0x578 controller sub-object
+0x5B0 next entity (linked list)
+0x6D8 appearance pointer
+0x690 player number (1-based)
I hook TeamCopy (0x1404d0230), which runs once when a scene loads to populate the team's entity list. After the original function creates P1's entity, my hook allocates a second entity for P2, copies the appearance data, sets the roster ID, and links it into the list. I also hook GetRenderCounts (0x140da7870) to bump the entity count so the render dispatcher iterates over both entities.
P2's model shows up in the gym. Correct appearance, correct position. Two players standing on the court.
But P2 doesn't move.
The Controller Routing Problem
This is where it got hard. The game's input system has multiple layers:
InputSnapshot (60fps heartbeat, 0x1401d22f0)
-> RawControllerDispatch (0x1401d3e20)
-> PerFrameSlotProcessor (0x1401d2fc0, iterates 10 slots)
-> PerEventRouter (0x1401d1cd0)
The boot initialization function (0x1401d2870) already sets the game to local-MP mode (DAT_144309a00 = 1) and the dispatch chain iterates all 10 controller slots. The infrastructure for multiple controllers exists. The problem is that only player 0 is marked active.
I tried everything:
Attempt 1: Hook IsCareerPlayer to return TRUE for P2. Didn't matter. The function isn't on the per-frame input path.
Attempt 2: Write to the controller slot array manually. The array (0x144309a18, 10 slots at 0x268 each) is populated during game modes but empty during gym scenes. Writes get ignored.
Attempt 3: Use the blacktop controller table. The blacktop mode has its own table at 0x14382da28. The gym ignores it completely (max=0).
Attempt 4: Activate the lockstep input system. The game has a deterministic input distribution system (string ref "OFFLINE LOCKSTEP") designed for networked play. Activating it in the gym crashes because the dispatch function pointer (0x14434cba8) is NULL.
Attempt 5: Hook the active controller getter. The function at 0x1401b9460 returns the active controller index from identity + 0x33e0. But the gym inlines this read directly in assembly. The function is only called once per session, not per frame. Hooking it changes nothing during gameplay.
Attempt 6: Direct position writes. Writing to entity+0x50 (the position vec3) does move the model, but without animation. The character teleports and T-poses. Movement needs to go through the character controller system, not raw position writes.
Each of these attempts took 4-8 hours of Ghidra analysis, hypothesis formation, implementation, testing, and failure documentation.
The Fix
The breakthrough came from tracing the entity registration path. The function at 0x140413110 reads a specific field when registering entities with the character controller system:
entity + 0x578 -> controller sub-object
sub-object + 0x08 -> controller slot index
The character controller table at 0x1457cf7b0 has 10 entries at 0x98 bytes each. When an entity's slot index is set to a valid value (0-9), the game natively routes the corresponding physical controller to that entity.
P1's entity had *(entity+0x578)+8 = 1 (slot 1, connected to controller 1). P2's entity had *(entity+0x578)+8 = -1 (unregistered). That -1 was the entire reason P2 couldn't move.
The fix:
*(int*)(p2Entity + 0x578 + 0x08) = 0; // Register P2 at slot 0One line. One integer. After weeks of tracing input pipelines, lockstep systems, and controller dispatch chains, the fix was setting a slot index on P2's entity sub-object.
Important detail: the slots must not collide. When I first tested with both players on slot 1, P1 lost control. P1=slot 1, P2=slot 0. Both controllers independently control their characters. Movement, dribbling, animations, everything works through the game's native character controller system.
The Gym Isn't a Game Mode
This was the most misleading discovery. I spent days looking for gym-specific controller setup code, assuming the gym was a game mode like blacktop or exhibition. It's not.
The gym is a StudioEnvironment scene, part of the story/cutscene system. It doesn't use the basketball gameplay tick (0x140b6f630). It doesn't use the game slot array. It doesn't call MultiplayerInit. It's a story scene that happens to have basketball physics running inside it.
This means the input system research for basketball game modes (exhibition, blacktop) was largely irrelevant. The gym has its own per-frame tick:
ControllerScan (0x140a731b0)
-> GymMainTick (0x140a72cc0)
-> EntityAnimCamera (0x140a4ffe0)
-> GameplayContext (0x140c705f0)
-> BasketballMovement (0x140b37300)
-> GymSpecificLogic (0x140a52650)
Once I understood the gym was a story scene, the entity registration approach became obvious. Story scenes populate entities differently than game modes, and the controller sub-object at +0x578 is the story system's way of binding controllers to characters.
Current State
Working:
- P2 player creation through the full editor flow
- Dual career stat tracking (separate 256KB save structs)
- P2 model rendering with correct appearance
- Controller routing (P1=slot 1, P2=slot 0, independently controllable)
In progress:
- Persistent controller binding across scene transitions
- P2 integration in actual games (exhibition confirmed working, career games need testing)
Deferred:
- Custom cutscene editor (the cutscene system is fully documented in 362 lines of RE notes, including the
STORYSCENEstruct layout, conversation engine, and performer system, but building an editor is a separate project)
What I'd Do Differently
Document the entity struct earlier. I had position writes working at +0x50 on day 2 but didn't map the full 0x1570-byte entity until day 5. If I'd found the controller sub-object at +0x578 earlier, I'd have saved 3 days of chasing the input dispatch system.
Stop assuming architectural consistency. I assumed the gym used the same controller binding as exhibition mode because they look similar to the player. They don't share any code. The gym is a story scene. Exhibition is a game mode. Different systems, different entry points, different controller registration paths.
The failed approaches were necessary. Every dead end (lockstep system, blacktop table, slot array, active controller getter) eliminated a hypothesis and narrowed the search space. The input system research wasn't wasted, it proved definitively that the fix wasn't in the input layer, which is what led me to look at entity registration instead.