Categories
extras

Inside DT: ScummVM’s Director Debugger

When you fix a bug in a normal program, you fire up gdb or lldb, set a breakpoint, and step through the code. But what do you do when the “program” is a Macromedia Director game from 1995, written in a scripting language whose interpreter died two decades ago?

You build your own debugger. This post is a tour of DT (debugtools), the ImGui-based visual debugger built into ScummVM’s Director engine, which I have been working on since February.

DT was started by other ScummVM developers before I arrived; my work has been rebuilding the Score window, adding several new windows, and hardening the whole thing. Consider this the companion piece to my weekly posts, the missing chapter about where all the “DT:” commits actually go, with the parts I built called out along the way.

Why a game engine needs its own debugger

ScummVM’s Director engine is a reimplementation of the Macromedia Director runtime. Director games are “movies”: a timeline (the score) full of sprites, backed by a library of assets (the cast), all glued together with scripts written in Lingo.

When a game misbehaves, the bug is usually not in ScummVM’s C++ but in the interaction between the game’s Lingo scripts and our reimplementation of Director’s behavior i.e it’s a behavior difference which can be fixed in the C++ code.

But, a C++ debugger can’t help much there. What you actually want to see is: what frame is the movie on, which sprites are on which channels, what script is executing, what are the Lingo variables, and what did the original Director do differently.

DT answers those questions. It runs inside ScummVM itself, drawn with Dear ImGui, and you get it by launching any Director game with --debugflags=imgui. The game keeps running in its window while the debugger windows float around it, live.

This is the debugger layout I currently use. Layouts can be saved / loaded by clicking view > save state / load state.

The white outlines you see around the objects is enabled using the draw all command in the console debugger (see immediately below).

The older sibling: the console debugger

DT is not the engine’s first debugger. One directory up, in engines/director/debugger.cpp, lives the classic console debugger: a gdb-style text prompt (it literally greets you with lingo)) reachable through ScummVM’s debug console. It speaks the vocabulary you would expect, bpset, step, next, finish, bt for backtraces, disasm for bytecode, plus Director-specific commands like channels, cast, and markers, and even a small Lingo REPL for evaluating expressions against the running movie.

The two debuggers are complementary rather than competing. They share the same underlying machinery, breakpoints set in one are visible in the other, and the same interpreter hooks drive stepping in both. The console is precise and scriptable; DT is spatial.

The architecture in one diagram

The whole debugger lives in engines/director/debugger/, about 8,600 lines across a dozen files, and follows a simple shape. debugtools.cpp is the orchestrator: it owns the ImGui entry point, and every frame it calls a show*() function for each window. dt-internal.h holds the shared debugger state, one big struct that remembers which windows are open, what is selected, cached textures, script history, themes, and so on. Each dt-*.cpp file is one window, and each window reads directly from the live engine objects: the current movie, its score, its casts, the Lingo state.

what data is sent where

Because ImGui is an immediate-mode UI, there is no retained widget tree. Every single frame, each window re-reads the engine data and redraws itself from scratch. That sounds wasteful but it is exactly what makes the debugger feel “live”: whatever the engine is doing right now is what you see, with no synchronization layer in between.

The Score window

This was my first big project, and it is still my favorite. The score is Director’s timeline: a grid where rows are channels and columns are frames, and each cell says which cast member is on that channel at that frame. The original Director authoring tool had a famous score window, and game logic constantly jumps around the timeline, so you really want to see it.

The first version of the window was a plain ImGui table. It worked, but it could not look like Director’s score, and it fought the framework on things like custom cell decorations. So I rewrote it using ImGui draw lists, which are essentially a canvas API: you get rectangles, lines, triangles and text, and you draw the entire grid yourself. That rewrite (my first merged PR of the project) opened the door for everything that came after.

score of the imgui debugger

What the score window does today:

  • Sprite spans: consecutive frames where a channel holds the same sprite are drawn as one continuous bar with a start circle and an end square, exactly like Director drew them. Computing these spans means comparing every sprite against its neighbors across the whole score, so the result is cached per movie.
  • Display modes: the cells can show the cast member name, the behavior script, ink type, blend, location, or an extended multi-row view that shows all of them at once.
  • The main channels: tempo, palette, transition, and the two sound channels get their own rows above the sprite grid, again matching the original tool.
  • Navigation: horizontal and vertical scrolling (including mouse wheel), frame labels above the ruler, a playhead that tracks the current frame, and a center button that snaps the view to the playhead.
  • Interaction: clicking a cell selects the sprite and shows its details in an inspector strip (position, ink, blend, bounding box, flags), and double-clicking a frame jumps the movie there. That last one is dangerously fun.
score in the original director 4

The Cast windows

The cast is Director’s asset library: bitmaps, text, sounds, palettes, film loops, scripts, all numbered members. DT has a Cast browser with list and grid views, type filters, and thumbnails rendered from the actual cast member data, plus a Cast Details window that shows every property of a selected member, organized the same way Director’s own property dialogs were.

Some pieces of this I am particularly happy about:

  • The film loop viewer. A film loop is an animation packaged as a cast member, and internally it has its own miniature score. So the details window renders a miniature score grid for it, with its own playhead and frame stepping, plus thumbnails of the sprites in the current frame.
  • Sound playback. Sound cast members get play and stop buttons, sample rate and channel info, and a table of cue points. The preview plays through the engine’s own sound manager on a reserved channel, so what you hear is what the game would play.
  • The image viewer. Clicking a bitmap or text member opens a dedicated viewer with zoom, pan, fit-to-window, and for text members, a tab showing the raw text with a copy button. Sounds trivial, but when you are comparing a rendered bitmap against a reference screenshot pixel by pixel, zoom and pan stop being luxuries.
mini filmloop viewer in the cast details window

Scripts: reading decompiled Lingo

Director movies do not ship with Lingo source code, they ship compiled bytecode. DT shows you readable Lingo anyway, courtesy of LingoDec, a decompiler that reconstructs an AST from the bytecode. The script windows walk that AST and render it with syntax highlighting: keywords, builtins, literals, comments, each in their own color, with a bytecode view one toggle away.

And the scripts are not just text. Handler calls are links, click one and you jump to its definition. Variables have an eye icon, click it and the variable is added to a watch list. Each line has a breakpoint gutter.

The navigation used to be one floating window per handler, which collapsed the moment two scripts from different cast libraries shared a member number, since the windows were keyed by that number. I replaced it with a single Scripts window that works like a browser: an ordered history, back and forward buttons, and a dropdown of everything you have visited. Go-to-definition pushes onto the history, back pops you out.

the scripts window

Breakpoints plug into the Lingo interpreter itself. When execution pauses, the Control Panel offers step over, step into, and step out, implemented as small predicate functions that the interpreter calls after each instruction to decide whether to keep running.

Here is the entire step-over logic, to show how small these predicates are:
The interpreter calls this after every instruction while running. Step into and step out are the same idea with the conditions changed: step into pauses on any line change or callstack change, step out only when the callstack gets shorter. The debugger does not drive the interpreter, it just answers “should we stop here” when asked.

The Execution Context window shows the call stack per engine window, and clicking a stack frame opens that handler at the paused line.

movie paused at a breakpoint

Finding things: Search and the Windows panel

A Director game can contain hundreds of scripts across multiple cast libraries and a shared cast. The Search window greps them all, with modes for handler names, variable names (properties, arguments and globals), and full script bodies, the last one by decoding the compiled bytecode instruction by instruction. Results open in the script browser, and the matched text gets highlighted in the rendered script.

The Windows panel came out of debugging multi-window games (Director movies can open other movies in windows, and yes, that is as messy as it sounds). It lists every loaded window with its movie, play state and frame position, and below that, every .DIR file found in the game directory, click one and the engine navigates to it. That turned out to be the fastest way to explore a game’s movies one by one.

the search window

Two more windows deserve a mention here. The Vars window shows every global, local and property variable live, with changed values highlighted, and any variable can be added to a watch list that logs every write along with the script that did it, which is how you catch the question “who keeps resetting this flag”. And the Archive window is a raw resource browser: every chunk in the movie file, viewable as a hex dump, for the days when the bug is below the level of sprites and scripts entirely.

A real session

To make this concrete, here is roughly how the post office investigation from my last post went through DT (see https://blogs.scummvm.org/ramyak/category/week-6/).

The stamp snapped back on drop, so: open the Score window and find which channels the stamp and slot live on. Click the slot’s sprite, see it script in the inspector, click through to the Scripts window and read the decompiled mouseUp handler.

Set a breakpoint on it, drag a stamp in the game, and watch the breakpoint never fire.

That single observation, visible in seconds, is the whole bug. The rest was C++.

Everything breaks, including debuggers

A recurring theme this summer: the debugger observes a live engine, and live engines change under you. Movies get switched, casts get destroyed, windows get closed, and every raw pointer the debugger cached becomes a landmine. A good chunk of my June work was a sweep through the whole debugger fixing null dereferences, out-of-bounds accesses, stale pointers and memory leaks, several of which I found by reading the code, looking at crash backtraces etc. and asking about every stored pointer: who deletes this, and does the deleter know we kept a copy?

That exercise changed how I write the feature code too. It is one thing to be told “don’t cache raw pointers to engine objects”, it is another to watch your own cast details window explode because the movie you were inspecting no longer exists.

None of this happened in a vacuum. Every one of these PRs went through review, and the pattern of feedback shaped the debugger more than any single feature: sev pushing back on fixes that need rework, and me reflecting on the review to make the code better.

What is left

DT is genuinely useful today, I use it daily to debug the Gus games, but there is plenty on the wishlist like making it more bullet proof, finding edge cases, adding new features.

If you want to try it: build ScummVM with ImGui support, add --debugflags=imgui to any Director game, and press Ctrl+2 through Ctrl+4 to toggle the main windows.

Things worth knowing on day one: Ctrl+F1 toggles mouse capture, so your clicks stop reaching the game while you arrange windows (hold Shift to click through temporarily); debugger windows can be dragged entirely outside the main ScummVM window if multi-viewport is enabled in Settings; and there is a light theme in Settings for people who debug in daylight. Bug reports welcome, I have become quite good at reading the crashes.

The PRs behind this post

New Score window GUI with draw lists
Score scrolling and frame labels
Score center button and QOL changes
Variable watch logging and script search
Film loop score viewer
Sound cast member audio controls
Cast viewer crash fix and film loop regression fix
Image and text viewer window
Bug and crash sweep through the visual debugger
Search redesign and Windows panel
Cast details improvements and browser-style script navigation
Script viewer rendering fix

Appendix: what each file does

For anyone who wants to hack on DT, here is a map of engines/director/debugger/:

    • debugtools.cpp / debugtools.h: the orchestrator. Owns the ImGui lifecycle (onImGuiInit, onImGuiRender, onImGuiCleanup), the main menu bar, the keyboard shortcuts, and the theme definitions. Also home to the shared helpers everything else leans on: the texture cache for cast member thumbnails, toImGuiScript() for turning a handler into something renderable, and the script context lookups.
    • dt-internal.h: the shared state. One big ImGuiState struct that remembers which windows are open, current selections, script history, search results, cached vars, themes, everything that has to survive between frames. If two windows need to talk to each other, they do it through this struct.
    • dt-cast.cpp: the Cast browser window. List and grid views, type filters, name filter, thumbnails.
    • dt-castdetails.cpp: the Cast Details window with per-type property tabs (bitmap, text, rich text, shape, sound, film loop), the film loop mini-score viewer, and the image viewer with zoom and pan.
    • dt-controlpanel.cpp: playback controls (play, stop, rewind, frame stepping) and the Lingo stepping buttons. The step over/into/out predicates that the interpreter consults live here.
    • dt-lists.cpp: the grab bag of list windows: Vars (globals, locals, properties), Watched Vars with the write log, the Breakpoints list, the Archive resource browser with a hex view, and the Windows panel.
    • dt-score.cpp: the big one. The Score window (grid, spans, ruler, playhead, main channels, sprite inspector) and the Channels window showing the live state of every channel in the current frame.
    • dt-scripts.cpp: the Scripts window with its browser-style history, the Functions list, and the Execution Context window with per-window call stacks.
    • dt-script-d4.cpp: the renderer for decompiled Lingo. Walks the LingoDec AST and draws syntax-highlighted code with the breakpoint gutter, current-statement marker, and clickable handler calls. Used for Director 4+ bytecode.
    • dt-script-d2.cpp: the same job for older movies (D2/D3), which ScummVM compiles from source itself, so this walks ScummVM’s own AST instead of LingoDec’s.
    • dt-search.cpp: the Search window: handler names, variable names, and full body search by decoding bytecode.
    • dt-save-state.cpp: layout persistence. Serializes open windows, ImGui window positions, and settings to JSON so your debugging setup survives restarts.
Categories
Week 6

Return to Sender: Fixing Gus’s Post Office

This week was about closing out a drag-and-drop bug that had been bothering me, building test movies in real Director 4, and finally facing the AddressSanitizer. Let me walk through what happened.

The Post Office Bug

The centerpiece of the week. In Gus Goes to Cyberopolis, the post office letter minigame was broken: dragging a stamp onto the letter’s slot made it snap back to its tray every time, making the minigame unwinnable.

Some quick background for this one. A Director movie is composed of sprites, visual objects placed on numbered channels, and each sprite can have a script attached that reacts to events like mouseDown and mouseUp. Lingo (Director’s scripting language) also has a property called the clickOn, which returns the channel number of the sprite the user last clicked.

The game’s logic is simple: the stamp’s script handles mouseDown and starts the drag, and the slot’s script handles mouseUp and places the stamp. On release, the slot’s script asks the clickOn which channel was involved and checks whether it holds an empty slot. So for a drop to work, two things must happen when the mouse is released: the mouseUp event must be delivered to the sprite under the mouse (the slot, not the stamp), and the clickOn must return the slot’s channel.

Neither was happening in ScummVM. For Director 4 movies, mouseUp was being delivered using the sprite remembered from the original mouseDown, so the stamp’s script received the mouseUp. The stamp has no mouseUp handler, so the event fell through to the frame script, whose fallback logic snaps the stamp back to the tray. And the clickOn still pointed at the stamp’s channel from the original click.

Understanding this took a detour through Director’s message hierarchy, events are offered to the sprite’s script first, then the cast script, the frame script, and finally the movie script, with each level able to stop or pass the event along. Reading the game’s four scripts against a debugger session made it click.

The fix: deliver mouseUp to the sprite currently under the mouse for Director 4 movies, and update the clickOn when mouseUp lands on a sprite, so drop-target scripts can identify the target channel. Stamps now stick to letters.

NOTE: the fix is not final yet. Further discussion with sev will decide its final shape.

Building the Regression Test

Sev’s condition for the fix was tests, so I made some test movies in Director 4. The test suite in the director-tests repo uses a plugin (an “XObject” in Director terms) that can inject input events, move the mouse, press and release the button, and assert on the order in which handlers run, using a global counter.

The new test places two sprites on stage, presses the mouse on sprite 1, moves to sprite 2, and releases. Sprite 1’s script asserts it received the mouseDown; sprite 2’s asserts it received the mouseUp. Without the fix, sprite 2’s handler never fires and the assert count comes up short; with the fix, everything passes.

My first version attached the handlers to the cast members instead of the sprites, and the test failed for the wrong reasons, the handlers need to be sprite scripts to exercise the exact dispatch path the fix touches. I also added a screenshot call to last week’s text wrapping test, so that fix now has visual regression coverage against a reference image captured from real Director 4.

Film Loop Position Shift

A film loop is a Director cast member that packages a small animation so it can be placed on a channel like any static image. In the Kooky Carnival’s shooting gallery, animal sprites jumped to the wrong position when clicked.

When a script swaps a sprite’s image for another cast member at runtime, the new member may use a different registration point, the anchor that decides how the image is positioned. Normal bitmaps anchor at the top-left; film loops anchor at their center. ScummVM wasn’t adjusting the sprite’s position for that difference, so the sprite visually shifted by half its size on swap.

The fix saves the sprite’s on-screen bounding box before the swap and adjusts its position afterward, so it stays put. I had pushed a similar solution a long while ago, but it was in the wrong place. This one works.

What not to do

I had some uncommitted changes sitting on the buildbot server, which broke the imagediff tooling.

A refactor by sev had moved code from imagediff.py into main.py, and two things were left broken. First, main.py imported its config before running the sys.path bootstrap, so the dashboard could not load from a fresh start at all (ModuleNotFoundError: config). Second, screenshot_diff.py still imported from the old imagediff/imagediff.py module that no longer existed, so every ScreenshotDiffStep failed.

The fix moved the bootstrap before the config import, pointed the diff step at the new module, and cleaned out leftover debug prints and dead code. Merged.

Fortunately the uncommitted changes on the server were trivial and I was able to revert them.

Lesson learnt: do not leave unfinished work on prod servers (-_-) (use git, github).

My First Use-After-Free

While testing one of the Cyberopolis movies, ScummVM crashed when switching movies, but only in my AddressSanitizer build. This was my first time actively reading an ASAN report. As I was looking into this, I asked sev and after looking at the backtrace he found 2 commits which were the likely cuplrits.

The older commit, related to some changes in movie cast members, was the culprit. As this is a difficult bug, this will be taken care of by sev.

The visual debugger

Some DT changes have been made locally, but will finalize them and push the commits soon. Mostly bugs and crashes, nothing very interesting. And the DT blog is almost ready. Wasn’t able to work on it for a few days. Will publish in a day or two.

Next up:
– work on an animation speed bug
– continue on gus bugs

PRs this week:
Fix mouseUp dispatching to wrong sprite in D4 (not merged, in discussion)
Add D4 test movies for mouseUp dispatch and text wrapping (director-tests)
Fix filmloop position shift when swapping cast member via Lingo
ImageDiff cleanup

Categories
Week 5

Fixing Scripts, Text, and Finding Gus

This week had a mix of bug fixes, debugger improvements, and game detection work. Let me walk through what happened.

Before I get into bugs, a quick note: I will be posting a separate blog on the visual debugger soon. Here is a snippet from a data flow diagram for the dt:

An Unresolved Bug

While testing Gus Goes to Cybertown’s Science Dome, I found a bug in the Sink or Float minigame: dropping the golf ball or spoon shows Prof. Gus’ face instead of the drop animation.

I traced it to cast member 525 (s_fqt, a digital video) on channel 14, which spans frames 7350 – 7359 during the drop animation.

In the original Director 4, this sprite is effectively invisible, its bounding box appears as three vertical dots on stage, not selectable or resizable. For the spoon case, no bounding box appears at all. ScummVM renders it at full size (240×180) instead.

The score stores the correct dimensions, setCast is called correctly, and the bbox passed to createWidget is right. So ScummVM is reading the data correctly – the original Director is doing something to suppress this sprite that we aren’t replicating yet. The likely culprits are the ink mode or a puppet flag, but I haven’t confirmed which yet.

Sev’s advice was to study the score at the exact frame where the original renders correctly; check ink, dimensions, and puppet flag,  and look for any Lingo touching that channel. If the cause still isn’t clear, the plan is to strip the movie down to just the affected frames and cast members as a minimal test case, which can then live in the test suite to prevent future regressions.

This one is staying open for now. Sev also pointed out that since this bug is complicated, it’s worth finishing a sweep of all the Gus games first to find something simpler to close out, which is the plan for the coming week.

Text Wrapping 

The next bug I tackled was text wrapping in D4 text cast members. Some labels like “Hamburger” were incorrectly wrapping as “Hamburge\nr”, one character too early.

The root cause turned out to be two separate issues. MacText treats maxWidth as the outer width and subtracts border, gutter, and shadow internally, so I was passing the wrong value and the effective inner wrap width was narrower than the cast member’s designed text area.

On top of that, wordWrapTextImpl was using > instead of >=, meaning text that exactly fills the available width was being wrapped onto a new line when it shouldn’t be.

Both are easy fixes in isolation, but finding them required understanding how the text layout pipeline chains together across three layers. The fix ships with a regression test in director-tests.

Debugger (DT): Script Viewer

While testing Gus Goes to Cybertown, clicking on some cast members in the Cast window showed the script tooltip on hover but opened nothing on click. The script viewer window stayed empty.

The issue has two parts. First, scripts using internal generic event handlers (scummvm_generic) were failing because getHandler() matched by handler.name == handlerId exactly, but generic event handlers store an empty name and are only identified by the isGenericEvent flag.

Matching on that flag when handlerId is “scummvm_generic” fixes it.

Second, some cast members return null from getScriptContext(), the root cause is still under investigation, but the scripts do exist and their source text is available in CastMemberInfo::script.

As a workaround, addToOpenHandlers() now falls back to displaying the raw Lingo source text when no compiled AST is available.

Sev shared a different D8 movie that had the same issue, which confirmed it’s not game-specific. The workaround covers that case too.

Gus Goes to Cybertown: Detection Entries

Gus Goes to Cybertown has three distinct Windows versions: Retail (Director 3), Retail Revised (Director 4), and Golden Master (Director 4). Each has different file sizes and hashes, so three separate detection entries were needed.

That was a summary of most of the things I did this week.

My next immediate goal is to fix the remaining gustown errors, and add it to release.

PRs this week:
Fix text wrapping in D4 text cast members
D4-win director-tests repo changes for the above PR
Fix script not rendering in script viewer
Add detection entries for Gus Goes to Cybertown

Categories
Week 4

Game Detection and ImageDiff Integration

Tuesday

Not a lot happened this week because I was travelling.

the hfs file system

The file system used by old mac computers was the Hierarchical File System (HFS). And for director engine that meant a bunch of issues, like allowing the weird file names (solved by punycode) and resource forks.

Mac files have two parts: a data fork and a resource fork. For Director games, the projector executable lives in the data fork, but the startup movie (the first thing the game loads) is in the resource fork.

When ScummVM detects a Mac Director game, it hashes the resource fork specifically (using the r: prefix in detection entries) rather than the data fork, because the data fork is just the generic Director player and would be identical across many games.

This week I added detection entries for Gus Goes to Cyberopolis, both Windows and Mac versions. The process was interesting: you add a placeholder entry with a garbage hash, compile, point ScummVM at the game folder, and it reports back the real hash and file size for you to fill in.

I also learned why two-file detection entries (MACGAME2, WINGAME2) matter: with a single file, ScummVM just picks whichever game has the most files matching, which can cause false matches when multiple games share a disc. Two files makes the match unambiguous.

For Windows, it’s a bit different. The .exe file is actually three things concatenated together: the generic Director projector code, the startup movie, and a small header at the end that stores the offset to the movie. The executable would read its own tail to find the header, then seek back to load the startup movie. This is why ScummVM can’t hash the first 5000 bytes of a Windows Director executable, they’re always identical across every game built with the same Director version. Instead it hashes the last 5000 bytes (t: prefix), which come from the embedded startup movie and are unique to each game.

ImageDiff Integration (final)

I also worked on integrating ImageDiff into the ScummVM buildbot.

I have mentioned about the imagediff tool in my past blogs in detail. It was currently running on a detached tmux session, which is kind of a hack, so sev gave me some resources to read and integrate the tool into actual buildbot.

This process unfortuntely took a lot of time for me, because this was my first time in a long time dealing with a different kind of code.

The integration used a buildbot plugin called buildbot-wsgi-dashboards, which lets you embed a Flask web app directly into the buildbot UI. Getting it to work involved fixing a few issues. Most of the issues were trivial but there was this one issue which was causing a lot of problem and I wasnt able to figure out the root cause for, for the longest time.

The issue was, whenever I loaded the imagediff page on the buildbot, it would load unreliably i.e every time I would refresh the page, I couldn’t predict whether I would get a “Resource not found” error or the page would actually load. On top of that the table wasn’t loading properly.

The fix was actually two separate issues. First, the environment variables weren’t being loaded early enough, the SCREENSHOTS_DIR variable was being read at import time before the .env file had been loaded, so it defaulted to ./screenshots/ which didn’t exist on the server. Adding load_dotenv() at the top of config.py fixed that.

The second issue was the JavaScript on the frontend constructing API URLs without the correct path prefix. Since ImageDiff is served under /plugins/wsgi_dashboards/imagediff/, a hardcoded /api/target_data/... URL would 404 every time. The fix was deriving the base path dynamically from window.location.pathname at runtime. That’s why the page was loading unreliably, depending on timing and caching, sometimes the old URL worked by accident and sometimes it didn’t.

After sorting all of that out, ImageDiff is now properly integrated into the ScummVM buildbot at john.scummvm.org. But it’s still rough around the edges, and the remaining issues will be handled by sev.

PRs:

Imagediff

Detection Entry

 

Categories
Week 3

Read the Error

Wednesday

This is the follow-up to the previous post.

The Blunder

The first task was integrating ImageDiff into the buildbot repo.

The first PR was wrong immediately. I added the files directly without bringing in the git history from the original ImageDiff repo. Sev had asked for history. The PR wasn’t mergeable, and sev fixed the git situation himself rather than have me fight with it. First mistake.

Then the actual blunder. When wiring ImageDiff into the buildbot’s Python pipeline, I hit an import error imagediff.py does from config import SCREENSHOTS_DIR at module level, which breaks when imported from a different directory. Instead of reading the error and fixing it, I asked an AI, got an importlib workaround, and pushed it.

Sev said in the chat, “please don’t do that anymore, asking AI and pushing the slop I mean.”

The correct fix was a one-line sys.path insert, something I’d have found in two minutes if I’d just read what the error was saying.

Deployment

After the ImageDiff PR merged, I was hesitant to deploy directly on the buildbot server as I didn’t want to break anything.

In response I was given the green light to break it, because we already have a VM snapshot saved.

Deployed, it worked, buildbot was up with ImageDiff integrated.

The Misunderstanding That Cost the Most Time

Once it was running, sev noticed the tool was timing out on target: theapartment-mac, he found it and pointed toward the cache logic.

That was a part of it. The cache logic was kind of flawed. But we had a bigger elephant in the room.

The real problem was something I hadn’t understood about how the tool was supposed to work at all.

The movie_diff() function was opening every PNG frame from both builds with PIL, running ImageChops.difference() on each pair, and checking to detect any pixel difference.

But it was completely wrong. The Director engine already does the pixel comparison during playback, in score.cpp. It compares each new frame against the stored reference using a pixel difference threshold, and only saves the file to disk if the difference exceeds that threshold.

So, file presence is the diff signal.

The fix was replacing all the PIL logic with: does any file matching {movie}-*.png exist in the comparison build’s directory?

I didn’t know the engine had this mechanism. PIL was redundant work the engine had already done.

The Punycode Crash

After the performance fix, a new crash appeared:

ValueError: chr() arg not in range(0x110000)

The movie name causing it was xn--xn--File IO-oa82b-. Sev explained why this exists.

Mac HFS allowed file names with characters that would be illegal on any normal filesystem  /, *, newlines, etc. “The Apartment” has movies named things like File I/O and •Main Menu.

To store these on a normal filesystem, sev designed an encoding scheme based on Punycode: files with special characters get an xn-- prefix, the special characters are removed from the name, and their positions are encoded in the tail.

xn--xn--File IO-oa82b- is doubly encoded, it went through the encoder twice, which is valid, it just needs two decode passes to get back to File I/O.

The bug was in decode_string(). There was a loop that stripped trailing dashes from the string before handing it to the punycode decoder:

i = len(orig) - 1
while i >= 0 and orig[i] == "-":
i -= 1
orig = orig[:i+1]

For xn--xn--File IO-oa82b-, this strips the trailing - and corrupts the input before the decoder even runs. Removing it was the entire fix. Without the loop, Python’s punycode decoder handles the string correctly.

Director Debugger

Three things on the dt-new branch:

Cast Details Panel: was showing ... for almost everything. Now shows : script text previews on hover with click-through. The old showScriptCasts pipeline was removed entirely everything now goes through renderScript.

Scripts Window: decoupled from the execution context, which previously shared state with it. The scripts window now has its own handler list and browser-style back/forward navigation.

Also fixed a bug where the Lingo/Bytecode toggle was a single global flag shared across all open handlers.

Keyboard Shortcuts: Cmd+2/3/4 toggle Control Panel, Cast, Score. On Mac, cmd instead of ctrl must be used.

PRs

scummvm-sites #39 · #40 · #41 · scummvm #7577

 

Categories
Week 3

Before I disappear

Tuesday

No, this is not my last post. I am just rushing because today is the last day to post for the previous week and I am not sure how long will I have a stable internet connection.

I will not be online (probably) for the next 2 days, because I am in a very remote location (mountains) so internet access might be a problem. So, this post will get straight to the point.

I made a very big blunder this week, and a lot of code was rushed. But the week ending was good. Got to learn a lot. And very interesting story to tell.

I will make a detailed post for this week after I get a stable internet connection.

See you soon.

Categories
Week 2

Following the Warning Lines

Tuesday

This week I finally got SSH access to john.scummvm.net, the machine that runs the Director buildbot. I’d seen its output for weeks (those BUILDBOT: warning lines I kept triggering) but had no idea how it actually worked. Getting access and reading through the code was very satisfying because I finally met the machine that had been yelling at me for the past couple months.

The Buildbot Architecture

buildbot_diagram

How a build starts

The first thing that clicked was how the build is scoped. The buildbot watches the ScummVM GitHub repo via a webhook, but it doesn’t rebuild on every commit. master.py checks whether any changed files are in engines/director or graphics/macgui. If yes, it kicks off a build. If not, it ignores the commit entirely. Simple filter, makes sense.

Triggering the tests
Once the ScummVM binary is compiled and uploaded to the master, build_factory.py calls steps.Trigger(schedulerNames=["Director Tests"]). This tells the buildbot master to fire the Director Tests scheduler, which is a Triggerable scheduler defined in master.py. That scheduler then queues up all the test builders at once; one for each entry in targets.json, so they all start running in parallel on the available workers. The build step waits (waitForFinish=True) for all of them to complete before marking the overall build as done.

Running the tests and error checking

The test runners themselves are defined in targets.py. Each one downloads the binary, rsyncs game files from /storage, and runs ScummVM against a list of movies. Screenshots are saved to /home/director-buildbot/screenshots/ on every run. Error checking is done in steps.py, which watches the output for lines matching “BUILDBOT: incorrect check for line:” – that’s the log-replay mechanism from the director-tests repo, where expected output is recorded directly into the test movie file, and on each buildbot run the live output is compared against it line by line.

Reading through this, the separation of concerns became clear: master.py handles scheduling, build_factory.py handles the build pipeline, targets.py defines what gets tested, and steps.py defines how a single test run behaves. Once I had that mental map, the rest followed quickly.

The ImageDiff tool

pic of the tool from dev chat

One thing the buildbot produces but doesn’t analyze automatically is screenshots. Sev pointed me to ImageDiff, a tool built to catch visual regressions, cases where the engine produces slightly different output without triggering any log-based errors.

It’s a Flask web app that reads from the screenshots directory and shows a frame-by-frame diff between any two builds for a given target and movie. The core logic uses PIL’s ImageChops.difference to compute a pixel-level diff. If there’s any difference, the diff image is rendered alongside the source and comparison frames so you can see exactly what changed. Results are cached to disk so repeated comparisons don’t recompute.

I temporarily deployed it on john.scummvm.net on port 5002. The two targets currently generating screenshots are director (from the director-tests-* folders) and theapartment-mac, both of which showed up with their full build history. It’s a simple tool but it fills a gap that log-replay can’t, visual changes.

Bugs

This week I did not work on any game bug fixes. I only made changes to the visual debugger and its bugs.

The gus games bugs are mostly fixed already and the remaining one bug has not been very consistently reproducible. So, this week I’ll try to find out how to replicate it.

Here is a brief on the DT changes:

Windows Panel

  • Added a new Windows panel showing all currently loaded windows and all .DIR movie files in the game directory
  • Clicking a movie navigates to it via func_goto

Search

  • Added variable search mode to the search bar
  • Improved search with new modes (Handlers, Variables, Body) and a cleaner 3-column results table with keyboard focus on open

PRs:

https://github.com/scummvm/scummvm/pull/7564

https://github.com/scummvm/scummvm/pull/7553

last week’s changes: https://github.com/scummvm/scummvm/pull/7563

What I am currently working on

  • Currently I am working on some more DT changes.
  • Working on the checksum function.
    • Some Director movies have a VWCF resource with a checksum that our implementation fails to verify correctly, causing a crash when navigating to those movies in the debugger.
    • Sev suggested – before diving deep – running the mismatched movies through ProjectorRays first to see if it also miscalculates, that way we know whether the bug is in our implementation or the movies themselves. The code once completely written, will be identical to the Projector Rays checksum code.

P.S. The buildbot integration will be re-worked soon by one of our devs rvanlaar, so the current architecture might not be relevant in the future

Categories
Week 1

Catching momentum

Sunday

Coming back to a large codebase after a break is disorienting.

I returned this week after a 3-week gap and barely recognized the code I’d been working on. So I planned the week around small, finishable tasks. Nothing ambitious. Just things I could complete and feel good about, to rebuild momentum.

Start small

Sev assigned me my Planka board (the task tracker we use) and pointed me toward a set of Gus games: Gus Goes to Kooky Carnival, Cybertown, and a few others. I went through the tickets, broke each issue into its own card, and we had a Zoom call where he helped me set up the environment properly.

This call is worth describing because it’s a good example of what mentorship looks like here. We talked about Dumper Companion (a tool for inspecting Director movie internals), useful ScummVM debug flags, debugging workflows. The non-technical conversation ended up being just as helpful for reorientation as the technical one. If you’re new and a maintainer makes space for this kind of call, take it.

cast viewer in the visual debugger

It wasn’t loading shared cast members. I fixed that and removed some duplicate code while I was there. Small, satisfying, done. That’s the point of starting small.


Bug 1: Crashing on empty cast slots

The hard part of a fix is often defining the boundary correctly, not writing the code

In original Director, if you set a property on a cast member slot that exists but is empty, it fails silently. ScummVM was throwing an error, which crashed games that relied on that behavior.

Imagine cast slots are numbered 1 through “b”, where “b” is the last populated member. Accessing slot “a” (within bounds but empty) should fail silently. Accessing slot “c” should throw an error. The bounds are defined by the last populated cast member, not the total allocated size.

The fix seemed obvious: skip the error if the cast member isn’t found. But that would also swallow errors for genuinely out-of-bounds IDs, real bugs you’d want to catch. The problem wasn’t the error. It was defining “in-bounds” correctly.

test movies

This is also where Sev introduced me to test movies, minimal Director movies that reproduce a single buggy behavior in isolation. Instead of loading an entire game to verify a fix, you create the smallest possible movie that triggers the issue. Much faster, much cleaner.

The solution: teach ScummVM to distinguish between an empty cast slot within a valid range and a genuinely invalid cast member ID (error).


Bug 2: Sprite dragging not working

The symptom and the cause can live in completely different systems

In one of the Gus games, clicking a puzzle piece should let you drag it around the screen. In ScummVM, clicking did nothing. The piece stayed frozen.

I assumed the drag logic was broken. It wasn’t. The drag code was fine. The real issue was buried in the event pipeline, and finding it meant tracing the entire event flow from click to handler.

ground truth testing

I needed to confirm how Director 4 actually behaves, so I ran the original Director inside Basilisk II (a classic Mac emulator) to observe the real immediate sprite property. You can’t rely on documentation alone (shocker for me), some features are marked obsolete but still functional, and behavior varies between versions.

Here’s what immediate does in Director 4 (5 & 6 too):

normally, mouseDown fires on press and mouseUp fires on release. When immediate is set on a sprite, both mouseDown  and  mouseUp  fire on the press.

The drag handler works by running a repeat while the stillDown loop inside on mouseUp. If mouseUp only fires after the physical release, stillDown is already false and the loop exits instantly. Nothing moves.

The fix was in queueEvent(): when a press arrives on an immediate sprite in D4, queue both mouseDown and  mouseUp  events immediately, then suppress the physical mouseUp later to prevent double-firing.

version boundaries

sev pointed out something: does immediate behave the same in D3? Does it stop working in D5? 

Turns out immediate works in D4, D5, D6. Not tested D3 yet. Will update this after.

the director-tests repo

Instead of loading full games to test a behavior, we use the director-tests repo, a collection of minimal test movies that verify specific Director engine behaviors. You create the smallest possible movie that reproduces just the thing you care about, and it lives in the repo as a permanent regression test.


What I’m aiming for next week

  • Work through more Gus bugs. 

  • Look at build-bot issues. 

  • Go deep on one subsystem end-to-end.

  • Improve tests and stress the visual debugger.

Categories
Introduction

Day 0

Hi, I am Ramyak Sharma, a third year computer engineering student. I like C++ and systems, so this summer is a great opportunity to learn from my mentor Sev and some of the best engineers in reverse engineering, and hopefully be one step closer to becoming a good engineer myself. I will be working on the Director Engine.

Today is the 24th of May, 2026. My work officially starts tomorrow. Because of my finals, I haven’t been able to catch up with everything yet, so I’ll be reading through the code and skimming through some Director books. My first task will be completing film loops and movie cast members.

See you in the next blog.