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!

Categories
GSoC 2026

WEEK 2

Welcome back! This second week was all about bringing the EGA port of Kult from “it boots but it’s unplayable” to a genuinely playable experience. I tackled memory corruption, weird rendering delays, and a few bizarre hauntings.

Let me walk you through the three most interesting mysteries I cracked this week.

Story 1: Groundhog Day in the Tunnels

Early in the game, you walk through a tunnel and have a chance to encounter an Aspirant. But during my testing, he wasn’t just there sometimes—he was hostile and attacked me every single time. It felt less like a random encounter and more like a scripted mugging.

I dug into the engine’s random number generator (RNG) and found the culprit. It turned out our randomize() function was a stub, leaving the seed permanently stuck at 0. Furthermore, the prepareAspirant logic was reusing a stale random value instead of pulling a fresh one.

To fix this, I seeded the RNG with the host’s millisecond timer (mimicking the original DOS game, which read the BIOS timer) and forced a fresh byte draw in the room prep logic:

C++

rand_seed = (byte)(g_system->getMillis());
getRand();

With the dice finally rolling correctly, the encounters became truly unpredictable again!

Story 2: The Ghost That Followed Me Through Doors

Once I got past the Aspirant, another weird bug appeared. I walked through a door into a completely new zone (“De Profundis”), where no NPCs should exist. Suddenly, the Aspirant’s face popped up on screen, along with a speech bubble asking if I wanted to trade!

This turned out to be a classic state leak. When swapping rooms, the engine correctly cleared the upcoming command queues, but it forgot two things: the command chain currently executing, and the pointer to the current NPC. If you crossed a door at the exact millisecond the Aspirant’s trade script was running, that script would survive the transition. The engine would then happily render the previous room’s NPC in the new room.

The fix was a surgical guard inside the door transition path (SCR_42_LoadZone):

C++

the_command = 0; 
script_vars[kScrPool8_CurrentPers] = pers_list;

This instantly aborts any in-flight command chain and drops the stale actor pointer before the room swap finishes. No more ghosts!

Story 3: The Door, The Scorpion, and the Greedy Opcode

In the Scorpion room, the intended solution is to use the “Pray” command on a statue to open a door. But doing so gave me corrupted, gibberish text on the screen, the speech bubble got stuck, and the door remained firmly locked.

I suspected it was a missing item at first, but the truth was a desync in the script decoder. The EGA and CGA versions of the game have slightly different opcode lengths. When you pray, the game wants to do two things: play a sound (0x68 PlaySfx) and then set a variable to open the door (setVar).

However, in our engine, the EGA opcode 0x68 was mistakenly programmed to read an extra “pad” byte. Because of this, the sound opcode literally “ate” the door-opening command! The engine lost its place in the script, spit out garbage memory as text, skipped the bubble cleanup, and never opened the door. Removing that single rogue script_ptr++ operand width fixed all symptoms instantly.

Honorable Mentions

While digging through the engine, I also patched up a few other nasty issues:

  • Exploding Sprites: A massive “Lutin” character sprite was overflowing its allocated scratch memory (scratch_mem1) and corrupting the EGA sprites_list[], causing random crashes during blit/restore operations. I bumped up the memory limits to keep the big guys safely contained.

  • Death Loops: Fixed an infinite loop tied to the “YOU FAILED THE ORDEALS” timer.

What’s Next

This week felt amazing because the EGA version is finally starting to behave like a real game. Next week, I’ll continue hunting down logic bugs and refining the remaining EGA-specific rendering quirks.

Thanks for reading — see you next week!

Categories
GSoC 2026

WEEK 1

GSoC Week 1: Mouse Bugs, Rigged Minigames, and a Frozen Snake

This first week was full of detective work. Let me walk you through the three main mysteries I tackled.

Story 1: The Mouse That Lied to You

The game’s interaction system works by letting you hover over objects to highlight them, then click to interact. Early on I noticed something frustrating: hovering over an action worked perfectly — the cursor snapped to it and highlighted it — but the moment I clicked, the game would sometimes grab a different nearby object, or worse, produce a “not a good idea” failure message as if I’d clicked on empty space.

The hover and click paths were reading coordinates from different places.During a hover, the game samples cursor_x and cursor_y continuously from the
mouse movement events, so the position is always current. But on a click, the code was still using whatever position happened to be in those variables from
the last movement event, which could be slightly stale if the mouse had moved even a pixel between events. The fix was straightforward: latch the coordinates directly at the moment the button-down event fires.

case Common::EVENT_LBUTTONDOWN:
cursor_x = event.mouse.x; // capture position at click time
cursor_y = event.mouse.y;
mouseButtons |= 1;
break;

While I was deep in the input code, I also spotted that the cursor gave no visual feedback at all when hovering over interactive spots. The original game changed the cursor color on hotspots, but it was rendering pure white
regardless. A one-line fix in the EGA cursor renderer now makes the cursor turn yellow when over a hotspot and white otherwise — a small touch, but it immediately makes the game feel more alive and responsive.

Story 2: The Cups Game Was Always Rigged (And That’s Correct)

This was my favorite discovery of the week.

The game has a classic shell game — a character shuffles three cups, and you have to guess which one hides the skull. While testing it, I kept losing .The shuffle was to fast to track the cup, so it was pure luck. It felt like a bug, but something about the pattern nagged at me.

I pulled out the script disassembler and dug into the original game bytecode at offset 0xa76, where the ball’s cup position is calculated. The logic reads:

rand_value & 7 + 7

That expression generates a number in the range 7–14. But valid cup positions in the game’s data are only 7–12 (three cup positions × two states). So whenever the random number generator yields a 6 or 7 in the lower bits, the ball gets placed at position 13 or 14 — positions that don’t correspond to any cup.

My first instinct was to clamp the value. But then I stopped and checked if this was an accident or a feature. It’s not an accident — 25% of the time the ball is literally placed off the table, making it harder to win .

I will confess, though, that I temporarily hardcoded myself a 100% win rate just to bypass him and test the rest of the game, but rest assured, the final release will keep the authentic 25% scam rate entirely intact.

Story 3: The Snake That Wouldn’t Move Its Mouth

The third bug was the most technically satisfying to crack, and my mentor Sev deserves credit for pointing me toward the solution.

In the room called “The Twins”, there’s a snake you can interact with. You click OPEN on it, the game logic updates correctly (afterwards you can click SHUT , proving the internal state changed), but the snake’s sprite on screen
stays frozen in the closed position. The animation just never fires.

My first suspicion was a backbuffer issue — the game uses a software backbuffer for rendering, and maybe the wrong flush function was being called after the state change. I poked at SCR_5F vs SCR_11 for a while without getting anywhere.

Sev told me to run the script dumper on the original bytecode and compare what the EGA build was actually calling. When I dumped the scripts, the answer jumped out immediately: the sequence that handles the snake interaction ends with opcode 0x6B —RedrawRoomStatics. This opcode tells the engine to redraw all the static elements in the room and flush the backbuffer to screen.

The problem? Our opcode dispatch table only went up to 0x6A. The bounds check at the time was a hardcoded >= 107 (which is 0x6B in decimal), so the engine
hit opcode 0x6B, saw it was out of range, and silently aborted the script mid-execution. The state update had already happened, but the screen redraw command and everything after it was never reached.

The fix turned out to be surprisingly simple! The EGA version of the game used a slightly different command number to redraw the screen compared to the older CGA version. Our engine simply didn’t recognize this new command, so it just ignored it.

All I had to do was add the missing EGA command and link it to the existing CGA logic—essentially telling the engine, ‘Hey, this new command does the exact same thing as the old one.’ Finally, I updated the engine’s internal safety limits so that if it ever encounters another unknown command, it won’t just silently skip it.

What’s Next

This week showed me how much of game archaeology this work involves. Sometimes you’re fixing your own code, sometimes you’re uncovering a 36-year-old intentional design decision, and sometimes you’re hunting a silent abort in a dispatch table.

Next week I’ll be looking at more EGA-specific rendering differences and continuing to close the gap between the CGA and EGA builds of the engine.

Thanks for reading — see you next week!

Categories
GSoC 2026

Finishing Incomplete ScummVM Engines

Hello everyone! My name is Andy and I’m a 2nd-year Computer Science student. I am incredibly excited to announce that I will be participating in Google Summer of Code 2026 with ScummVM!

I am deeply drawn to ScummVM because of its core mission of digital preservation, coupled with my deep passion for retro gaming. For this summer, my project is titled “Finishing implementation of incomplete engines”. The ultimate goal is to push three nearly-finished ScummVM engines over the finish line, ensuring they are stable and ready for official ScummVM releases.

Currently, these engines face various challenges, ranging from incomplete low-level graphics and legacy code structures to unresolved gameplay bugs.

I am looking forward to diving deep into the codebase and sharing my progress, technical challenges, and bug-hunting stories right here.

Stay tuned for more updates, and let the coding begin!