Skip to content
Damian Small

Adding Extra Checkpoints to Halo CE's Race Gametype with SAPP

The goal

Move a Bloodgulch race's existing checkpoints to different positions on the map.

Then add more checkpoints than the map actually has.

Make all of them genuinely passable, not just decorative.

Do it entirely server-side, through a SAPP Lua script.

Don't corrupt the map, and don't crash the server.

I didn't expect those five things to fight each other quite as hard as they did.

The relocation part worked in an afternoon. Then the question became whether we could just add more checkpoints on top. That's when it stopped being a small favour.

Where the checkpoints actually live

Halo's Race gametype checkpoints are netgame flags baked into the map's scenario tag, plus a runtime array the engine populates when a round starts. The relocation script already walked both: read the scenario tag's netgame flags, filter for the ones flagged as race checkpoints, and match them against the runtime array with a nearest-neighbour heuristic, since the two don't come in the same order.

That last part should have been a warning sign. If you need a distance-matching heuristic just to line two lists back up, the "real" order isn't as simple as it looks. I filed that thought away and moved on, which turned out to be a mistake I'd come back to later.

The client already knows too much

Before touching anything, it's worth mentioning a wrinkle the original script's own comments already admitted: relocating a checkpoint's coordinates in server memory doesn't make the client see the new position. The client parses its own copy of the map file independently. Server memory and client memory are just two separate processes agreeing to disagree.

That fact turned out to matter a lot less than I expected, and a lot more than I initially assumed, in that order.

Attempt one: just write more entries

The obvious first move was to relax the cap. The relocation script capped the checkpoint count at the smaller of three numbers: how many named waypoints existed, how many race-type flags the scenario tag had, and how many the engine's runtime array currently held. Drop that cap, and indices beyond the map's real count would just write into the runtime array anyway.

They did. Nothing crashed. The extra checkpoints even spawned their health-pack markers in the right spot.

They also didn't do anything else. No sound. No completion. Nothing the client or the server's own scoring logic seemed to care about.

A bitmask that lied to me

Somewhere in the engine there's a dword, found via a signature scan, whose value gets decoded through a hand-rolled popcount into "how many checkpoints this round has." Overwrite that dword directly, I reasoned, and the engine could just be told there were more checkpoints than it thought.

That actually worked, in the sense that the client's HUD (Chimera race_hud.lua) started reporting 8 checkpoints instead of 6. I was fairly pleased with myself for about ten minutes.

The extra checkpoints still weren't hittable.

So the mask controlled what the client displayed, and nothing else. It was a real discovery, just not the one I was looking for. I'd fixed a number on a scoreboard, not the game underneath it.

There must be a table somewhere

At this point the working theory was: the scenario flags and the runtime checkpoints must be linked by something outside their coordinates, since the two lists don't come in the same order to begin with. Find that link, and a new flag could be pointed at a free slot.

I went looking for it three separate times, and struck out three separate times.

First, every field of a genuine race flag's memory struct, dumped side by side with an unused one. Identical, byte for byte, except the type field and the position. No hidden ID.

Second, the exact addresses a lookup table would need to contain, searched across live process memory with scanmem, on the theory that if something stored pointers to these flags, those addresses should turn up somewhere else too. Zero matches — and a sanity check against a value known to exist came back with thirty-four thousand hits, so the search itself wasn't broken. The addresses just weren't stored anywhere.

Third, the unexplored byte range sitting between two fields that were already understood, on the theory that a small table might be hiding in the gap. All zeroes.

Three dead ends in a row is usually a sign you're asking the wrong question, not that you need a fourth attempt at the same one.

Reaching for Ghidra

The honest answer at that point was that I didn't know how the engine decided which scenario flag became which runtime checkpoint, and no amount of guessing from the outside was going to tell me. So the dedicated server executable went into Ghidra, looking for the function that actually builds the checkpoint list.

A search for the same byte pattern the Lua script's sig_scan already used landed on a function with exactly one caller in the entire binary. Its decompiled output did in about thirty lines what the Lua script had been approximating with nearest-neighbour distance matching all along:

There it was: a word field at offset +0x12, right next to the type field I'd already been reading, that the engine uses directly as both the mask bit position and the runtime array index. Not iteration order. Not proximity. A field I'd already read as part of a larger dword during the struct dump and dismissed as part of the type value, because I'd only ever looked at the low 16 bits.

The nearest-neighbour matching in the original script wasn't a clever workaround. It was a Lua reimplementation of a field that was there the whole time.

Promoting spare flags, properly this time

With the real field identified, the fix wasn't to write beyond the map's bounds at all. Every scenario tag has far more netgame flags than a Race gametype uses; a Bloodgulch race only needs 5 out of the 191 flags placed on the map. The other 186 are CTF stands, weapon spawns, and other gametype furniture sitting there unused.

Flip one of those flags' type field to the race type, and give it a checkpoint index the real flags aren't already using, and it becomes indistinguishable from a genuine race checkpoint. No array growth. No writing past anything.

Winning a race against the engine's own boot sequence

Except it still didn't work, and this time the reason was timing rather than data.

The function found in Ghidra runs once per round, before any Lua callback gets a chance to execute. OnGameStart fires after it, every time, with no exceptions found. OnScriptLoad fires before the map's tag data even exists. Neither hook landed on the correct side of the one function that mattered.

The trick, in the end, was to stop trying to get ahead of that function and instead make it run again after the correct data had already been written. sv_map_reset forces exactly that recalculation, and the event it fires, OnMapReset, lands before the recalculation happens rather than after. So the working sequence became: promote the flags in OnGameStart, then immediately trigger sv_map_reset from inside the same callback.

That produced its own small bug. The guard flag meant to stop the reset from re-triggering itself assumed sv_map_reset would fire OnGameStart again, which it doesn't — it only fires OnMapReset. The guard got set once and never cleared, so the whole mechanism quietly worked exactly once and then stopped, which is a special kind of frustrating to debug, because everything looks fine right up until the second attempt. Moving the flag's reset into OnMapReset, instead of relying on a callback that was never coming, fixed it properly.

The result

Eight checkpoints, on a map whose scenario tag only ever shipped five. All eight genuinely hittable, confirmed by walking through every one of them in a live round rather than trusting a console log.

Lessons learned

Nothing here would have been found by reading the map format harder. The actual link between a scenario flag and its runtime slot isn't documented anywhere I could find, because there was never a reason to document it: whatever placed these flags in the first place presumably just writes the field correctly, and nobody downstream ever needs to know it exists.

Over the course of this I was misled by:

  • A mask that genuinely did something, just not the thing I wanted it to.

  • Three independent, methodical searches for a table that didn't exist.

  • A struct field already read and misread as part of a different one.

  • A guard flag whose assumption about callback ordering was wrong in a way that only showed up on the second use.