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!

Leave a Reply

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