{"id":45,"date":"2026-06-15T18:53:37","date_gmt":"2026-06-15T18:53:37","guid":{"rendered":"https:\/\/blogs.scummvm.org\/andy\/?p=45"},"modified":"2026-06-15T19:13:08","modified_gmt":"2026-06-15T19:13:08","slug":"week-3","status":"publish","type":"post","link":"https:\/\/blogs.scummvm.org\/andy\/2026\/06\/15\/week-3\/","title":{"rendered":"WEEK 3"},"content":{"rendered":"<div class=\"container\">\n<div id=\"model-response-message-contentr_bbfaa92af2546f4d\" class=\"markdown markdown-main-panel stronger enable-updated-hr-color\" dir=\"ltr\" aria-live=\"off\">\n<h2 data-path-to-node=\"5\">The Endgame That Ate Itself<\/h2>\n<p data-path-to-node=\"6\">Welcome back! After last week&#8217;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.<\/p>\n<p data-path-to-node=\"7\">That\u2019s when the final boss fought back\u2014not in the story, but in the engine.<\/p>\n<h3 data-path-to-node=\"8\">Story 1: The Confrontation That Wouldn&#8217;t End<\/h3>\n<p data-path-to-node=\"9\">The endgame features a tense confrontation sequence. You whittle the enemy down, and at the climax, you use the &#8220;PSI POWERS&#8221; menu to win the game. In my playthrough, I successfully gave the enemy the winning item&#8230; but nothing ended.<\/p>\n<p data-path-to-node=\"10\">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&#8217;s global variables seemingly rewrote themselves at random.<\/p>\n<p data-path-to-node=\"11\">Two symptoms\u2014a looping menu and creeping visual corruption\u2014almost always point to one thing: a stack overrunning its bounds.<\/p>\n<p data-path-to-node=\"12\">I dug into the engine and found the culprit: <code data-path-to-node=\"12\" data-index-in-node=\"45\">script_stack<\/code>, a tiny 5-frame array used to track nested script calls. The original DOS game had a &#8220;priority command&#8221; 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.<\/p>\n<p data-path-to-node=\"13\">Our C++ port never restored that pointer. So, every time a priority command fired from inside a subroutine, it quietly leaked one <code data-path-to-node=\"13\" data-index-in-node=\"130\">script_stack<\/code> frame. In the endgame, every single use of &#8220;PSI POWERS&#8221; 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!<\/p>\n<p data-path-to-node=\"14\">Furthermore, each C++ call nested the menu one level deeper. When I finally won, the stack unwound only <i data-path-to-node=\"14\" data-index-in-node=\"104\">one<\/i> level, landing me back in a stale, &#8220;zombie&#8221; confrontation menu.<\/p>\n<p data-path-to-node=\"15\">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:<\/p>\n<div class=\"code-block ng-tns-c3519020110-45 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation\" data-hveid=\"0\" data-ved=\"0CAAQhtANahcKEwjepqaSwYmVAxUAAAAAHQAAAAAQLw\">\n<div class=\"formatted-code-block-internal-container ng-tns-c3519020110-45\">\n<div class=\"animated-opacity ng-tns-c3519020110-45\">\n<div class=\"code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c3519020110-45 ng-star-inserted\">\n<p><span class=\"ng-tns-c3519020110-45\">C++<\/span><\/p>\n<div class=\"buttons ng-tns-c3519020110-45 ng-star-inserted\"><\/div>\n<\/div>\n<pre class=\"ng-tns-c3519020110-45\"><code class=\"code-container formatted ng-tns-c3519020110-45\" role=\"text\" data-test-id=\"code-content\"><span class=\"hljs-keyword\">case<\/span> <span class=\"hljs-number\">0xF000<\/span>:\r\n    script_stack_ptr = script_stack;\r\n<\/code><\/pre>\n<\/div>\n<\/div>\n<\/div>\n<p data-path-to-node=\"17\">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 <code data-path-to-node=\"17\" data-index-in-node=\"157\">gameLoop<\/code>, draining all pending commands from a safe baseline:<\/p>\n<div class=\"code-block ng-tns-c3519020110-46 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation\" data-hveid=\"0\" data-ved=\"0CAAQhtANahcKEwjepqaSwYmVAxUAAAAAHQAAAAAQMA\">\n<div class=\"formatted-code-block-internal-container ng-tns-c3519020110-46\">\n<div class=\"animated-opacity ng-tns-c3519020110-46\">\n<div class=\"code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c3519020110-46 ng-star-inserted\">\n<p><span class=\"ng-tns-c3519020110-46\">C++<\/span><\/p>\n<div class=\"buttons ng-tns-c3519020110-46 ng-star-inserted\"><\/div>\n<\/div>\n<pre class=\"ng-tns-c3519020110-46\"><code class=\"code-container formatted ng-tns-c3519020110-46\" role=\"text\" data-test-id=\"code-content\"><span class=\"hljs-keyword\">do<\/span> {\r\n    g_vm-&gt;_prioritycommand_1 = <span class=\"hljs-literal\">false<\/span>;\r\n    g_vm-&gt;_prioritycommand_2 = <span class=\"hljs-literal\">false<\/span>;\r\n    res = runCommand();\r\n} <span class=\"hljs-keyword\">while<\/span> (g_vm-&gt;_prioritycommand_1);\r\n<\/code><\/pre>\n<\/div>\n<\/div>\n<\/div>\n<p data-path-to-node=\"19\">No more accumulating menu frames, no more overflow, and no more haunted endgame. The final puzzle actually triggers the victory sequence now!<\/p>\n<\/div>\n<h3 data-path-to-node=\"3\">Story 2: The Bugged Wall<\/h3>\n<p data-path-to-node=\"4\">Deep in the game, there&#8217;s a room called <i data-path-to-node=\"4\" data-index-in-node=\"40\">The Wall<\/i> \u2014 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&#8217;s the drama the designers intended.<\/p>\n<p data-path-to-node=\"5\">In my EGA build, the drama fell completely flat. The gate would slide in from the sides&#8230; 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.<\/p>\n<h4 data-path-to-node=\"6\">The Wrong Suspects<\/h4>\n<p data-path-to-node=\"7\">My first instinct was that the closed wall was being drawn correctly, but then immediately erased. The engine keeps two copies of the screen\u2014a visible &#8220;front&#8221; buffer and a persistent &#8220;back&#8221; 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.<\/p>\n<p data-path-to-node=\"8\">No change.<\/p>\n<p data-path-to-node=\"9\">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&#8217;t safe to call in the middle of a script command. Two dead ends.<\/p>\n<p data-path-to-node=\"10\">The thing that finally turned the investigation around was a more precise look at the symptom itself: the center wasn&#8217;t <i data-path-to-node=\"10\" data-index-in-node=\"120\">reverting<\/i> to black, it was never drawn in the first place. And the more the gate &#8220;closed,&#8221; the more black space appeared. That didn&#8217;t sound like a state bug anymore. That sounded like a broken sprite.<\/p>\n<h4 data-path-to-node=\"11\">Reading the Bytes<\/h4>\n<p data-path-to-node=\"12\">The wall sprite isn&#8217;t one solid image\u2014it&#8217;s a little mosaic. The game stores it as a list of small tiles, each with an offset instructing the engine: <i data-path-to-node=\"12\" data-index-in-node=\"149\">&#8220;place me at this position in the door.&#8221;<\/i><\/p>\n<p data-path-to-node=\"13\">The original CGA code lays those tiles into a buffer that&#8217;s 20 bytes wide (80 pixels). Therefore, a tile&#8217;s offset maps to a grid spot like this: <code data-path-to-node=\"13\" data-index-in-node=\"145\">row = offset \/ 20<\/code>, <code data-path-to-node=\"13\" data-index-in-node=\"164\">column = offset % 20<\/code>.<\/p>\n<p data-path-to-node=\"14\">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:<\/p>\n<ul data-path-to-node=\"15\">\n<li>\n<p data-path-to-node=\"15,0,0\"><b data-path-to-node=\"15,0,0\" data-index-in-node=\"0\">Correct (CGA layout):<\/b> columns 0, 16, 32, 48 | rows 0, 30 \u2192 a full 4\u00d72 grid.<\/p>\n<\/li>\n<li>\n<p data-path-to-node=\"15,1,0\"><b data-path-to-node=\"15,1,0\" data-index-in-node=\"0\">The EGA code&#8217;s version:<\/b> columns 0, 8, 16, 24 | rows 0, 15 \u2192 everything crushed into one corner.<\/p>\n<\/li>\n<\/ul>\n<p data-path-to-node=\"16\">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\u2014most of it\u2014stayed completely empty. Black.<\/p>\n<p data-path-to-node=\"17\">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!<\/p>\n<h4 data-path-to-node=\"18\">The Fix<\/h4>\n<p data-path-to-node=\"19\">The fix took just two lines. I made the EGA assembler agree with the original CGA math:<\/p>\n<div class=\"code-block ng-tns-c3519020110-66 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation\" data-hveid=\"0\" data-ved=\"0CAAQhtANahgKEwjepqaSwYmVAxUAAAAAHQAAAAAQkQE\">\n<div class=\"formatted-code-block-internal-container ng-tns-c3519020110-66\">\n<div class=\"animated-opacity ng-tns-c3519020110-66\">\n<div class=\"code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c3519020110-66 ng-star-inserted\"><span class=\"ng-tns-c3519020110-66\">C++<\/span><\/p>\n<div class=\"buttons ng-tns-c3519020110-66 ng-star-inserted\"><\/div>\n<\/div>\n<pre class=\"ng-tns-c3519020110-66\"><code class=\"code-container formatted ng-tns-c3519020110-66\" role=\"text\" data-test-id=\"code-content\"><span class=\"hljs-comment\">\/\/ Before \u2014 squashed into a corner<\/span>\r\nuint16 row    = cgaOfs \/ <span class=\"hljs-number\">40<\/span>;\r\nuint16 colCga = (cgaOfs % <span class=\"hljs-number\">40<\/span>) \/ <span class=\"hljs-number\">2<\/span>;\r\n\r\n<span class=\"hljs-comment\">\/\/ After \u2014 full 20-byte-wide layout, like CGA<\/span>\r\nuint16 row    = cgaOfs \/ <span class=\"hljs-number\">20<\/span>;\r\nuint16 colCga = cgaOfs % <span class=\"hljs-number\">20<\/span>;\r\n<\/code><\/pre>\n<\/div>\n<\/div>\n<\/div>\n<p data-path-to-node=\"21\">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.<\/p>\n<div id=\"model-response-message-contentr_bbfaa92af2546f4d\" class=\"markdown markdown-main-panel stronger enable-updated-hr-color\" dir=\"ltr\" aria-live=\"off\">\n<h3 data-path-to-node=\"20\">Honorable Mention: A Faithful Roll of the Dice<\/h3>\n<p data-path-to-node=\"21\">Last week, I seeded the RNG from the host&#8217;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: <code data-path-to-node=\"21\" data-index-in-node=\"214\">Common::RandomSource<\/code>. If a tester wants to provide a fixed seed via the GUI to reproduce a bug, my timer hack would break that.<\/p>\n<p data-path-to-node=\"22\">But here was the catch: the original game didn&#8217;t actually generate math-based random numbers on the fly. It used a hardcoded lookup table (<code data-path-to-node=\"22\" data-index-in-node=\"139\">aleat_data[]<\/code>) to guarantee a specific probability distribution. If I replaced the game&#8217;s dice rolls entirely with ScummVM&#8217;s modern RNG, I would lose the authentic feel of the original game.<\/p>\n<p data-path-to-node=\"23\">I found a perfect middle ground. I kept the original DOS lookup table, but I used ScummVM&#8217;s <code data-path-to-node=\"23\" data-index-in-node=\"92\">Common::RandomSource<\/code> to pick the <i data-path-to-node=\"23\" data-index-in-node=\"125\">starting offset<\/i> of that table:<\/p>\n<div class=\"code-block ng-tns-c3519020110-47 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation\" data-hveid=\"0\" data-ved=\"0CAAQhtANahcKEwjepqaSwYmVAxUAAAAAHQAAAAAQMQ\">\n<div class=\"formatted-code-block-internal-container ng-tns-c3519020110-47\">\n<div class=\"animated-opacity ng-tns-c3519020110-47\">\n<div class=\"code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c3519020110-47 ng-star-inserted\">\n<p><span class=\"ng-tns-c3519020110-47\">C++<\/span><\/p>\n<div class=\"buttons ng-tns-c3519020110-47 ng-star-inserted\"><\/div>\n<\/div>\n<pre class=\"ng-tns-c3519020110-47\"><code class=\"code-container formatted ng-tns-c3519020110-47\" role=\"text\" data-test-id=\"code-content\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">void<\/span> <span class=\"hljs-title\">randomize<\/span><span class=\"hljs-params\">(<span class=\"hljs-keyword\">void<\/span>)<\/span><\/span>{\r\n    rand_seed = (byte)g_vm-&gt;_rnd-&gt;getRandomNumber(<span class=\"hljs-number\">255<\/span>);\r\n    getRand();\r\n}\r\n<\/code><\/pre>\n<\/div>\n<\/div>\n<\/div>\n<p data-path-to-node=\"25\">This honors ScummVM&#8217;s architecture (we get GUI configuration support for free) while keeping the original game&#8217;s exact probability sequence completely intact.<\/p>\n<h3 data-path-to-node=\"26\">What&#8217;s Next<\/h3>\n<p data-path-to-node=\"27\">The endgame actually ends! This feels like a massive milestone\u2014you can now play the <i data-path-to-node=\"27\" data-index-in-node=\"84\">Kult<\/i> EGA port from start to finish. Next week, I&#8217;ll focus on polishing, chasing down the remaining EGA-specific rendering quirks, and ensuring the whole experience is as stable as possible.<\/p>\n<p data-path-to-node=\"28\">Thanks for reading \u2014 see you next week!<\/p>\n<\/div>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>The Endgame That Ate Itself Welcome back! After last week&#8217;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\u2019s when the final boss fought back\u2014not in the story, but [&hellip;]<\/p>\n","protected":false},"author":31,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2],"tags":[],"class_list":["post-45","post","type-post","status-publish","format-standard","hentry","category-gsoc-2026"],"_links":{"self":[{"href":"https:\/\/blogs.scummvm.org\/andy\/wp-json\/wp\/v2\/posts\/45","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/blogs.scummvm.org\/andy\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/blogs.scummvm.org\/andy\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/blogs.scummvm.org\/andy\/wp-json\/wp\/v2\/users\/31"}],"replies":[{"embeddable":true,"href":"https:\/\/blogs.scummvm.org\/andy\/wp-json\/wp\/v2\/comments?post=45"}],"version-history":[{"count":3,"href":"https:\/\/blogs.scummvm.org\/andy\/wp-json\/wp\/v2\/posts\/45\/revisions"}],"predecessor-version":[{"id":49,"href":"https:\/\/blogs.scummvm.org\/andy\/wp-json\/wp\/v2\/posts\/45\/revisions\/49"}],"wp:attachment":[{"href":"https:\/\/blogs.scummvm.org\/andy\/wp-json\/wp\/v2\/media?parent=45"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/blogs.scummvm.org\/andy\/wp-json\/wp\/v2\/categories?post=45"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/blogs.scummvm.org\/andy\/wp-json\/wp\/v2\/tags?post=45"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}