Categories
GSoC 2026

WEEK 9

After last week’s big push to get Chamber of the Sci-Mutant Priestess over the finish line, this week was all about MacVenture — turning last week’s “the games boot!” moment into something people can actually install and play.

Getting the games out of Steam

The biggest chunk of work went into the tooling. Last week I explained the awkward situation with the Steam releases: the actual game data isn’t sitting there as loose files, it’s a Mac HFS disk image buried inside a Chromium wrapper executable. Extracting that by hand once, for debugging, is one thing — but nobody buying the games on Steam should have to do that.

So I taught ScummVM’s dumper-companion how to do it. Point it at the Steam .exe, and it now digs the HFS disk image out of the PE resource, punycodes the filenames so they survive on a non-Mac filesystem, and hands you a clean set of game data. What used to be a fragile manual ritual is now a single command.

The Apple IIGS rabbit hole

While I was in there, I noticed the Steam executables actually carry two disk images — the Mac one everybody expects, and an Apple IIGS version tucked away in a separate resource. The IIGS releases have their own look and feel, so it felt wrong to leave them on the table.

Two problems stood in the way. First, the dumper only knew about the Mac disk, so I extended it to pull the IIGS .2mg image as well. Second — and this was the more surprising one — ScummVM’s detection for the IIGS games was quietly dead. The detection entries existed, but there was no data-fork fallback, so the files these games actually ship with never matched anything. I fixed the detection so it hashes the right fork, and suddenly the IIGS versions light up in the launcher like they always should have.

A detour back to Chamber

One small but satisfying fix landed on the Chamber side too. A user reported (bug #17004) that if you had both the CGA and EGA data in the same folder, the engine would stubbornly force CGA regardless of what you picked. It turned out to be a detection-hint issue, and now the engine honors the variant you actually chose.

Testing begins

With the tooling in place, I’ve started the part I’ve been looking forward to: real playthroughs. Déjà Vu is first up — the noir detective one, the natural place to start — and so far it’s going really well. The window manager behaves, items drag and drop where they should, the text scrolls cleanly. No showstoppers yet, which is exactly what you want to see from the first of four games.

Next week

The plan is to keep grinding through the playthroughs — finish Déjà Vu, then move on to Déjà Vu II, Uninvited, and Shadowgate — noting anything that misbehaves along the way. If they hold up like Déjà Vu has, the goal is to get MacVenture’s Steam and IIGS releases marked as testing so players can start reporting back.

As always, thanks to my mentors for the steady guidance — onward to the testing grind!

Categories
GSoC 2026

WEEK 8

Last week I was still deep in the reverse-engineering weeds of the Amiga port. This week the mood changed completely: it was the week Chamber of the Sci-Mutant Priestess finally stepped out into the light, and the week I started the work on a second engine called MacVenture.

Chamber goes to testing

The headline first: Chamber is now enabled for the upcoming ScummVM release and marked as testing. After all the months of decoding scripts, chasing endianness bugs, and rebuilding renderers one palette at a time, it felt genuinely strange to finally flip the switch.

The change enables the engine to build by default and promotes all five game variants — the multi-language CGA build, the US CGA build, the EGA build, and both Amiga builds (EU and US) — from ADGF_UNSTABLE to ADGF_TESTING. That last part matters: it means the games now show up for players who want to try them and report issues, instead of being hidden behind a developer flag.

It also carried a couple of last-minute fixes I wanted in before the release:

  • The endgame saucer animation was accumulating frames on the linear back buffer in EGA and Amiga modes, leaving a smeared trail instead of a clean animation. Fixed.

  • Hercules mode mouse mapping was off, so hotspot detection didn’t line up with what you saw on screen. Corrected the coordinate mapping.

And, on a more personal note, my name went into the credits with this work. Small thing on a diff, big thing for me.

A second engine: the MacVenture Steam versions

With Chamber wrapped up for release, my mentor pointed me at the next target: getting the MacVenture games — Shadowgate, Déjà Vu, Déjà Vu II, and Uninvited — running from their Steam releases, so we can eventually announce those for testing too, exactly like we just did with Chamber.

I expected to spend the week playing games. Instead I spent the first part of it doing detective work, because the Steam builds are not what you’d think.

Where is the game?

Each Steam download is basically a small Chromium (CEF) web wrapper: a .exe and a few DLLs. There are no loose game files anywhere. It turns out these are the “MacVenture Series” web ports, and the actual game data — the original Macintosh files, resource forks and all — is stored as an 800K Mac HFS disk image embedded as a resource inside the .exe.

So the pipeline to feed ScummVM became:

  1. Pull the disk image (.dsk) out of the PE resources in the executable.

  2. Run it through ScummVM’s own dumper-companion tool, which extracts the Mac files and, crucially, punyencodes the filenames. That’s the part that lets a name like Déjà Vu survive as xn--Dj Vu-sqa5d on a normal filesystem while ScummVM still recognizes it.

The nice surprise: once extracted, all four games were detected immediately as the existing “1993 rerelease” entries — the resource forks are identical — so no detection work was needed at all.

The crash that blocked everything

The bad surprise: all four games crashed on startup with an assertion failure the moment the GUI tried to initialize.

Assertion 'isValidRect()' failed (common/rect.h:201)

This one was a proper rabbit hole, and a good reminder of how far an uninitialized value can travel before it hurts you. Tracing it back through the window manager and the nine-patch border renderer, the story was:

  • MacVenture builds its window border offsets in borderOffsets(), setting every field of the BorderOffsets struct explicitly… except a newer field, titlePadding, which was never assigned.

  • That garbage value fed straight into the console window’s title width calculation.

  • In nine_patch.cpp, the border width is computed as dw = _h._fix + _titleWidth. With a garbage title width of ~32,800, dw overflowed to 32,864.

  • That produced an invalid Common::Rect (its right edge wrapped past its left edge), which tripped the assertion and aborted the game.

The fix is a single line — initialize titlePadding to 0 alongside the other offsets — but finding it meant instrumenting the whole border-drawing path to watch the bad number appear. And because it’s a genuine upstream bug, the fix helps the ordinary 1993 rereleases too, not just the Steam ones.

After that, all four games boot and run cleanly.

Reflection and what’s next

Two milestones in one week: Chamber crossing the finish line into testing, and MacVenture going from “won’t even start” to “boots all four games.” I owe a big thank-you to my mentor, sev, for all the help and patience.

Next week is the fun part I thought I’d be doing this week: actual playthroughs of all four MacVenture games. I’ll be watching closely for the Window Manager quirks, the lasso/marquee item selection, and the console text scrolling — the areas most likely to still hide bugs. Once those hold up, we’ll document the extraction steps and announce MacVenture for testing as well.

See you next week.

Categories
GSoC 2026

WEEK 7

Hello, everyone! With both the European and US Amiga releases of Kult / Chamber of the Sci-Mutant Priestess playable from start to finish, this week I shifted focus from adding features to hardening what was already there. Last week I mentioned that ScummVM runs the engine through the Coverity and PVS-Studio static analysers, and that their reports had turned up a handful of issues worth looking at. This week I went through those reports properly, and on top of that I chased down a save/load bug that had been quietly breaking a couple of rooms after loading a game.

Working through the Coverity report

Static analysers are good at spotting the kind of code that is technically wrong but rarely bites in day-to-day play: unreachable branches, resources that are allocated but never freed, and fields that are read before they are written. Chamber is a reimplementation of a 1989 DOS game, so a lot of the code mirrors the original binary very closely — which means some of these findings are actually faithful reproductions of dead code that was already dead in the original.

That distinction mattered while reviewing the reports, because I did not want to “fix” something that was deliberately mirroring the disassembly. So for each finding I went back to the original code to confirm what was really going on before touching anything.

A few of the more interesting ones:

  • In CMD_21_VortTalk, Coverity flagged a branch guarded by rand_value >= 170 as unreachable. Checking the disassembly, the num == 7 case it belonged to is dead in the original too, so the branch could go.

  • In drawRoomStatics, there was a door check for index == 91, but by the time that code runs the index is already constrained to the 50..60 range, so the check can never be true.

  • In the CGA blitter, blitToScreen had an endY > 200 clamp that could never fire, because dy and h are hardcoded to 0 and 200 just above it.

Alongside the dead code, the report caught two genuinely useful things: a memory leak and an uninitialised field. Several call sites were calling loadFond / ega_loadFond / loadSplash, which return a Graphics::Surface, and then throwing that surface away without freeing it — a small leak every time a room background or splash was loaded. And the engine’s _speaker member was not being initialised in the constructor. Both are the sort of thing that is easy to miss by eye and exactly what these tools are for.

Working through the PVS-Studio report

PVS-Studio leans towards a different class of finding — dead stores and redundant expressions — and it caught a similar mix:

  • Another impossible clamp in the CGA blitter, this time endX_bytes > 80 on the horizontal axis, again dead because the width is hardcoded to a full 320 pixels.

  • A dead ofs++ in cga_ZoomInplace, immediately followed by ofs being reassigned to oofs, so the increment never had any effect.

  • In SCR_23_HidePortrait, a left/right button branch where both arms were identical — this handler simply has no special behaviour to skip, so the branch collapsed to a single path.

  • In SCR_46_DeProfundisLowerHook, the return value of getPuzzlSprite() was being stored into sprofs and then overwritten on the very next line, so the first store was pointless.

There were also a couple of intentional busy-wait loops that PVS-Studio flagged for having an empty ; body. These are genuinely meant to be empty — they are timing delays — so instead of silencing the warning I gave them an explicit {} body to make the intent obvious to both the analyser and the next person reading the code.

None of these are dramatic bugs, but clearing them keeps the engine’s future reports clean, so real regressions do not get lost in a wall of pre-existing noise.

Fixing room state that vanished after loading a save

The most interesting problem this week was not a static-analysis finding at all — it was a save/load bug I ran into while testing.

Chamber builds each room from a base decor, but scripts frequently draw persistent changes on top of that: alternate decor sets, extra objects that appear once triggered, and the final frames of animations. The catch is that they draw these directly into the backbuffer. On a fresh visit that is fine, but when you saved and reloaded, the engine rebuilt the room from the base decor only — so anything a script had painted into the backbuffer was simply gone.

In practice this meant that things like the De Profundis monster, or Deilos, would fail to reappear after loading a game, even though the game state said they should be there. The state was correct; the pixels that represented it had never been saved.

The fix was to bump the save version to 2 and store what was actually missing: the zone palette, the current video mode, and the backbuffer contents themselves. On load, if the video mode matches, the backbuffer is restored verbatim; otherwise the engine falls back to the old rebuild-from-decor behaviour, so older saves and cross-mode loads still work. While I was in there I also made load reapply the room palette and honour the launcher’s save-slot option, so booting straight into a save from the launcher lands you in a correctly coloured room.

Looking ahead

That wraps up the cleanup pass I wanted to do before moving on. The engine is now noticeably tidier and more robust, which is a perfect state to leave it in. Starting next week, I will be shifting my focus to a completely different engine. I wonder if anyone can guess which one it will be?(Hint: It will be a great adventure!) Stay tuned, and thanks for reading.

Categories
GSoC 2026

WEEK 6

Crossing the Atlantic: the US Amiga Release

Last week ended with the European Amiga version of Kult running almost perfectly, and a promise: next up, the US release. In America, the game shipped as Chamber of the Sci-Mutant Priestess, published by Draconian, and this week was all about making that version boot and play too.

Same game, different executable

My hope was that the US build would just be the EU one with translated text files. It wasn’t. The Draconian build lays out the KULT executable completely differently:

  • Every static resource lives at a different offset. On the EU side, I had a table of file offsets for the resources embedded in the executable (palettes, icons, zone data…). The US executable shifts them around non-uniformly, so I couldn’t just add a constant — I had to reverse-engineer the US binary and rebuild the whole offset table entry by entry. The good news: once located, 8 of the 12 embedded resources turned out to be byte-identical to the EU ones; only ZONES, TEMPL, CARAC, and ICONE actually differ.

  • The English text banks are jammed straight into the executable, completely abandoning the neat separate files the EU release used. This meant writing a different extraction path just for the US text.

  • The filenames are lowercase.Normally, this would be an immediate problem on case-sensitive file systems like Linux. Thankfully, ScummVM’s Common::File API handles case-insensitivity out of the box, which saved me from having to write any extra fallback logic!

A different welcome screen

The US version also greets you differently: it shows an intro.pia title screen before the usual pres.bin presentation. It’s a small detail, but until the engine knew about it, the game wouldn’t even get past boot.

Zone Scan, the Amiga way

While testing, I also finished the Amiga version of the Zone Scan psi power. The Amiga renderer draws it differently from DOS: the effect applies a dedicated palette delta that tints the room green while the scan line sweeps across the screen. Getting the line itself right took one more fix — the sweep works on chunky pixels now, so the line width and the inversion effect had to be adapted from the original planar code.

Detection entries

With both variants playable, I added proper detection entries for the Amiga EU and US releases. ScummVM now recognizes all of them straight from the launcher.

Looking back at these past two weeks, I am incredibly happy to see the Amiga versions finally fully animated and playable. Building the Amiga support from absolute scratch was a massive, daunting challenge, and I honestly couldn’t have reached this milestone without the immense help and guidance from my mentor. Seeing these versions breathe and run flawlessly makes all the heavy lifting worth it!

Next week

While all this was happening, sev ran static analysis over the engine and sent me a nice stack of Coverity and PVS-Studio reports. While chasing those, I found a bug that wasn’t in my engine at all, but in ScummVM itself. More about that (and about a monster that refused to survive a save/load cycle) in the next post!).Stay tuned!

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!

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!