Categories
GSoC 2026

WEEK 3

The Endgame That Ate Itself

Welcome back! After last week’s rampage through tunnels, ghosts, and greedy opcodes, the EGA port was finally playable. So this week, I did what any reasonable person does with a newly playable game: I tried to actually beat it.

That’s when the final boss fought back—not in the story, but in the engine.

Story 1: The Confrontation That Wouldn’t End

The endgame features a tense confrontation sequence. You whittle the enemy down, and at the climax, you use the “PSI POWERS” menu to win the game. In my playthrough, I successfully gave the enemy the winning item… but nothing ended.

Instead, the confrontation menu re-prompted me as if nothing had happened. Because the win state had already been consumed, the engine immediately threw me into a forced game-over. Worse, the longer this loop dragged on, the more the screen fell apart: sprites glitched, the rendering corrupted, and the game’s global variables seemingly rewrote themselves at random.

Two symptoms—a looping menu and creeping visual corruption—almost always point to one thing: a stack overrunning its bounds.

I dug into the engine and found the culprit: script_stack, a tiny 5-frame array used to track nested script calls. The original DOS game had a “priority command” mechanism. When a priority command fired, the original assembly engine would yank the script stack pointer all the way back to a known baseline, effectively throwing away whatever nested call chain was in progress.

Our C++ port never restored that pointer. So, every time a priority command fired from inside a subroutine, it quietly leaked one script_stack frame. In the endgame, every single use of “PSI POWERS” fires a priority command. If you use it five times, the 5-frame array overflows, scribbling over the globals that live right next to it in memory. That explained the sprite garbage and the corrupted script state!

Furthermore, each C++ call nested the menu one level deeper. When I finally won, the stack unwound only one level, landing me back in a stale, “zombie” confrontation menu.

The fix was an architectural shift to make our port honor the original DOS stack discipline. I restored the script stack pointer to its baseline when a priority command runs:

C++

case 0xF000:
    script_stack_ptr = script_stack;

Then, instead of re-entering the priority handler at whatever deep nesting level we happened to be at, I propagated the pending flag straight up to the main gameLoop, draining all pending commands from a safe baseline:

C++

do {
    g_vm->_prioritycommand_1 = false;
    g_vm->_prioritycommand_2 = false;
    res = runCommand();
} while (g_vm->_prioritycommand_1);

No more accumulating menu frames, no more overflow, and no more haunted endgame. The final puzzle actually triggers the victory sequence now!

Story 2: The Bugged Wall

Deep in the game, there’s a room called The Wall — a massive gate you open and close by stepping toward it. Press the arrow once, and the gate slides halfway shut; press again, and it should slam closed, sealing the room in front of you. That’s the drama the designers intended.

In my EGA build, the drama fell completely flat. The gate would slide in from the sides… and then just stop, leaving a big black void in the middle where solid stone was supposed to be. Half-closed left a small black gap; fully closed left a massive black hole. The wall looked less like an ancient sci-fi gate and more like someone forgot to finish painting it.

The Wrong Suspects

My first instinct was that the closed wall was being drawn correctly, but then immediately erased. The engine keeps two copies of the screen—a visible “front” buffer and a persistent “back” buffer used to redraw things. I figured the freshly closed gate was living only in the front buffer and getting reverted by the back buffer a frame later. So, I wrote code to explicitly copy the gate into the back buffer after the animation.

No change.

So, I went bigger: I forced the game to re-run its full room-entry redraw routine after the gate moved. That immediately earned me a segmentation fault. Those routines simply aren’t safe to call in the middle of a script command. Two dead ends.

The thing that finally turned the investigation around was a more precise look at the symptom itself: the center wasn’t reverting to black, it was never drawn in the first place. And the more the gate “closed,” the more black space appeared. That didn’t sound like a state bug anymore. That sounded like a broken sprite.

Reading the Bytes

The wall sprite isn’t one solid image—it’s a little mosaic. The game stores it as a list of small tiles, each with an offset instructing the engine: “place me at this position in the door.”

The original CGA code lays those tiles into a buffer that’s 20 bytes wide (80 pixels). Therefore, a tile’s offset maps to a grid spot like this: row = offset / 20, column = offset % 20.

The EGA port I inherited was doing the math differently. It divided by 40 and halved the column. Rather than argue with myself about which formula was right, I opened the data file and printed the actual tile positions using both methods:

  • Correct (CGA layout): columns 0, 16, 32, 48 | rows 0, 30 → a full 4×2 grid.

  • The EGA code’s version: columns 0, 8, 16, 24 | rows 0, 15 → everything crushed into one corner.

There it was. The EGA formula squashed every tile to half spacing on both axes, piling the entire door into the top-left quarter of its own frame. The rest of the gate—most of it—stayed completely empty. Black.

The animation and the draw code had been perfectly innocent the entire time; they were faithfully rendering a sprite that had been assembled wrong from the start!

The Fix

The fix took just two lines. I made the EGA assembler agree with the original CGA math:

C++

// Before — squashed into a corner
uint16 row    = cgaOfs / 40;
uint16 colCga = (cgaOfs % 40) / 2;

// After — full 20-byte-wide layout, like CGA
uint16 row    = cgaOfs / 20;
uint16 colCga = cgaOfs % 20;

Because that single function builds every wall sprite, the fix lit up everything at once. The half-closed gate, the fully closed gate, and the sliding animation in between all snapped into solid, properly-textured stone. The wall finally closes the way it was meant to.

Honorable Mention: A Faithful Roll of the Dice

Last week, I seeded the RNG from the host’s millisecond timer to make encounters properly random again. However, my mentor Sev rightly pointed out that ScummVM has a native, deterministic way to handle randomness: Common::RandomSource. If a tester wants to provide a fixed seed via the GUI to reproduce a bug, my timer hack would break that.

But here was the catch: the original game didn’t actually generate math-based random numbers on the fly. It used a hardcoded lookup table (aleat_data[]) to guarantee a specific probability distribution. If I replaced the game’s dice rolls entirely with ScummVM’s modern RNG, I would lose the authentic feel of the original game.

I found a perfect middle ground. I kept the original DOS lookup table, but I used ScummVM’s Common::RandomSource to pick the starting offset of that table:

C++

void randomize(void){
    rand_seed = (byte)g_vm->_rnd->getRandomNumber(255);
    getRand();
}

This honors ScummVM’s architecture (we get GUI configuration support for free) while keeping the original game’s exact probability sequence completely intact.

What’s Next

The endgame actually ends! This feels like a massive milestone—you can now play the Kult EGA port from start to finish. Next week, I’ll focus on polishing, chasing down the remaining EGA-specific rendering quirks, and ensuring the whole experience is as stable as possible.

Thanks for reading — see you next week!

Leave a Reply

Your email address will not be published. Required fields are marked *