35. Divergence Integration
Lessons 31, 32, and 33–34 were developed from separate bases. Each lesson touched largely different systems, so the videos and concepts still hold. But the code diverged, and so I needed to combine 3 separate histories back together.
This lesson walks through the integration process: what broke, how to fix diverging branches, and how to track down a memory corruption bug using a debugger.
Diverging Branches
There are three branches, each built from a slightly different commit. This happened because I was working on separate systems concurrently and not rebasing between lessons.
This happens in real projects, especially solo. It's a pain, but not a disaster.
The question is which merge strategy to use. In this case, cherry-pick wins over merge or rebase because we want specific commits from each branch, not the full history. Walk through each branch, identify the commits that matter, and cherry-pick them onto a single integration branch.
Once the cherry-picks are done, you'll have one commit that combines everything from the three branches into a working base. From here, the compiler will tell you what's broken.
Code Changes
battle_start Signature Change
The old signature was doing too much:
battle_start :: proc(
bs: ^Battle_State,
scene_def: Scene_Definition,
return_scene: ^Scene_State,
enemy_entity_def_ids: [4]Maybe(int),
) -> ^Scene_State {
We've been moving towards pulling state from context.user_ptr, so flatten this down:
battle_start :: proc(enemy_entity_def_ids: [4]Maybe(int)) {
battle_start now gets gs and bs from context.user_ptr internally, and sets gs.scene directly rather than returning a ^Scene_State. The call site becomes trivial - just pass the enemy def IDs.
Battle Scene Created via Duplication
Create battle_flat.glb (duplicate flat.glb for now) and an empty battle_flat.json as no spawners needed for a battle scene.
Then, update gs.battle_scene_key from "Battle (Town)" to "battle_flat".
Remove Scene Definition Check
The old call site wrapped the battle start in:
if scene_def, ok := gs.scenes[gs.battle_scene_key]; ok {
// ...
}
Scene definitions were removed in an earlier lesson, so this check is dead code. Replace it with a direct call to battle_start.
Create Player Outside of scene_switch
Previously, player_init was called inside scene_switch. This coupled scene loading to player creation.. That's a problem, because battle scenes don't have a player entity. scene_switch shouldn't assume one exists.
Move the player_init call out to each call site that needs it, after scene_switch returns:
scene_switch(&gs.scene_arena, &gs.scene, "flat")
gs.scene.player_handle = player_init(gs, gs.scene)
Battle scenes skip this entirely.
scene_switch Signature: dest: ^^Scene_State
Previously scene_switch wrote directly to gs.scene internally. Now it takes dest: ^^Scene_State so it can target any scene pointer - the field scene, the battle scene, whatever you need:
scene_switch :: proc(arena: ^mem.Dynamic_Arena, dest: ^^Scene_State, scene_name: string) {
This change may already exist depending on which branch you followed. If it does, move on.
Fix Allocator in scene_switch
This one's subtle. The Scene_State struct was allocated in the arena, but its dynamic arrays were allocated on the default heap:
// Before (default allocator):
ss.entities = make(type_of(ss.entities))
ss.generations = make(type_of(ss.generations))
On dynamic_arena_reset, the struct is wiped but the arrays are leaked.
Fix: pass the arena allocator to every make call inside scene_switch:
// After:
ss.entities = make(type_of(ss.entities), allocator)
ss.generations = make(type_of(ss.generations), allocator)
Do this for every make call in the procedure. If it's allocated inside scene_switch, it should use the arena allocator.
The Memory Bug
With all the integration changes applied, the game compiles and the field scene works.
However, starting a battle crashes the game.
Symptom
entity_get returns nil for a handle that was just created.
The entity and generation arrays are out of sync - which should be impossible, since entity_create always appends to both.
Investigation
The first new(Battle_Menu_Item, allocator) call in battle_construct_menus is zeroing bs.battle_scene fields.
That makes no sense on the surface. A new call allocating a menu item shouldn't touch the scene state at all. Unless the memory regions overlap.
Root Cause
In battle_start, the original code did this:
// 1. Get arena allocator
allocator := mem.dynamic_arena_allocator(&bs.arena)
// 2. Allocate menu stack buffer FROM the arena
mem.stack_init(&bs.menu_stack, make([]byte, 4096, allocator))
// 3. scene_switch resets the arena - menu buffer is now invalid
bs.battle_scene = new(Scene_State)
scene_switch(&bs.arena, &bs.battle_scene, gs.battle_scene_key)
The menu stack's 4096-byte backing buffer was allocated from bs.arena. Then scene_switch called dynamic_arena_reset, which freed everything in the arena. The arena reused that same memory region for the new Scene_State, its entities, and its generations.
The menu stack still held a pointer to the old (now-reused) memory. When battle_construct_menus wrote a Battle_Menu_Item into the menu stack, it overwrote the bytes that now belonged to bs.battle_scene.
The Pattern
This is a general arena bug pattern worth memorising:
- Allocate object A from an arena
- Arena resets (or grows and reallocates)
- Allocate object B from the same arena - B may occupy A's old memory
- Write through a stale pointer to A → corrupts B
Any time two allocations share an arena but have different lifetimes, this can happen. The symptom ("this new call is zeroing my struct") rarely points at the actual cause (overlapping arena regions).
This is why we use a debugger rather than solely relying on print statements. We can see exactly when the memory becomes something unexpected and diagnose from there, rather than when something breaks, which may be much later.
Fix
Allocate the menu stack buffer after scene_switch, so it gets a fresh region that doesn't overlap with the scene data:
// scene_switch wipes the arena
bs.battle_scene = new(Scene_State)
scene_switch(&bs.arena, &bs.battle_scene, gs.battle_scene_key)
// Now allocate from the arena (after the reset)
allocator := mem.dynamic_arena_allocator(&bs.arena)
mem.stack_init(&bs.menu_stack, make([]byte, 4096, allocator))
Order matters. The arena reset happens inside scene_switch. Everything allocated before that reset is gone. Everything allocated after it is safe - until the next reset.
The menu stack buffer still lives inside the battle arena. If anything ever calls
scene_switchonbs.arenaagain mid-battle, the overlap returns.Consider using (and reusing) a permanent allocation separate from the scene arena's lifetime.
Summary
Diverging branches happen. Cherry-pick is a clean way to integrate when you want specific commits rather than dragging in entire branch histories.
When refactoring call signatures and moving responsibilities (like player_init out of scene_switch), update every call site.
Always pass the correct allocator to make. Mismatched lifetimes between a struct and its dynamic array fields cause leaks or corruption.
Arena memory bugs are silent until they aren't. The symptom rarely points at the actual cause. A debugger is how you find them.