Categories
GSoC 2026

WEEK 5

The Amiga Comes to Life!

This week was a massive milestone. The European Amiga version of Kult now runs almost perfectly under ScummVM — it boots, plays through, and looks exactly the way it did on real hardware. Getting there took twelve intensive commits(for now) that dragged the port all the way from “the engine knows the Amiga platform exists” to “you can actually sit down, click around, and play it.”

The absolute best news from the start: the Amiga build didn’t require a completely separate engine. It runs on the same chunky, one-byte-per-pixel, 16-colour pipeline as EGA — the Amiga’s planar source data just gets converted into it. Almost everything that differs comes down to the palette, the way static data is stored, and a handful of filenames. That single insight shaped my entire week.

Sharing the EGA Path

The first major step was to stop the graphics code from asking “is this strictly EGA?” and start asking “is this an EGA-like renderer?”

I added an isEgaLikeRenderer() helper and routed the sprite, portrait, and transition code through it instead of comparing directly against the EGA render mode. With that simple seam in place, the upcoming Amiga renderer could reuse all of those paths completely unchanged! There was zero behavioural change for EGA or CGA, but we now had a clean foundation to hang the new platform on.

On top of that, I implemented the AmigaRenderer itself: a custom 12-bit palette over the same planar graphics, selected purely by platform. It came with its own cursor decoder for the SOURI.BIN hardware sprites, plus the title screen and its fade-in ramp wired directly into the boot path.

Finding the Data

The Amiga release doesn’t lay its files out like the DOS versions. Instead of a compressed PXI module, it keeps the static resources uncompressed inside the KULT executable itself. Furthermore, it ships per-language text files  rather than the generic ...I.BIN names.

I added a dedicated loadAmigaStaticData() function and routed the resource loading accordingly, ensuring the engine pulls text and data from the exact right offsets for each language.

Colour, At Last

Early on, the rooms were wearing the wrong palette entirely. The fix was to compose the room palette from the zone’s palette_index and apply it as soon as the zone loads. This means intro and special screens (which can bypass the usual zone refresh) are no longer left stuck on the previous title palette.

The title screen also had its own bizarre palette bug. It was reading a table at one offset in the KULT executable that turned out to be a brightness fade-lookup ramp, not actual RGB data — which is exactly why the whole title initially rendered in spooky shades of red! Pointing it at the title’s actual 16-colour palette brought the gorgeous artwork fully back to life.

 Before:

After:

Decoding the Sprites

The sprite banks were the trickiest reverse-engineering puzzle of the week. They share the EGA record layout, but with two fun twists:

  • The record size is stored big-endian.

  • The width/height byte pair is swapped.

The pixels themselves are word-planar — one big-endian word per four-pixel column. I added appendFromStreamAmiga() to decode them, a shared placeholder for any missing sprites, and successfully loaded the merged Amiga banks (SPRIT/PUZZL/A/B).

Scripts and Threads

Two subtler bugs came next:

  • The Padding Bug: The Amiga script blob inserts 0x00/0xAA padding for word alignment right in the middle of instructions. The engine didn’t expect this and was aborting scripts early (for instance, the Twins’ serpent-jaw redraw would just randomly stop). Instructing the engine to skip that padding fixed it instantly.

  • The Racing Thread: I also had to explicitly skip animateGauss on Amiga, because it runs on the timer thread and blitting from there directly races the main render thread.

The Ending and Final Polish

The endgame sequence needed several specific fixes to shine:

  • Saucer Animation: The vblank flush originally only pushed the chunky screen buffer for EGA, meaning the in-place saucer take-off animation never reached the Amiga screen. Now, it correctly flushes for any EGA-like renderer.

  • End Screen Noise: The engine was trying to decode the planar Amiga PRES.BIN with the CGA splash loader, turning the ending into a band of static noise. Loading it properly and restoring the title palette fixed the climax.

  • Infinite Loops: The non-US end path used to loop forever on an empty busy-loop, causing the host OS to report a frozen, “not responding” application. Now it cleanly pumps the event loop, holds the end screen until the player clicks or presses a key, and then returns gracefully to the launcher.

  • Portrait Pacing: Portrait animations were pacing off a busy-wait loop that collapses to nearly zero on fast modern CPUs, making them run at lightspeed. They are now properly paced by wall-clock time.

  • UI Borders: The rectangular UI boxes finally had their borders corrected and the right color as well.

Next Week

With the European Amiga version playing through cleanly, next week I’ll turn my full attention to the US variant. I’ll be checking exactly how its assets and text differ to bring it up to the exact same standard. The hardest groundwork is done; now it’s all about making the second variant just as rock-solid as the first!

These are some versions from before I find the right palette:

Categories
GSoC 2026

WEEK 4

Week 4: Phantom Pixels, a Lying Clock, and the Effects That Never Played

Welcome back! Last week ended on a high note: the endgame finally ended, and the Kult EGA port was playable start to finish. This week was all about the ultimate QA test: a full, end-to-end playthrough to hunt down the final remaining gremlins. Quick heads-up: I have more stories than usual to tell you this time! 🙂

And spoiler alert: we did it. The game is now incredibly stable and essentially bug-free! The final polish came down to fixing subtle rendering gremlins that survive a normal playthrough, and finally implementing the EGA visual effects the port had quietly left as empty stubs.

A theme ran through almost everything: EGA stores four bytes where CGA stored one, and a lot of inherited code never got the memo—either by doing CGA math in an EGA world, or by not doing the work at all. Here are the final stories that stood between the EGA port and perfection.

Story 1: The Garbage at the Top of the World

Some rooms—like Placating the Powers, where you face the High Priestess—have animated objects scattered around: flickering torches, glowing runes, things that breathe. And in those rooms, a band of garbage pixels kept getting stamped across the very top rows of the screen. Not flickering. Not reverting. Just… smeared there, like the engine wiped its hands on the ceiling.

The mechanism behind animated spots is clever and frugal. Before the engine draws a moving object, it backs up the background underneath it so it can restore it cleanly next frame. Those backups live in a scratch buffer, and right after them—at a fixed +1500 byte offset—the engine loads the animation sprites themselves (lutin sprites).

scratch_mem1  ──► spot backups
scratch_mem2  ──► scratch_mem1 + 1500  ──► lutin sprites

That 1500-byte gap is the original CGA budget. And there’s the trap: in CGA, each backed-up pixel is packed 4-to-a-byte. In our EGA port, every pixel is stored as a full CLUT8 byte—four times the size. A backup that needed 1500 bytes on CGA needs up to 6000 on EGA.

So, the backups overran their 1500-byte fence and spilled straight into the lutin region. The next sprite load then clobbered the backup headers—and a corrupted header means a corrupted offset, with an X coordinate no longer aligned to 4. When the engine dutifully restored those backups, it pasted them at a bogus, top-of-screen position. The garbage band was the engine faithfully restoring backups it could no longer find.

The fix was to teach the memory layout about EGA’s appetite. I sized the gap for the EGA worst case, and grew the scratch buffer so neither a fat backup nor a fat sprite can overrun its neighbor:

C++

// Spot-backup gap: EGA worst case is 4x the CGA budget
gap = 6000;                 // was 1500
scratch_mem1 = gap + 12800; // gap + EGA lutin worst case

No more overrun, no more clobbered headers, no more graffiti on the ceiling.

Story 2: The Clock That Lied

Vorts and turkeys (yes, turkeys) are timed encounters. They’re supposed to wander into a room, hang around, and leave on a schedule. In my build, they were teleporting in and out at the wrong moments, blinking on for a frame, vanishing, and leaving little visual scars behind. The room felt haunted.

The encounter logic is a simple comparison: has enough time passed yet? It checks an encounter deadline (next_vorts_ticks, next_turkey_ticks) against the global timer (timer_ticks2). Straightforward—except the two numbers weren’t speaking the same dialect.

The deadlines are stored as plain numeric values (host endianness). The global timer is stored big-endian. Comparing them directly is like comparing 0x0100 against 1 and wondering why the alarm keeps going off early. The clock wasn’t broken—it was just lying about what time it was.

The fix is a one-sided byteswap so both operands are honest numbers before the comparison:

C++

if (next_vorts_ticks <= Swap16(script_word_vars.timer_ticks2)) { ... }

With both sides numeric, the vorts and turkeys finally keep their appointments instead of strobing through the walls.

Story 3: The Scan That Gave Up a Quarter of the Way

This one I caught live, mid-playthrough. The Zone Scan PSI power sweeps a horizontal line down the room to reveal hidden objects. The line is supposed to travel cheek to cheek across the whole play area.

In EGA, it covered exactly a quarter of the width and stopped dead.

You can probably guess the villain by now—it’s Story 1’s twin. Room coordinates in this engine are measured in 4-pixel blocks. The scan’s starting offset is computed by calcXY_p, which correctly scales the block coordinate by 4 for EGA’s one-byte-per-pixel buffer. But the width of the line being inverted and blitted was the raw block count, used directly as a byte count:

  • CGA: one byte = 4 pixels, so w bytes cover the full width.

  • EGA: one byte = 1 pixel, so w bytes cover… one quarter.

Same root cause as the backup overflow—a CGA-era width used unscaled in a 4x wider EGA world. The fix is to scale the width by 4 in EGA (and widen the loop counter, since w * 4 no longer fits in a byte):

C++

// Room coords are 4-pixel blocks; EGA is 1 byte/pixel
uint16 pw = (videoMode == kRenderEGA) ? (uint16)w * 4 : w;
for (px = 0; px < pw; px++)
    frontbuffer[offs + px] = ~frontbuffer[offs + px];

The scan now sweeps the full width, the way the designers intended—and the hidden flask reveals itself like it’s supposed to.

Story 4: The Ending That Almost Wasn’t

Fixing last week’s confrontation loop got me to the victory sequence—which had its own pile of problems. You win, the screen pans up to a flying saucer receding into the sky, and THE END drops in. Instead, my EGA build crashed with a divide-by-zero, and on the runs that somehow survived, the saucer shot off-screen and the logo was clipped or missing. One ending, five bugs:

  1. The crash: The end-logo frame descriptor was missing from the table, so the portrait builder read a frame width of zero and divided by it. Restoring pers_frames[9] (plus a defensive guard) killed the SIGFPE.

  2. The runaway saucer: Our recurring villain again—the saucer’s path X, read from SOUCO.BIN, was treated as a 4-pixel-byte column and multiplied by 4. In EGA it’s a raw pixel column. Dropping the *4 put it back on its flight path.

  3. The saucer that wouldn’t shrink: The EGA zoomImage and zoomInplaceXY functions were non-scaling stubs that ignored the target size and redrew at native size every frame. I wrote a real nearest-neighbour scaler so the saucer actually recedes properly.

  4. The clipped ‘D’: The scaler sampled with (srcW-1)/dstW, which drops the final source column—shearing the right stroke off the D in THE END. Switching to srcW/dstW makes a 1:1 draw the identity map.

  5. The rising red dot: The cutscene cleared its buffer with sizeof - 2, leaving the bottom-right two pixels uncleared. CGA never noticed; EGA’s scroll-reveal lifted them up the screen as a tiny red balloon riding the saucer. Clearing the whole buffer sent it home.

Now, the saucer rises, shrinks, and slips away; THE END lands cleanly; and nothing divides by zero on the way to the credits.

Story 5: The Effects That Never Played

The last piece of polish wasn’t a bug—it was a blank. Walk between rooms in CGA and the world animates: when you stride The Ring or the passages, the background spirals in over the old room before the new one appears; elsewhere there are lift wipes, a dot dissolve, and zoom-in reveals. In EGA, all of these were stubbed out—rooms just snapped. Functional, but lifeless, and not what the designers built.

So, I implemented the EGA renderer’s transition effects to match the original CGA:

  • The spiral reveal for The Ring and passages (finally wiring up a flag, skip_zone_transition, that had been sitting unused, which decides when the spiral should play).

  • The lift wipes (the room block slides up / down / left / right one line per step).

  • The dot dissolve.

  • The zoom-in reveals.

It’s the kind of work that’s invisible when it’s done right—and that’s exactly the point. Room changes in EGA now have the same texture and rhythm as the original, instead of cutting like a cheap slideshow.

What’s Next: Expanding the Scope

With these final quirks down, a clear pattern emerged: the deepest EGA bugs weren’t logic errors, they were unit mismatches and missing pieces. Fixing them was the final piece of the puzzle.

After applying these patches, I completed a full start-to-finish playthrough of the main EGA port without encountering a single glitch, crash, or graphical artifact. But Kult isn’t just one version. Now that this primary EGA build is rock-solid, my focus for next week shifts to full regression testing. I will be diving into the other EGA releases and the original CGA versions, playing through them to ensure my engine fixes didn’t break anything else, and making sure the entire Kult family runs perfectly in ScummVM.

Thanks for reading, and see you next week!

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!