Back to blog
2025-01-2218 min

Reverse Engineering Co-Op Into a Game That Never Had It

reverse-engineeringCMinHookgame-moddingGhidra

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:

  1. Call orig_ProcessEndOfGame() normally (writes P1 stats through the real pointer)
  2. Swap the global pointer to P2's shadow struct
  3. Call orig_ProcessEndOfGame() again (now all the internal functions read P2's data)
  4. 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:

  1. Detect when the confirmation dialog fires (hash 0xc3a42254)
  2. Let P1 confirm normally
  3. Intercept the creation callback (0x140888ed0)
  4. 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 0

One 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 STORYSCENE struct 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.