Introduction
This handbook documents the decompilation of Logical Journey of the Zoombinis (Broderbund, 1996), and everything built around it: the tools, the file formats, the decompiled source, and the port that runs it in a browser. It is written for someone joining the project who wants to find their way around, not for someone who only wants to play.
What this project is
The disc ships two builds of the game. The project targets the 32-bit Windows 95 build, zoombi32.exe: a PE32 executable compiled with Borland C++ 4.5 (about 634 KB), with its assets in Mohawk archives (DATA/*.MHK, the container format of Myst and Living Books) and one intro movie in QuickTime.
| Piece | Where | Status |
|---|---|---|
| Decompiled game and engine | decomp/ | Every function (about 2,100) is written as C++. 1,915 compile with Borland C++ 4.5 to the original's exact bytes; 176 differ in register choice; 37 are portable stand-ins for hand-written assembly. |
| Game data | assets/ | Every resource converted to a modern format (PNG, WAV, MIDI, TOML) and packed back into archives identical byte for byte to the disc's. |
| Tooling | src/zbtools/ | Python tools for extraction, a scripted Windows 98 VM, Ghidra, a function matcher, asset and movie converters, a rebuild and the port. |
| Rebuilt executable | uv run build | Linked with the original's linker; runs in the VM. |
| The port | port/ | The same decomp/, compiled unchanged for WebAssembly (and 32-bit native/headless) over SDL2, with a Win32 subset called miniwin. |
The long-term goal is source code that builds and runs on modern systems. The game's logic is done; the work that remains is mostly the last near-misses, playing the rebuilt game all the way through, and making the code 64-bit clean.
How to read this book
- New here? Start with Prerequisites and Setup, then How the pieces fit.
- Decompiling or matching functions? Read The workflow, Matching, and keep Matching BCC32 open.
- Looking for the code behind something you saw in the game? Go to Gameplay and the code: it walks through the game in the order a player meets it, with a code map for every screen and puzzle.
- Learning the engine? Read Architecture and the chapters after it.
- Working on the web port? Read The port.
- Editing this book? See About this book.
- Poking at assets? See Formats.
Conventions
- Addresses (
0x46be2e) are virtual addresses inzoombi32.exe. Every decompiled function carries one in a marker comment, sogrep -rn 0x46be2e decomp/finds it. - Commands are written
uv run <tool>; the tools are listed in the tool reference. - Names are camelCase for functions, variables and fields and PascalCase for types. Names recovered from RTTI are used exactly (
displayPort,DIB8Port). Names that aren't understood yet are after their address (fn_46be2e,g_4a7f58). - Block quotes that begin with a camera emoji and
Screenshot: <id>are screenshot placeholders: places where an image of the running game belongs (the first is in Gameplay and the code). See Screenshots for how to capture and add them.
Legal
The repository doesn't distribute the original game's binaries or its disc. It holds source reconstructed from them (the decompiled code, and the resources converted to modern formats, from which the tools rebuild the original archives), for preservation and interoperability. Building anything needs your own copy of the game. The Cornerstone font and the GeneralUser GS SoundFont have their own licences, noted in the README. The tools' generated report contains the game's disassembly and must never be published.
Prerequisites
Supported hosts: macOS on Apple Silicon (tested) and Linux (should work, untested). The tools check for anything missing and tell you how to install it; they never install host packages themselves.
Software
| Needed for | Software | Install |
|---|---|---|
| everything | uv | see its site |
| the Windows 98 VM | QEMU | brew install qemu; Debian/Ubuntu qemu-system-x86 qemu-utils; Fedora qemu-system-x86 qemu-img |
| the VM's setup floppy | mtools | brew install mtools, or the mtools package |
| reading the Borland CDs | 7-Zip | brew install sevenzip, or 7zip |
| Ghidra | JDK 21 | brew install openjdk@21, or openjdk-21-jdk / java-21-openjdk-devel |
| Ghidra's native decompiler where there's no prebuilt one (Apple Silicon) | a C/C++ compiler and make | xcode-select --install, or build-essential |
| the Borland compiler | Wine | macOS: downloaded for you into build/wine/ (needs Rosetta 2); Linux: the wine package |
| the port | the Emscripten SDK | downloaded for you by uv run port setup (~1.8 GB) |
| this book | mdBook | brew install mdbook or cargo install mdbook |
You don't need most of this for every task. Working only with assets/ or the port needs just uv (and the Emscripten SDK for the port); the matcher needs Wine, 7-Zip and the Borland CD; the VM needs QEMU and a Windows CD.
Why a VM, and why Wine for the compiler only? Rosetta 2 cannot run 16-bit Windows code, and the QuickTime installer the game needs contains some, so Wine can't run the game on Apple Silicon. The 32-bit Borland command-line tools run fine under Wine, so those use it.
Bring-your-own files
The original game and Windows media aren't in the repository. Put them in the gitignored data/ directory under these names (other paths can be passed as arguments):
| File | What it is | Needed for |
|---|---|---|
data/Logical Journey of the Zoombinis.iso | the game CD (e.g. from the Internet Archive) | extract-game, ghidra, match, assets extract/verify, the VM |
data/Windows 98 Second Edition.iso | Windows 98 SE install CD | vm install |
data/Borland C++ 4.5.iso | the compiler the game was built with (4.52 also works as Borland C++ 4.52.iso) | toolchain, match, build |
data/Borland C++ 5.02.iso | optional: for comparing the engine with a later compiler | match --release 5.02 |
Create a gitignored .env in the repository root with your Windows product key:
WINDOWS_PRODUCT_KEY=XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
data/ is read-only to the tools. Everything they write goes to build/ (also gitignored), and uv run clean removes it by category.
What works from a fresh clone
No bring-your-own files are needed for uv run assets pack, uv run port setup/build/package/serve, uv run lint and this book. The resources and the installed game's few files are committed under assets/, which is why the port builds without the disc.
Setup
Each step below is scripted; run only the ones you need (the previous page says which need what).
1. Extract the game
uv run extract-game
Copies the disc into build/disc/ and unpacks the Windows 95 build from the InstallShield 3 archive ZBARCHIV.Z into build/zoombi32/. Pass --iso PATH for a disc image elsewhere. uv run unpack-isz build/disc/ZBARCHIV.Z [outdir] lists or extracts an InstallShield archive directly.
2. The Borland C++ toolchain
uv run toolchain setup
Extracts BIN, LIB and INCLUDE from each Borland CD in data/ into build/toolchain/<release>/, then compiles, links and runs a test program with each release under Wine (the first run downloads Wine on macOS, about 180 MB). Afterwards:
uv run toolchain check # rerun the test program with each release
uv run toolchain run 4.5 BCC32 -c foo.c # any Borland tool, in the current directory
Under Wine, release 4.5 is drive T:, 4.52 is U:, 5.02 is V:, the repository is R: and the host root is Z:. The Borland tools truncate paths over 80 characters, which is why the repository gets its own short drive.
3. Ghidra
uv run ghidra setup # download, import and analyse zoombi32.exe (a few minutes)
uv run runtime-symbols # name the Borland runtime functions
uv run classes # recover C++ classes from RTTI
uv run ghidra label # apply names, types, classes, calling conventions
uv run ghidra open # browse the project in Ghidra's GUI
uv run ghidra decompile 0x46be2e # Ghidra's C for one function
setup downloads a pinned Ghidra into build/ghidra/ and writes every function it finds to build/ghidra/functions.json. Close the project in the GUI before running decompile, which opens it headlessly. Your manual work in the GUI lives in the project and survives label; only ghidra setup --force or clean ghidra-project removes it. See Ghidra notes.
4. The Windows 98 VM
uv run vm install # unattended Windows 98 SE setup, 30-60 minutes
uv run vm install-game # QuickTime and the game; about a minute, no input
uv run vm run # boot with the game disc in D:
uv run vm reset # discard everything since the installs
uv run vm screenshot # PNG of the VM's screen
To play, type zoombi32 in the Start menu's Run box. Depending on your Windows CD, setup may stop at a few wizard pages (licence, product key, user information) with the answers filled in; click Next on each.
The VM's disk is a stack of read-only layers: the Windows install (win98-base.qcow2), then QuickTime and the game (win98-game.qcow2), then a throwaway copy-on-write overlay that vm run boots and vm reset discards. --force redoes an install. See The VM.
5. Check the decompilation
uv run match # every marked function against the original, byte for byte
uv run match-data -q # data and data references
uv run near-misses # review what doesn't match
uv run report --open # progress report (contains disassembly: keep it local)
6. Build and run
uv run build # build/rebuild/zoombi32.exe
uv run vm run --exe build/rebuild/zoombi32.exe # run it in the VM
uv run port setup && uv run port build # the WebAssembly port
uv run port package && uv run port serve # then http://127.0.0.1:8000/
7. Resources
uv run assets extract # the disc's archives and movies into assets/ (committed)
uv run assets pack # assets/ back into archives, in build/assets/
uv run assets verify # packing reproduces the disc byte for byte
8. This book
uv run book build # build/book/index.html
uv run book serve # live-reloading server
uv run book screenshots # which screenshot placeholders still lack an image
Cleaning up
uv run clean [CATEGORY…] deletes generated files by category and never touches data/ or .env. With no arguments it removes everything cheap to rebuild and keeps the VM installs, the Wine, Emscripten and SoundFont downloads and the port's saved games. --list shows the categories; --dry-run shows what would go. Cleaning up lists them all.
Development checks
uv run lint # ruff, ruff format, strict mypy, pytest
uv run lint --fix # apply fixes and formatting first
uv run pre-commit install # once: runs lint, and `match --update` when decomp/ changes
How the pieces fit
┌────────────────────────────────────────────┐
game CD (data/) ───▶ │ extract-game ─▶ build/disc, build/zoombi32 │
└───────┬──────────────────────┬─────────────┘
│ zoombi32.exe │ DATA/*.MHK, *.MOV
┌────────────────▼───────────┐ ┌──────▼─────────────────┐
│ ghidra, runtime-symbols, │ │ assets extract/pack │
│ classes (understand it) │ │ (resources as source) │
└────────────────┬───────────┘ └──────┬─────────────────┘
│ names, types │ assets/ (PNG, WAV,
┌────────────────▼───────────┐ │ MIDI, TOML, scenes)
Borland CD ─▶│ decomp/*.cpp (write C++) │ │
└───────┬────────────┬───────┘ │
toolchain │ match │ build │
(BCC32 under Wine) │ (bytes == │ (TLINK32) │
│ original?)▼ │
│ build/rebuild/zoombi32.exe ─▶ VM (Windows 98)
│ │
└──────────▶ port/ (CMake, SDL2, miniwin) ◀──────┘
│
▼
WebAssembly site (build/port/site)
There are four loops, and most work happens in one of them.
- Understand the binary. Ghidra holds the disassembly and decompiler output.
runtime-symbolsnames Borland's runtime by matching its libraries against the game;classesrecovers the engine's C++ classes from RTTI;ghidra labelapplies both, plus every name already chosen indecomp/. The original game runs in an emulated Windows 98 PC for dynamic analysis. - Write and verify source. A function is written as C++ in
decomp/, marked with its address, compiled with the original compiler (Borland C++ 4.5 under Wine) and compared byte for byte with the original. Matching is the proof of correctness: a function that matches cannot hide a mistake.matchandmatch-datado this for code and data. - Treat resources as source. The disc's Mohawk archives are converted into
assets/in modern formats such thatassets packrebuilds the archives exactly. Resources are edited and diffed like code. - Run it.
buildlinks the decompiled code into an executable that runs in the VM;portcompiles the same, unchanged sources against miniwin, a Win32 subset over SDL2, for the browser.
Why this particular shape
- The decompiled code is portable C++ that matches. Both goals hold at once. Where only machine code could reproduce the original (inline assembly), the portable equivalent is marked functional instead. Nothing in
decomp/uses inline assembly,__emit__or pseudo-registers, which is what letsport/compile it with clang unchanged. - Everything is reproducible. Every setup step is a script; the only manual inputs are the bring-your-own files. Findings live in these docs or in code comments, not in someone's head.
- The game is layered like its history. It was written for the Mac and ported: a QuickDraw-style engine (the Mohawk engine) sits under the game, and the game sits on a thin Windows layer. The port exploits that: it replaces Windows, not the game. See Architecture.
Vocabulary you'll meet
| Term | Meaning |
|---|---|
| match / matching | a decompiled function compiles to exactly the original's bytes (linker-filled fields masked) |
| near-miss | compiles to almost the original's bytes, nearly always only register allocation |
| functional | complete and portable, but not byte-exact by design (the original used assembly) |
| marker | the /* @zoombi32 0x… */ comment before each function; the link to the binary |
| module | one original object file; decomp/<module>.cpp |
| Mohawk | Broderbund's engine and its archive format (.MHK) |
| scene | one screen of the game (a puzzle, the map, a camp) with open/close/frame/key callbacks |
| view | an animated thing on screen, driven by a script |
| snoid / Zoombini | a character; "snoid" is the code's name |
| miniwin | the Win32 subset the port implements |
The decompilation workflow
Choosing something to decompile
uv run worklist # functions ready to decompile, smallest first
uv run worklist --region engine
uv run worklist --module bridge # one source module
uv run report --open # statistics per region and module
A function is ready when everything it calls directly is done (matched, identified runtime code, or outside the region you're working on), so you work leaf-first and the callee names are already known. Regions are startup, game, runtime, engine and quicktime, derived from the recovered symbols. The report shows each function's recorded status and what is measured now, with a badge where they disagree. It contains the game's disassembly: keep it local.
Writing the function
- Read Ghidra's output:
uv run ghidra decompile 0x46be2e. Remember its parameters are reversed for the game's Pascal functions (Ghidra notes). - Write it in
decomp/<module>.cpp, in address order, preceded by/* @zoombi32 0x0046be2e */. Methods are marked the same way (void Widget::set(long v)). - Declare it in the module's header (
decomp/<module>.h) and globals by address (extern long mousePresent; /* @data 0x4a7f58 */). Shared types and anything several modules use go indecomp/zoombinis.h; see Naming and headers. - Run
uv run match decomp/<module>.cpp. A mismatch is shown as side-by-side disassembly. - Iterate with the field guide until it matches, or note in a comment what still differs ("Not exact: register allocation…").
- Name things as soon as their purpose is clear, and run
uv run ghidra labelso Ghidra shows the names. uv run match --updaterecords matches indecomp/matching.txt(the pre-commit hook does this whendecomp/changes; if it rewrites the file, add it and commit again). From then on a regression failsuv run match.
Rules for the code
- Portable C++. No inline assembly,
__emit__,_EAX/_EBP, or reliance on the x86 stack layout. Calling the Windows API is fine; the port supplies it. - Typed pointers. Keep pointers as pointers where the type is known, not
long, for an eventual 64-bit build. - Don't replace what standard C++ reserves (the global placement
operator new(size_t, void *)); the game's zeroing placement new isaudioObj's. - Where only machine code reproduces the original, write the portable equivalent, mark it
/* @zoombi32-functional 0x… */and say what the original does. If there is none, leave a documented stub. - Compiler-generated functions (an implicit destructor) have no definition to mark: name them in a marker of their own in the file whose object holds them,
/* @zoombi32-implicit 0x0048a6f9 DIB8Port::~DIB8Port */. A global object's construction and destruction calls are<startup>and<exit>. - A function is only done when it matches (or is functional). Match status is measured, never declared.
Checking the data
uv run match-data places each module's data in the original's DATA section and compares it; uv run define-data defines declared-but-undefined globals with the original's initial values. See Data layout.
Reviewing what's left
uv run near-misses classifies functions that don't match (Near-misses). Review anything with more than allocation differences before trusting it.
Recording findings
Confirmed facts about formats, flags or file meanings belong in this book (with the evidence: addresses, strings, experiments) or in the source comment of the code they explain. Rename freely as understanding improves; the marker holds the address, so the tools don't care.
Matching
uv run match is the project's definition of "correct". It compiles each file in decomp/ with the original compiler and compares every marked function with the original, byte for byte.
What it does
- Finds the
/* @zoombi32 0x… */markers in each source. - Compiles the file with Borland C++ 4.5 under Wine, with the game's usual options
-p -k-unless the file says otherwise (/* @flags … */,/* @release 5.02 */;--releaseand--flagsoverride every file's, to experiment). - Reads the object (
omf.pyis a minimal OMF reader) and the original (exe.pywraps pefile and capstone) and compares each marked function's bytes, masking fields the linker fills in (addresses and call targets). A call to another marked function in the same file must go to that function's address in the game. Every absolute address into the function itself (switch tables) must point to the same offset as the original's. - Prints side-by-side disassembly for mismatches and exits 1 if anything differs.
Compiled objects are cached in build/match-cache/, keyed on the source, the local headers it includes, its options and the release; --no-cache recompiles everything.
Statuses
| Status | Meaning |
|---|---|
| matched | identical bytes, recorded in decomp/matching.txt |
| near-miss | compiles, differs (usually registers); a comment says what |
| functional | @zoombi32-functional: complete and portable, not byte-exact by design |
| implicit | @zoombi32-implicit: a compiler-generated function, measured like any other |
| todo | not yet decompiled |
| library | Borland runtime or QuickTime glue, not decompiled |
decomp/matching.txt is written by uv run match --update. match fails if a recorded match regresses and reports new ones.
What it can't see, and what covers for it
match masks addresses, so a function can match while reading the wrong global. uv run match-data checks that every data reference points where the original's does. Hand-written assembly can't be reproduced from portable C++, so those are functional. Near-misses with more than allocation differences are reviewed with near-misses.
Where the numbers stand
About 2,100 functions: 1,915 matched, 176 near-misses, 37 functional. The remaining near-misses are mostly engine functions whose register choices no source form reproduces; see The compiler and Near-misses.
Hints, in one paragraph
Declaration order and block scope decide register assignment. volatile pins a local to the stack. Writing a global directly lets BCC32 hoist its address into a register. Comparison operand order follows the source. Write switch cases in the original's code order. The field guide is the long version.
The compiler: Borland C++ 4.5
The game and its engine were built with Borland C++ 4.5 (BCC32 for the code, TLINK32 for the link). 4.52 generates identical code for this game, so the tools use 4.5 (data/Borland C++ 4.5.iso; a 4.52 or 5.02 CD can be added for comparison).
How we know
| Evidence | Implies |
|---|---|
| The 16-bit build's NE header says linker version 6.1 | TLINK 7.0a, which shipped only with BC++ 4.5 and 4.52 |
Both builds contain Borland C++ - Copyright 1994 Borland Intl. | a 4.x runtime (5.0's says 1996) |
C++ exception handling and RTTI (**BCCxh1, Bad_typeid, xalloc) | BC++ 4.0 or later |
The PE has linker version 2.25 and sections named CODE/DATA | Borland's TLINK32 |
| Built January 1996 | before BC++ 5.0 |
4.5 and 4.52 differ only in the Pentium FDIV workaround (-fp, 4.52 only), none of whose support code is in the game; their runtime libraries are the same code module for module. The proof that matters is empirical: 1,915 functions compile to the original bytes with 4.5 (see Matching).
The Mohawk engine (0x4764bc onwards) is the one place where 4.5 is not the whole story: the engine's code often keeps short-lived locals in saved registers where BCC32 4.5 keeps them in eax or on the stack, and it uses Windows 95's DEVMODE (0x94 bytes) where 4.5's headers have the older 0x7c-byte one. So the engine was built with 4.5 or something very close to it, probably against newer Windows headers. 5.02 is not it (compiling the engine with 5.02 matches far fewer functions: it builds frames for leaf functions and picks other scratch registers). Those functions remain near-misses.
The game is C++
The Borland C++ exception-handling and RTTI runtime is linked in, and functions with local objects that have destructors call __InitExceptBlock, so the decompiled code is C++, not C. What BCC32 4.5 does with it:
- Plain functions are mangled with their argument types only (
setCurrentMap(long)is@setCurrentMap$ql, even when declared__stdcall); global variables keep C names (_g_4a7f58).__pascalfunctions have the whole mangled name upper-cased (@FN_4115F5$QSL);demangle.pyhandles both. - Methods receive
thisas a hidden first stack argument ([ebp+8]), not in a register as with Microsoft's compilers. - Destructors take a hidden second argument whose bit 0 means "also free the memory".
Options: -p -k-
BCC32 reproduces the game's code with:
-p: the Pascal calling convention is the default. Parameters are pushed left to right and the callee pops them (ret N). The first parameter in the source is at the highest stack offset. A decompiled game function therefore has no calling-convention keyword and lists its parameters in Pascal order; Ghidra shows them reversed (itsparam_1is the last source parameter), because it has no__pascalandghidra labelmarks these functions__stdcall.-k-: no standard stack frame unless one is needed. It only shows in functions that make calls but have no parameters and no stack locals.- Everything else at BCC32's defaults: no optimisation (
-Od), register variables (-r), byte alignment. What looks like optimisation in the original (registers for locals, rotated loops) is BCC32's ordinary code generation.-O1/-O2and-r-break functions that otherwise match.
Options were set per module, as an IDE project allows:
| Code | Options | Why |
|---|---|---|
The game (0x41008c-0x46cca0) | -p -k- | the default for every file |
The Mohawk OS layer (0x46d754-0x46f7a4, os_*.cpp) | -p and -x- | built with frames; no exception frames |
The Mohawk engine (0x4764bc-) | -p -x- per module | Pascal-order like the Mac Toolbox calls it imitates, but its C++ class methods are __cdecl |
A file overrides the defaults with /* @flags ... */ and the release with /* @release 5.02 */. The engine's classes' methods don't pop their arguments, so they are declared __cdecl module by module; plain QuickDraw-style helpers (emptyRect, offsetRect) use the game's -p -k-.
See Matching BCC32 for what the source has to look like to reproduce the original's code.
Matching BCC32: a field guide
Byte-matching means reproducing not just what the code does but how BCC32 4.5 compiled it. Almost all the effort goes into a handful of recurring effects. This is the distilled list; the original's quirks are in the source comments next to each function (search decomp/ for "Not exact").
Register allocation
BCC32 gives its three saved registers (ebx, esi, edi, in that order) to the most-used locals, and on a tie to the one used first. Everything else follows from that:
- Declaration order matters. Registers go to variables in declaration order among equals, so declare locals in the order the original's registers suggest.
- Lifetimes can share a register. Variables whose lives don't overlap can share one, even of different types. When one register holds several unrelated values in the original, try one reused variable as well as separate ones (
placeDialogList,runViewScript). - Block scope decides sharing. Declaring a loop index inside the block that uses it is what lets it share a register with a flag used elsewhere.
- A local that lives across no call goes in
eax/edx/ecx, not a saved register. volatilekeeps a local on the stack where BCC32 would give it a register, and keeps stores to otherwise-unused locals. It is the standard tool for "the original leaves this in memory"; the prototype must sayvolatiletoo when it's a parameter.- Assigning inside a condition (
if ((p = find(id)) && p->x)) keepspin its stack slot; assigning first and testingpkeeps it ineax. - Unused locals: an unused scalar takes no space, but an unused array or struct does. Frame gaps in the original are reproduced with dummy arrays or structs (structs sit below arrays).
Globals, structs and addresses
- BCC32 hoists a global's address into a register by itself when a function uses it often (
mov esi, 0x4b9b6c). That only happens when the source writes the global directly (heap.error); a local pointer to it (MemoryState *state = &heap) becomes a register variable in declaration order and gets a different register. The reverse also occurs: a few functions only matched with a pointer local. Try both. - The hoisted address is one symbol's: two separate globals are always addressed absolutely. So where the original addresses several places through one base register (
mov esi, 0x4af8acthen[esi+0x24]), they are fields of one struct. - Arrays indexed from 1. The original's
[array - size + i*size]isa[i - 1]in the decompilation; BCC32 folds the- 1into the displacement. Declare the array where the data starts, not an element early. - String literals are pooled per module, in order of first use, and a function with several literals addresses them from the pool's start (
lea eax, [edi+offset]). The offsets are code, so a function's literals only match once every earlier literal-using function in its module is decompiled, in address order. Functions with few literals push each address instead. Named strings (char msgFoo[]) are pushed by address and stored in an unrelated order; the game's startup messages are like that. DIBPortis declared inside#pragma pack(push, 4)to reproduce both its size (0xc8) and its derived class's (0xc6).- A structure copied through its address is the second half of a chained assignment (
a = b = c). - An initialised local array is copied from a hidden copy in
DATA(rep movsd) where it's declared.
Expressions
- A comparison's operand order follows the source (
exclude != iandi != excludecompile differently). Between two register variables BCC32 puts the right operand first, soj < donebecomescmp done, j. x = x * -1compiles tomov/neg/mov,x = -xtoneg [x].++x > Nloads, increments and compares in a register;x++; if (x > N)works in memory.if (c < 1)on achar,c <= 0andc - 1 < 0all differ. A one-caseswitchon anunsigned shortcompiles to 16-bit compares, on ashorttomovsx.x + 200on a register variable adds in the full register,x += 200on ashortaddsbx.- Masks written with
~differ from their positive forms (op & ~3vsop & 0xfc). - Initialising several variables in a
forheader versus in declarations changes the setup order;while (p && !found) p = p->next;and aforwithbreaklay out differently. - Dead code is compiled (
else if (0) {...}), and ajmpover a lone instruction is anifBCC32 resolved at compile time (anunsigned shorttested< 0). - Switches: the jump table lives inside the function and is relocated. BCC32 lays case bodies out in source order but numbers the table its own way, so write the
caselabels in the original's code order. Aswitchon acharwhose cases start above 0 compiles as byte decrements; the original's table from 0 needs explicit emptycase 0:/case 1:labels.matchrequires every absolute address into the function itself to point where the original's does, so a switch with its cases swapped cannot match by accident. - Pascal order evaluates arguments left to right.
f(*cel++, *cel++, *cel++)reproduces one original, but C++ leaves the order unspecified, so it's decompiled with indexing and marked@zoombi32-functional. - Loads whose values go unused can come from an inline function called for nothing: BCC32 drops the body but still evaluates the arguments.
Types the game shares with the engine
- The engine takes rectangles as
const Rect &; the game passes itsShortRects, each converted into a temporary by an inline constructor (memcpy(this, &r, 8)). With two in one call each gets its own register. Some engine modules call the same constructor out of line (RECT_OUT_OF_LINE). Coloris constructed out of line (__cdecl) and returned through a hidden pointer; passing one by value copies a dword then a word.RGBColorhas an inline three-argument constructor for the game's uses, while the engine calls the out-of-line one.- The game has inline byte-swapping helpers for big-endian (Mac) values (
swapShort,swapLongindecomp/zoombinis.h). Their inlined shape (copy to a stack temporary, reassemble bytes in reverse) only reproduces with the helper taking its parameter by value. - Resource types are built Mac-style:
RESOURCE_TYPE('C','U','R','S'), because Borland's multi-character constants have the opposite byte order.
Where matching stops
A few functions can't match by construction: those the original wrote in assembly (see Functions written in assembly), and a few dozen near-misses whose register choice no source form reproduces. They are measured (they are simply absent from decomp/matching.txt) and reviewed with uv run near-misses.
Functions written in assembly
BCC32 only compiles inline asm through TASM32, which isn't part of Borland C++ 4.5, so some of the original's functions were assembled separately, or compiled via TASM. They cannot come from C++, and decompiled code must be portable (no __emit__, no pseudo-registers). Each is therefore written as a portable equivalent marked @zoombi32-functional, or, where none exists, left as a documented stub.
| Where | What | Portable form |
|---|---|---|
| OS layer | atomicIncrement/Decrement/Exchange (lock inc, xchg) | Interlocked* (functional) |
| OS layer | initContext, resumeContext, abandonContext, switchContext: carve a thread's stack and switch registers, esp and flags | functional with Win32 fibers (Windows 98/NT 4; not 95) |
| OS layer | recordReturn: walk stack frames | documented stub |
| OS layer | fixedMul, fixedDiv: 16.16 arithmetic | functional |
| OS layer | debugBreak: int3 | DebugBreak() |
| WaveMix | the mixing inner loops (xlat, jo saturate, rep movs) | functional |
| Graphics | nearest-colour search; packed-pixel draw and read; the DIB8 blitters | functional |
| Graphics | decompressImage: differs from our compile only in TASM's accumulator encodings (66 25 0f 00 for and ax, 0xf) | functional |
The assembly-built modules went through TASM, which is why a few differ only in instruction encodings from BCC32's output. Assembling through TASM does not change register allocation, so it doesn't explain other near-misses.
Packed pixels, the engine's run-length encoding of 8-bit images, are described in Images.
Near-misses
A function that compiles to almost the original's bytes is a near-miss. Of about 2,100 functions, 1,915 match exactly, 176 are near-misses (nearly always only register allocation), and 37 are written portably in place of the original's assembly.
uv run near-misses [files…] sorts them by comparing what the two versions compute: operations, branch conditions, calls and constants, counted and normalised so equivalent forms look alike, plus which stack slots have their addresses taken.
| Class | Meaning | Action |
|---|---|---|
| allocation only | the same computations; different registers or frame slots | usually fine |
| frame layout | a local whose address is taken sits elsewhere | check sizes: a too-small buffer would overrun |
| needs a look | the tool lists what each version computes that the other doesn't | review by hand |
Most "needs a look" entries are equivalent forms the tool doesn't recognise (16-bit arithmetic, lea for add, a view's body computed in place at +0x30, offsets from a cached base). The review pays off in real bugs: a decompiled function that sets midi->looping where the original sets audioObj::looping, a 1-byte local where the original's DOS IOCTL block was 2 bytes (which overwrote the saved frame pointer in the rebuilt game), and switch bodies swapped between cases all showed up as near-misses first. So review any near-miss with more than allocation differences before trusting it.
The reason for matching at all is the same: a function that matches byte for byte cannot hide a mistake.
Naming and headers
Names
- Rename a function, global or struct field as soon as its purpose is clear, so its callers read better. Until then, name it after its address:
fn_46be2e,g_4a7f58. - Functions, variables and fields are camelCase (
isMousePresent,currentTimeMs); types are PascalCase. Names recovered from RTTI or runtime symbols are used exactly (basePort,fileSpec,DIB8Port). - Prefer honest, specific names over guesses at the originals. A field nothing reads keeps its offset name (
unknown66). - A renamed global keeps its address in a marker:
extern long mousePresent; /* @data 0x4a7f58 */. Two functions that share a name each carry their address (/* 0x46daca */). uv run ghidra labelcopiesdecomp/names into Ghidra without overwriting names set by hand there; the worklist and report show them.
Where declarations go
Declarations live in headers, globals by address.
| Declaration | Goes in |
|---|---|
| a module's functions, and the globals and types only its code uses | decomp/<module>.h |
| shared types, and everything else (engine, OS layer, runtime, globals several modules use) | decomp/zoombinis.h |
A module's source and its callers' sources include zoombinis.h first and then the module header. uv run includes [files…] sets each source's module-header includes to the ones declaring names it uses; run it after adding a call into another module.
This split exists because match caches objects by the headers each source includes: changing zoombinis.h recompiles every file, changing a module header recompiles only the files including it. Declare each function and global once (a test checks).
Keep headers plain (structs, extern globals, prototypes): uv run ghidra label reads them (src/zbtools/declarations.py) to give Ghidra the structs (category /zoombinis) and the globals' names and types.
Struct fields
Fields carry their offsets in comments (short tag; /* +0x1e: a word for its scene's use */), which is how most of the structs in zoombinis.h were recovered. Snoid and View are the best-annotated; see Entities.
Source modules
TLINK32 lays each object file's code out contiguously, in link order, and pads it with zeros to a 4-byte boundary. Where a function is followed by that padding, a module ends. About one boundary in four is invisible (the code happened to be aligned), but the data follows the same order, so a boundary can show there instead: each module's initialised data follows the previous module's, with string literals last.
decomp/modules.toml is the curated map: 42 game modules, the Mohawk OS layer's six, and the engine's 149. uv run modules checks it against the padding and prints the strings and imports of each range, which is how modules are named: by archive (bridge.mhk, Ferry.MHK, Tunnels.MHK, Net.MHK), by characters (Pizza Pass's trolls), or by messages (Zoombini.who, Zoombi32.CFG, e2AllocHandle). Unknown ones are named by address (module_4124a4) with the evidence in note.
Each module's functions go in decomp/<module>.cpp, in address order (string literal pools make that order matter), with a decomp/<module>.h of its own declarations. The module map also drives uv run worklist --module and the report's per-module progress.
Boundaries the code doesn't show can be found in the data: isle was split from net, and hotel from lilly, because their globals and initialised data start in different places even though no padding separates their code.
The full list, with what each module does, is in Modules and scenes.
How data is laid out, and matched
uv run match-data and uv run define-data rest on how BCC32 and TLINK32 lay a module's data out. (The code is in src/zbtools/match_data.py; its docstring has the exact placement rules.)
The rules
- Code reaches its own module's data through the segment (
_DATAor_BSS), with the offset in the instruction itself and a zero fixup displacement. A matching function's data reference therefore tells you where that global lives in the original: the instruction holds the absolute address.matchmasks those addresses, somatch-dataseparately checks that every data reference in a decompiled function points where the original's does. A function can match and still read the wrong global; this check catches it. - Each object's
_DATAholds initialised globals and statics in definition order, then its string literals, padded to 4 bytes. In the original the modules follow each other without that padding where literals end, so padding isn't compared. _BSSfollows the same module order as code and_DATA. It starts at0x4aa410, inside the zero padding of theDATAsection's file data (TLINK32 pads to 0x200), not at the end of it.- Zero-initialised globals may be in
_DATAif the original wrote= 0; if the decompilation doesn't, BCC32 puts it in_BSS. Add the initialiser. - A hidden copy of an initialised local array is data like any other and shows the original's initialiser.
- String literals are pooled per module in order of first use (see Matching BCC32).
Defining globals
Headers declare globals as they're found, by address: extern short primes[5]; /* @data 0x4a0800 */. uv run define-data defines the ones no source defines, in the module whose code uses them in the original (else the module of the globals around them), with initialisers rendered from the original's bytes as typed C++: numbers, text, pointers as the function, global or string they point to, structs and arrays in braces. Types are checked against BCC32's own sizeof through a probe file. It leaves a global for a person, saying why, when it can't be exact.
Pitfalls it found
- Arrays indexed from 1. The original's address is an element before the array, so a declaration "starts an element early" and overlaps its neighbour. Declare the array at its real start and shift the uses (
a[i - 1]compiles to the same instruction). - Second names for elements. Code that uses one element by itself looks like a separate global (
view6000iscampThingViews[9];inputFlagsHighis the high byte ofinputFlags). Fold them into the array or field. - Input groups list their items: each scene's
Grouppoints at its buttons plus an item for the whole screen ({0, 0, 640, 480}), so button arrays have one more entry than there are buttons. - Genuine overlaps exist. The tail view's drag cursor sits inside its body;
splitIntoGroupscan write more ofnetGroupsthan it clears, over its neighbours. The decompilation keeps the original's lengths and notes the overrun in the declaration.
uv run report shows the totals: 69% of the original's initialised data is placed and identical byte for byte, and every checked reference points where the original's does.
Code layout of zoombi32.exe
zoombi32.exe is a PE32 executable (about 634 KB) with sections CODE, DATA, .idata, .edata, .reloc and .rsrc.
CODE (0x410000-0x494000)
| Range | What |
|---|---|
0x410000 | Borland's Win32 startup code (C0W32.OBJ); the entry point |
0x41008c-0x46cca0 | the game's own code (42 modules, see Source modules) |
0x46cca0-0x46d754 | QuickTime for Windows' SDK glue (QuickTime glue) |
0x46d754-0x46f7a4 | the Mohawk OS layer (os_*.cpp) |
0x46f7a4-0x4764bc | the Borland C++ runtime (CW32.LIB), named by uv run runtime-symbols |
0x4764bc-0x494000 | the Mohawk engine: ports, audio, WaveMix, files, resources, memory (149 small modules) |
The three Windows API families the code imports are KERNEL32, USER32, GDI32, ADVAPI32 and WINMM; DSOUND, QTIM32, CMGR32 and VERSION are loaded by name at run time.
What the layout says about the source
- The game's code came from the Mac version. Its message table (
0x4a4da4-0x4a4f7a) holds messages the Windows game never shows ("Requires Sound Manager 3.1…", "GetVol failed. Boot drive."), the memory manager and resource manager imitate the Mac Toolbox, resource types are Mac four-character codes, and rectangles, regions and ports follow QuickDraw. - The data section follows the code's module order. Each module's initialised data comes after the previous module's, with its string literals last, then its uninitialised data in the same order; see Data layout. This is what lets the project find module boundaries and place globals.
- Switch tables, vtables and RTTI descriptors live in
CODE, which is why a linear disassembly shows odd instructions (in,out,cli) inside some functions: those are table bytes. atexitis given a six-byte__cdeclwrapper, because the game's functions are Pascal andatexitwants the C convention.
Entry
WinMain (0x4546f8, in the game module) is reached only through the startup code's table at 0x4a0044. See Startup.
The rebuilt executable
uv run build compiles decomp/ and glue/ (reusing match's objects), the icon (BRCC32) and links them with TLINK32 1.50, Borland C++ 4.5's linker, into build/rebuild/zoombi32.exe with a map.
- Linked in the original's order (each source by its first marked function;
glue/where the code it stands in for was), with-Tpe -aa -c, the result has the original's six sections at the same addresses, the same export (__GetExceptDLLinfo) and the same icon resources, byte for byte. - Its imports differ only where its code does:
DebugBreakand theInterlockedfunctions replace the original's inline assembly, andGetVersionExAcame from Apple's QuickTime glue, whichglue/quicktime.cppstands in for. - It runs in the VM:
uv run vm run --exe build/rebuild/zoombi32.exe.--trace LOGmakes QEMU log the executable's code as it runs, anduv run trace LOGnames the functions from the map; after a crash, the last one is where it happened. - The game refuses to start while the global atom
Zoombiniexists, which a crash leaves behind: reboot the VM before running again.
A near-miss can be the bug: when the rebuild crashes, look at what differs in the functions it was in.
Tool reference
Every tool is a module in src/zbtools/ exposing a Typer app, registered in pyproject.toml under [project.scripts] and run as uv run <name>. Defaults for inputs and outputs come from src/zbtools/paths.py; any can be overridden with arguments. The only code that knows about the host OS is host.py (binary locations, install hints, QEMU display and audio backends).
| Command | Module | Purpose |
|---|---|---|
extract-game | extract_game.py | disc contents to build/disc/, the Windows 95 build to build/zoombi32/ |
unpack-isz | unpack_isz.py | list/extract InstallShield 3 archives (hand-written: includes a DCL decompressor no Python library offers) |
vm | vm.py | the Windows 98 VM: install, install-game, run, reset, screenshot (the VM) |
toolchain | toolchain.py | Borland C++ under Wine: setup, check, run |
ghidra | ghidra.py | setup, open, decompile, label, codec (Ghidra notes) |
runtime-symbols | runtime_symbols.py | name Borland runtime code by matching the toolchain's libraries (runtime and RTTI) |
classes | rtti.py | recover C++ classes from RTTI into build/symbols/classes.json |
match | match.py | compile and compare marked functions (Matching) |
match-data | match_data.py | data placement and data references (Data layout) |
define-data | define_data.py | define declared-but-undefined globals with the original's initial values |
near-misses | near_misses.py | classify functions that don't match (Near-misses) |
worklist | worklist.py | what's ready to decompile next |
report | report.py | HTML progress report in build/report/ (Jinja2 templates in src/zbtools/templates/). Contains disassembly: never publish. |
modules | modules.py | the module map and its evidence (Modules) |
includes | includes.py | set each source's module-header includes |
assets | assets.py | extract, pack, verify, frames (Formats) |
movie-check | qb32.py | run the original movie codec under emulation and compare every frame with ours |
build | build.py | compile decomp/ and glue/ and link build/rebuild/zoombi32.exe (Rebuilt executable) |
trace | trace.py | name the functions in a QEMU execution trace from the map |
port | port.py | setup, build, package, serve, run (The port) |
book | book.py | build, serve and audit this book |
clean | clean.py | delete generated files by category |
lint | lint.py | ruff, ruff format, mypy, pytest |
Supporting modules
| Module | Role |
|---|---|
omf.py | minimal OMF object reader (Borland .OBJ), including "virtual segments" for type descriptors and templates |
exe.py | typed pefile/capstone wrappers |
demangle.py | Borland C++ demangler (demangle, qualified_name), checked against Borland's TDUMP on all 2,227 mangled names in CW32.LIB |
declarations.py | reads the headers' structs, globals and prototypes (for Ghidra and define-data) |
inventory.py | every function with region, calls, module and status; backs worklist and report |
module_map.py | loads decomp/modules.toml |
quicktime.py | the QuickTime glue's stubs and selectors |
mohawk.py, formats/, movies.py | the archive container, per-type formats and movie conversion |
x86.py | typed wrapper for Unicorn (x86 emulation) |
screen.py | recognisers for Windows screens in the VM (logon prompt, idle desktop) |
game_install.py | builds the "tools CD" that vm install-game runs inside the VM |
download.py | the shared pinned, checksum-verified download helper (Wine, Ghidra, Emscripten, SoundFont) |
env.py, paths.py | .env and every path the tools use, including CLEAN_CATEGORIES |
Cleaning
Every path a tool generates must belong to a paths.CLEAN_CATEGORIES entry (categories may include others by name); add new ones there, and to CLEAN_DEFAULT if cheap to rebuild, and keep the cleaning table (docs/src/reference/cleaning.md) in sync.
Where the work is cached
| Cache | Keyed on |
|---|---|
build/match-cache/ | source, local headers, options, release |
build/assets-cache/ | the encoder's own output for compressed images (never the disc's bytes, or verify would test nothing) |
build/ghidra/functions.json | the Ghidra project's analysis |
Ghidra: its limits, and what label fixes
uv run ghidra setup imports and analyses the binary; uv run ghidra label then repairs and names what auto-analysis gets wrong. You only need these notes if you work in the Ghidra project or change label.
What auto-analysis misses
- Functions reached only through pointers:
WinMain, window procedures, callbacks in tables, frameless (-k-) functions that start modules.labelcreates functions at relocated pointers from data (and from instructions outside a memory operand), at module starts, after a function that beginspush ebp; mov ebp, esp, and at direct call targets not yet in a function. It also disassembles them:createFunctionalone makes a one-byte function. - Relocated pointers in
CODEaren't always code: switch tables, byte index tables and RTTI descriptors live there too; operands like[eax*4 + table]don't count. - Switch tables cut functions short: BCC32's
jmp [reg*4 + table]with the table inside the function made Ghidra end the function at the jump.labelre-decompiles any function with an unresolved computed jump, lets the decompiler recover the table, and recomputes the body. - Fragments: Ghidra sometimes splits a function after
push ebp; mov ebp, esp; add esp, -n.labelmerges a function that nothing refers to into the previous one when that one falls through. - An
int3ends a function:debugBreak(0x46db83) isint3mid-function;labelmakes breakpoints fall through. - A few inner loops of assembly routines (
0x4899ed,0x48cfc9,0x48d03f) are reached through a register and look like functions; they're left as they are.
Calling conventions
Ghidra's x86 has no __pascal. label marks every function that pops its own arguments __stdcall (1,365 of them), which gets the stack right but numbers parameters in reverse. Read param_1 as the last parameter of the source.
What label applies
Runtime library names (runtime-symbols), C++ classes, vtables, constructors and destructors (classes), QuickTime stub names, the names, types and globals declared in decomp/ (headers' structs go in the category /zoombinis), calling conventions, and the repairs above. It never overwrites a name or type set by hand (source USER_DEFINED), so you can annotate freely; rerun it after renaming in decomp/.
The project lives in build/ghidra/project/ and may hold your own work: never delete or recreate it without being asked (uv run ghidra setup --force and clean ghidra-project do).
The movie codec
uv run ghidra codec imports qb32.qtc into its own project and writes its decompilation to build/ghidra/qb32.c. Ghidra doesn't find the codec's handlers because they take their selector in bx; the command creates them. The file is the original's code: never commit it.
Runtime symbols and C++ classes (RTTI)
Borland runtime functions
uv run runtime-symbols matches the code segments of the 32-bit Borland libraries (CW32.LIB, CW32MT.LIB, BIDSF.LIB, OWLWF.LIB, OCFWF.LIB, the C0*32.OBJ startup objects) against the executable, with linker-filled bytes as wildcards. With 4.5's libraries it names 217 addresses (e.g. _strcpy at 0x46f8f4, _strcat at 0x46f864, @__InitExceptBlock at 0x4716c0); 125 segments match in several places (mostly small C++ destructors instantiated in many modules) and are left unnamed. uv run ghidra label applies the names to the Ghidra project.
It also records the extent of each of the 116 segments it matches uniquely (27.6 KB). The linker put the libraries together, from the first runtime function (0x46f7a4) to the end of the last segment (0x4764bc), so everything in between is library code, including static helpers with no public name (e.g. the exception-handling internals in xx.cpp). What follows, up to the first engine class method (0x47af1c), isn't Borland's: 125 functions (18.7 KB) handling MIDI and wave-device mapping (MidiMap, DefaultWaveDevice, Software\Microsoft\Multimedia\Sound Mapper) and calling into the engine throughout. That's the Mohawk engine's C code, before its classes, so the engine starts at 0x4764bc.
Borland's C++ objects use "virtual segments" (COMDEF entries whose data type is a segment index; references to them set bit 0x4000 in the index) for type descriptors (@$xt$...), inline functions and template instances. omf.py reads them as extra segments named after their symbol.
C++ classes (RTTI)
Borland C++ emits a type descriptor for each polymorphic class, and for types used in exceptions, in the code section (they are virtual segments, @$xt$...). uv run classes (src/zbtools/rtti.py) finds them by their layout, worked out from the runtime library's own descriptors and confirmed against the game:
| Offset | Meaning |
|---|---|
+0x00 | object size |
+0x04 | flags: 0x0001 class, 0x0002 has a destructor and the fields below; 0x0010 pointer type |
+0x06 | offset of the name in the descriptor (0x30; 0x20 for classes with a base-class list but no destructor, such as audioObj; 0x10 for classes with neither; 0x0c for pointer types) |
+0x08 | offset of the vtable pointer in objects, -1 if none (pointer types: the pointed-to type's descriptor) |
+0x10 | offset of the base-class list: (descriptor, offset, flags) entries, 12 bytes each, ended by a null descriptor |
+0x14 | probably the class's deallocation function (0x4870d1 for the port classes, which their destructors call to free the object) |
+0x28 | the destructor (name at 0x30) |
Each vtable, in the data section, is preceded by a pointer to its class's descriptor and two zero words: the vtable starts 12 bytes after that pointer, and slot 0 is the virtual destructor. Constructors (and destructors) store the vtable's address into the object (mov dword ptr [reg], vtable). Destructors take a hidden second argument whose bit 0 means "also free the memory" (e.g. displayPort::~displayPort calls basePort::~basePort(this, 0), then the deallocation function if the bit is set).
46 classes, nearly all in Broderbund's Mohawk engine; the hierarchy is in C++ classes. The game's own code has only fileSpec (no vtable): its logic is plain functions and structs.
uv run ghidra label makes these Ghidra classes (descriptor, vtable, constructors, destructor, and vfuncN for virtual methods named after the class that introduces them) and sets __stdcall on the 1,365 functions that pop their own arguments.
The Windows 98 VM
The original game runs in an emulated Windows 98 SE PC under QEMU, for dynamic analysis and to test the rebuilt executable. (src/zbtools/vm.py; automation goes through QMP with qemu.qmp.)
Layers
win98.qcow2 throwaway overlay: what `vm run` boots; `vm reset` discards it
└ win98-game.qcow2 QuickTime for Windows 2.1 and the game (vm install-game)
└ win98-base.qcow2 the Windows 98 SE install, read-only (vm install)
installdrives an unattended Windows 98 SE setup from a customised boot floppy andMSBATCH.INF(product key from.env), then answers the logon prompt itself with a blank password. Closing the QEMU window or pressing Ctrl-C aborts and deletes the partial disk.install-gamebuilds a "tools CD" (game_install.py) holding the QuickTime installer (with its options file namedQT32B42.INI; see installer settings), the game's files and a filled-inZoombi32.CFG, boots the VM, runs the installer and powers off.runboots the overlay with the game disc inD:.--exe PATHcarries an executable on a floppy, copies it into the game's directory asREBUILT.EXEand starts it.--trace LOGhas QEMU log the executable's code as it runs, filtered to its addresses, stopping at 500 MB or two minutes;uv run trace LOGnames the functions from the build's map.resetdeletes the overlay.screenshotsaves a PNG of the screen (this is also how to capture images of the original game).
Things to know
- The game refuses to start while its global atom
Zoombiniexists, which a crash leaves behind. Reboot the VM before running again. screen.pyrecognises the logon prompt and idle desktop by their pixels, which is how automation knows when a step is finished.- Rosetta 2 cannot run 16-bit code, so Wine can't replace the VM on Apple Silicon.
Python conventions
Run uv run lint before finishing any change to Python code (--fix applies ruff's fixes and formatting first). It runs ruff lint, ruff format check, mypy and pytest, and also as a pre-commit hook (uv run pre-commit install); never bypass the hook with --no-verify.
- Strict typing. mypy runs strict with
disallow_any_explicit. Annotate every function and every variable whose type isn't inferred. Never writeAny,cast()or# type: ignore; fix the types. - Model structured data with types, not dicts:
@dataclass(frozen=True)for records built in code,NamedTuplefor small immutable tuples,TypedDictfor dict-shaped data that must stay a dict, and pydantic models for data from outside the program (JSON, protocol messages, files) that needs validating at run time (the QMP models invm.py). - CLIs use Typer: each tool defines
app = typer.Typer(...)with typed commands (Annotated[..., typer.Option(...)]). - Binary parsing: prefer
int.from_bytesoverstruct.unpack_from(untyped tuples).struct.pack_intois fine for writing. - Use
pathlib, notos.path.# fmt: off/ononly around data tables formatting would ruin. - Don't reinvent wheels. Check PyPI first for a maintained library with wheels for our Python (and types, or add a narrow mypy override as for
fontTools). Current choices: pycdlib (ISO 9660), fontTools, Pillow, python-dotenv,qemu.qmp, Typer, pydantic, Unicorn. Where none exists, prefer a standard host tool routed throughhost.py(mtools for FAT floppies). Hand-write only when neither exists, and say why in the docstring (e.g. the DCL decompressor inunpack_isz.py). - Ghidra classes can only be imported after
pyghidra.start(): import them inside functions (# noqa: PLC0415) and underTYPE_CHECKINGfor annotations. The stubs don't model Java's nulls: annotate variables that can be null asX | None. - Tests live in
tests/(pytest) for anything checkable without the bring-your-own files: parsers, demangling, formats. Anything needing the ISOs or toolchain is verified by running the tools. - Dependencies:
uv addfor runtime,uv add --devfor dev tools.
Cleaning up
uv run clean [CATEGORY ...] # delete generated files by category
uv run clean --list # show the categories
uv run clean --dry-run # show what would be removed
clean never touches data/ or .env. Each tool's output belongs to a category in paths.CLEAN_CATEGORIES.
| Category | What it removes | Rebuilt by |
|---|---|---|
extracted | build/disc/, build/zoombi32/, build/symbols/ | uv run extract-game, uv run runtime-symbols, uv run classes |
vm-state | the VM overlay and install leftovers | automatically on vm run |
vm-game | the QuickTime and game install (and the overlay on it) | uv run vm install-game (1 min) |
vm-base | the Windows 98 install (and everything layered on it) | uv run vm install (30-60 min) |
vm | all of the above | |
match-cache | objects match compiled (build/match-cache/) | automatically by uv run match |
toolchain | the extracted Borland toolchains, the Wine prefix and match-cache | uv run toolchain setup |
wine | the downloaded Wine build (macOS) and the Wine prefix | uv run toolchain setup (downloads ~180 MB) |
ghidra-project | the Ghidra projects (the game's and the codec's), including any work done in Ghidra's GUI, and the function list and codec C | uv run ghidra setup, uv run ghidra codec |
ghidra | all of Ghidra: the download, native build and project | uv run ghidra setup (downloads ~540 MB) |
report | build/report/ | uv run report |
book | the rendered handbook (build/book/) | uv run book build |
rebuild | the rebuilt executable and what went into it (build/rebuild/) | uv run build |
packed-assets | the archives assets pack built (build/assets/) | uv run assets pack |
assets-cache | compressed images (build/assets-cache/) | automatically by uv run assets pack or verify |
movie-frames | the frames assets frames drew (build/movie-frames/) | uv run assets frames |
port | the port's builds (build/port/web/, native/, headless/) and the site (build/port/site/) | uv run port build, uv run port package |
port-data | the port's drives, including what the native and headless builds saved (build/port/data/) | uv run port package |
emsdk | the Emscripten SDK (build/emsdk/) | uv run port setup (downloads ~1 GB) |
soundfont | the port's SoundFont (build/soundfont/) | uv run port setup (downloads 32 MB) |
python | .venv/, __pycache__ | automatically by uv run |
all | all of the above plus anything else in build/ |
With no arguments it removes extracted, vm-state, toolchain, report, rebuild, packed-assets, assets-cache, movie-frames, book, port and python: everything cheap to rebuild, keeping the VM installs, the Wine, Emscripten and SoundFont downloads, and the port's saved games. uv run clean all gets back to a fresh clone.
Keep this table in sync with paths.CLEAN_CATEGORIES. Every path a tool generates must belong to a category (categories may include others by name); add new ones to CLEAN_DEFAULT if they're cheap to rebuild.
About this book
The handbook is an mdBook: Markdown chapters in docs/src/, configured by docs/book.toml, published at https://docs.zoombinis.online.
Reading and building it
Install mdBook (brew install mdbook, or cargo install mdbook, or a release binary), then:
uv run book build [--open] # renders to build/book/
uv run book serve # http://localhost:3000, rebuilding as you edit
(mdbook build docs and mdbook serve docs work too; uv run book only adds an install hint and the screenshot tooling.) The output goes to build/book/, which uv run clean book removes.
Writing in it
- A chapter is a Markdown file under
docs/src/<part>/, and must be listed indocs/src/SUMMARY.md, which sets the sidebar order. Parts mirror the directories:getting-started,concepts,reference,formats,codebase,gameplay,port,appendix. - Link between chapters with relative paths (
../codebase/startup.md,#anchorsare the lower-cased heading with punctuation dropped). - Cite code by address and name (
openBridge,0x41a506) so it survives renames; the marker comments makegrep -rn 0x41a506 decomp/find it. - State what is confirmed and how (addresses, strings, experiments); say when something is inferred. Keep chapters current: delete what stops being true rather than appending history.
- Gameplay chapters follow the template in Gameplay and the code: on screen, scene facts, entry points, where the rules live, state, things to know.
- Screenshot placeholders and images are covered in Screenshots.
Checks
uv run lint runs tests/test_book.py, which fails if a chapter isn't in SUMMARY.md, a relative link to a chapter is broken, a screenshot id is duplicated, or the screenshot index is stale (fix with uv run book screenshots --update). CI also builds the book on every pull request.
Publishing
Pushes to main build the book and deploy it to its own Cloudflare Pages project (Continuous integration). There is nothing to do by hand.
Continuous integration
Workflows are in .github/workflows/.
pr.yml (pull requests), on GitHub's runners:
| Job | Does |
|---|---|
python | uv run lint: ruff, ruff format, strict mypy, pytest (which includes the book's link and screenshot-index tests) |
book | builds this handbook with mdbook build docs, so a broken chapter or SUMMARY.md fails the PR |
wasm | uv run port setup and uv run port build web: the decompiled game compiles for WebAssembly (needs only the source) |
regressions | uv run extract-game, toolchain setup, match, match-data: every recorded match still matches, and the data still matches. Needs the game CD and the Borland CD, so it runs on a self-hosted runner labelled zbtools-data |
main.yml (pushes to main):
- Deploys the port (
uv run port package) to Cloudflare Pages withwrangler-action, on a GitHub runner (it needs only the repository). Secrets:CLOUDFLARE_API_TOKEN,CLOUDFLARE_ACCOUNT_ID; variableCLOUDFLARE_PAGES_PROJECT. - Deploys this handbook (
mdbook build docs, outputbuild/book/) to a second Cloudflare Pages project, with the same two secrets and the variableCLOUDFLARE_DOCS_PAGES_PROJECT. - Runs
uv run reporton the self-hosted runner and uploads it as an artifact. The report contains the game's disassembly, so keep the repository private.
The self-hosted runner
The jobs that need the bring-your-own files run on a runner labelled zbtools-data, with ZBTOOLS_DATA in its environment pointing at a directory holding the files that would be in data/ (the workflow symlinks it), plus 7-Zip, Wine and JDK 21. They are skipped until the repository variable GAME_DATA_RUNNER is true.
Mohawk archives
A Mohawk archive (MHWK magic, RSRC directory) is the container for every resource the game loads. Its on-disk structure (header, data, type and resource tables, file table) is described under the resource manager, the reader in src/zbtools/mohawk.py reads and writes it exactly as the game's archives are laid out (rejecting anything it couldn't reproduce), and ScummVM's engines/mohawk/ is the reference for the container (it names the Zoombinis types but doesn't implement the game). This chapter is about what the game's archives contain.
The game's archives
The disc's Mohawk archives are the 20 DATA/*.MHK files and MIDIMAP.DAT (in the disc's root, installed next to the program; it holds two SYSX resources, MIDI channel messages the MIDI map sends a device, by ID from [MidiMap.TargetDeviceInfo]: despite the name, not system-exclusive, but controller resets for all 16 channels). BROEMIDI.TMI starts MHWK too, but it's a Mohawk MIDI file (MHWK/MIDI), not an archive. All 21 archives are laid out the same way, so each is determined by its resources' types, IDs, data, flags and order (uv run assets verify rebuilds them from just that, byte for byte):
- The header says version 0x100 and
compacted1 (the file may hold unused space). The unused space is 8 bytes,00 04 00 00 00 00 00 00, between the header and the first resource's data; compacting the file (closeResourceFilewithcompact) would drop them. - The data is stored in file-table order, with no gaps. The directory follows it, then the file table, which ends the file.
- The directory's type table is sorted by type (by its bytes, so
\0SNDcomes first and the lower-casettypes last). The types' resource tables follow in the order each type first occurs in the data, each followed by its (empty) name table. No resource has a name, so the names start where the directory ends. - The only file-table flag set on disk is
RESOURCE_PURGEABLE(0x80), on all 18tMIDresources inMIDIMPC.MHK.
What they hold (resource counts across the 20 .MHK archives):
| Type | Count | Bytes | Contents |
|---|---|---|---|
\0SND | 1,333 | 43.8 MB | Sounds (details) |
tBMP | 175 | 20.5 MB | Images and banks of images (details) |
SCRB | 1,782 | 345 KB | Feature scripts: the scripts that animate views (details) |
SCRS | 719 | 499 KB | Zoombini ("snoid") scripts: the same, with the way the Zoombini faces |
REGS | 79 | 29 KB | Tables of big-endian words, meaning what their users make of them (shapes' offsets, the maze's and Lilly's tables) |
SHPL | 41 | 25 KB | Shape lists: the first shape's ID (a tBMP) and the count, then a palette (e2memory.cpp) |
tMID | 18 | 77 KB | Music (details) |
NODE, PATH | 9 each | 0.9 KB | The graph of the paths Zoombinis walk: a count and points {x, y}; a count and 24-byte lists of nodes (0: none) |
tPAL | 6 | 6 KB | Palettes (MAZE2.MHK only): u16 first colour and count, then PALETTEENTRYs. Every colour of every real palette has flags 1 (PC_RESERVED); 13 one-colour placeholder palettes in SHPLs have 0. |
CURS | 5 | 340 B | Mac cursors: 16x16 image and mask, then the hot spot (ZOOMBINI.MHK) |
ScummVM's engines/mohawk/resource.h names the Zoombinis types (SCRB "Feature Script", SCRS "Snoid Script", NODE "Walk Node", PATH "Walk Path", SHPL "Shape List"), and detects the game but doesn't implement it.
The movies (DATA/LOGO*.MOV) are QuickTime files outside the archives: video in QkBk (the codec installed as qb32.qtc), sound in twos (PCM). See Movies.
Working with the assets
The game's resources are kept as source, like decomp/, in assets/ (committed). There is only ever one copy of each resource, in a modern form, or its original bytes (.bin) where no format fits (none of the game's needs that).
uv run assets extract # the disc's archives and movies into assets/ (needs the disc; won't overwrite without --force)
uv run assets pack # assets/ back into archives and movies, in build/assets/, laid out as on the disc
uv run assets verify # packing reproduces the disc's archives and movies byte for byte
uv run assets frames LOGO025 1 701 # draw frames of a movie as PNGs in build/movie-frames/
uv run movie-check # our drawing of every frame against the original codec's
pack needs only assets/, so it works from a fresh clone; extract and verify need the disc (uv run extract-game first). Edit a resource and pack builds the archives with the change; verify names the resources that differ from the disc's.
Layout
assets/<ARCHIVE>/archive.toml the archive's path on the disc, and its resources in stored order
assets/<ARCHIVE>/<TYPE>/<id>.<ext> one file per resource
assets/movies/<NAME>/ a movie (see Movies)
assets/zoombi32/ the executable's own resources and the installed game's files
| Type | What | Stored as |
|---|---|---|
SND | sounds | WAV (8-bit PCM; a loop as a smpl chunk) |
tBMP | images, and banks of them (a sprite's frames) | indexed PNG (a bank's in <id>/<n>.png), with <id>.toml recording how each was packed |
tMID | music | standard MIDI file |
SCRB, SCRS | scripts animating features and Zoombinis | TOML: frames of cels ([image, x, y]), events and sounds |
SHPL, tPAL | shape lists, palettes | TOML, colours as #rrggbb |
CURS | cursors | Windows .cur |
REGS, NODE, PATH, SYSX | tables: offsets, walking paths, MIDI messages | TOML |
ICON (zoombi32/) | the executable's two icons | indexed PNG, with resources.toml naming the icon group |
Zoombini.who (zoombi32/installed/) | the saved-game list | TOML (formats/roster.py) |
DATA/*.MOV | four QuickTime movies | frames.toml, palettes.toml, bitmaps.toml, casts/*.png, sound.wav, movie.toml |
The installed game's other files the port needs are also there so the port builds without the disc: mohawk.w32 (the engine's settings file) and CORNER.TTF (its font) as they are. The font, Cornerstone, is free for personal use and included on that basis, since this project is non-commercial.
The rules for a format
A Format (src/zbtools/formats/base.py) converts one resource type to files named after a stem (<type>/<id>) and back:
- It must round-trip exactly.
extractsaves a resource, loads it back and keeps it raw if the bytes differ.assets verifyrebuilds every archive and compares it with the disc: thematchof the assets. A format that can't give back the original bytes raisesUnconvertible. - It re-creates Broderbund's encoders, not any valid encoding: Okumura's LZSS, the row packer, the movie's run-length encoder. Where something can't be derived (leftover junk bytes in memory), it is recorded in TOML.
- It may use the rest of the archive only for viewing (an image's display palette), never for what
loadneeds. An image's pixel values are what the game draws; its PNG palette is only for looking at it. Colours are changed in the palette resources. - Compressed images are cached in
build/assets-cache/holding only the encoder's own output (never the disc's bytes, orverifywould test nothing); compressing all of them takes about two minutes of CPU.
To add a format: implement save/load in src/zbtools/formats/, register it in formats.FORMATS, add a test in tests/test_formats.py for anything checkable without the disc, and run extract and verify against your copy.
Icons
The executable's own resources (its icon) are in assets/zoombi32/ICON/<id>.png with resources.toml. extract writes them, verify checks them against zoombi32.exe's, and uv run build compiles them into the rebuilt executable. Each icon must stay 16 colours (plus transparency), as Windows 95 icons are; the port reuses them for the page's favicon.
Sounds, music, images and scripts
Sounds and music
A sound (waveObj, decomp/wavesound.cpp) is MHWK, a size, WAVE, then chunks padded to even lengths: an optional Cue# (a u16 count of named positions) and Data (u16 rate, u32 sample count, u8 bits, u8 channels, u16 encoding, u16 loop count, u32 loop start and end, then samples). All 1,333 are 8-bit mono PCM at 11,025 Hz. 23 loop, forever (0xffff), over a valid range, and exactly those have a Cue#, empty; no sound lists a cue point. Odd-length data has a zero pad byte, which the header's size includes.
Music is MHWK, a size, MIDI, then a standard MIDI file's MThd and MTrk chunks padded to even lengths, with a Prg# chunk after MThd: a count, then {u16 program, u16 mask of channels} for each program the track changes to, sorted. It's derived from the track in every one of the 18. Unlike a sound's, the header's size leaves out the last chunk's pad byte. All are type 0 at 480 ticks a beat, with markers the engine looks for (Setup end, Loop end; midisound.cpp) and a track name naming the original file (ZBIsle.mff).
Images
An image (ImageHeader) has a big-endian header: width, height, bytes per row (the width rounded up to 4) and flags (all the game's are 8-bit, 2, plus 0x10 packed and 0x100 compressed), then its pixels. A bank of images (loadImageBank, decomp/view.cpp), holding a feature's frames, has the same header, as if it were one image: its width the number of images, its bytes per row that rounded up to 4, and its height the bank's size divided by that (truncated to 16 bits: MAZE2.MHK's 2 MB tBMP 11000 says 3611). The header is followed by each image's offset (the first right after the offsets, each 4-byte aligned), then the images, each with its own header. decompressImage treats the bank's header as an image's, so a compressed bank is decompressed whole. Of the 175 tBMPs, 111 are banks (109 compressed), with 10,017 images (7,714 packed), and 64 are single images (61 compressed, 34 packed, among them the scenes' 640x480 backgrounds). Rows are stored top row first.
- Compression (
lzDecompress,0x48e4df) is LZSS: flag bytes low bit first (1: a literal), matches as big-endian words (low 10 bits a position in a 1 KB ring, the rest the length less 3), the ring zeroed with writing starting 66 bytes from its end. After the header come the decompressed size, the compressed size and the ring size (0x400). Broderbund's compressor was Haruhiko Okumura'sLZSS.C(1989): a port of its encoder, binary search trees and all, reproduces all 170 compressed resources byte for byte. The trees' shape decides between equally long matches, so an encoder that merely finds longest matches doesn't.LZSS.Cdoesn't clear the lookahead past the end of a short input: in the one resource shorter than the 66-byte lookahead (TOWN.MHK'stBMP1200, 48 bytes), the original had non-zero bytes there, and any value but 0 and the input's0x32reproduces its last match. - Packing (
drawPackedPixels): each row is a u16 size and packets, a header byte whose bit 7 means a run of the next byte, else literal bytes, the count being the low bits plus 1. Broderbund's packer runs colour 0 (transparent) always, other colours from 4 pixels on, splits runs at 128 (judging what's left afresh) and ends every row with a run, however short: rules that re-pack all 365,166 packed rows exactly. - Junk. After a packed image's rows the packer left 2 bytes (in a bank, then padding to a multiple of 4), and unpacked images have row padding. 80% of those tail bytes, and 79 padding rows, aren't zero but leftovers in memory (
db6d,6db6patterns);assets/records them where they aren't zero.
Scripts
A script (SCRB, SCRS; stepped through by features.cpp, frames found by scriptFrameOffset, 0x464cbc in view.cpp) is big-endian signed words: the number of frames, for a Zoombini script the way it faces (setSnoidFacing), then the frames. A frame is its cels (at most 24 in the game's), each an image of the view's bank (0 draws nothing; its x and y are then always 0) and its x and y, then an end word: 0xff00 plus an event number (0: none) that the view's owner is told of, or 0xfe00 plus an event followed by a sound to play. All 2,501 scripts parse exactly, their frame counts matching.
Small resource types and installed files
The other resource types in the game's archives (counts across the 20 .MHK files). The big ones (SND, tBMP, SCRB/SCRS, tMID) are in Sounds, images and scripts.
| Type | Count | Contents | Used by |
|---|---|---|---|
REGS | 79 | tables of big-endian words, meaning what their users make of them: shapes' offsets (hot spots), the maze's and Lilly's tables | loadMazeTable, loadHotSpotTable, groupHotX/groupHotY, the isle's and Mirror Machine's hot spot tables |
SHPL | 41 | shape lists: the first shape's ID (a tBMP) and the count, then a palette | e2memory.cpp (e2GetShapes) |
NODE, PATH | 9 each | the graph of the paths Zoombinis walk: a count and points {x, y}; a count and 24-byte lists of nodes (0: none) | snoids.cpp (pathsResource, pathNodesResource) |
tPAL | 6 | palettes (MAZE2.MHK only): u16 first colour and count, then PALETTEENTRYs. Every real palette's colours have flags 1 (PC_RESERVED); 13 one-colour placeholder palettes in SHPLs have 0 | Bubblewonder Abyss |
CURS | 5 | Mac cursors: 16×16 image and mask, then the hot spot (ZOOMBINI.MHK) | WinMain loads cursors 1-5 |
SYSX | 2 | in MIDIMAP.DAT: MIDI channel messages the MIDI map sends a device, by ID from [MidiMap.TargetDeviceInfo]; despite the name not system-exclusive but controller resets for all 16 channels | midimap.cpp |
Every resource ID is a number; there are no names. Resource files that look like archives but aren't: BROEMIDI.TMI starts MHWK but is a Mohawk MIDI file (MHWK/MIDI).
Files the installer leaves next to the program
| File | What | Where in the repo |
|---|---|---|
zoombi32.exe | the Windows 95 build | build/zoombi32/ (from the disc; never committed) |
qb32.qtc | the QuickTime codec for QkBk | build/zoombi32/ |
mohawk.w32 | the engine's INI ([Audio], [WaveMix], [WaveMix.DeviceInfo], [MidiMap.TargetDeviceInfo], [Resource] fShareReadOnly…) | assets/zoombi32/installed/ |
MIDIMAP.DAT | a Mohawk archive of MIDI messages | assets/MIDIMAP/ |
Zoombi32.CFG | [INSTALL] keys; see Zoombi32.CFG | generated by vm install-game and port.py |
CORNER.TTF | the Cornerstone font | assets/zoombi32/installed/ |
Zoombini.who | the saved-game list | assets/zoombi32/installed/ (TOML) |
What's on the disc
Paths are relative to the disc root (build/disc/ after extraction).
| File(s) | What |
|---|---|
ZBARCHIV.Z | InstallShield 3 archive holding the Windows 95 build: zoombi32.exe, qb32.qtc, config files |
ZOOMBINI._EX | the Windows 3.1 build, uncompressed despite the name: a Win16 NE executable (~940 KB, 191 segments, Borland C++ 4.x runtime). Useful for cross-reference only |
BRODFONT.DLL, BRODMIDI.DLL, BRODPGI.DLL, BRODREG.DLL, BRODUTIL.DLL | Broderbund Win16 support libraries |
DATA/*.MHK | Mohawk resource archives, roughly one per puzzle or area; read from the CD at run time by both builds |
DATA/*.MOV, QB.DEC | the QuickTime movies, and the Win16 codec |
MIDIMAP.DAT | MIDI messages archive |
MOHAWK.WIN, DATA/MOHAWK.MAC | engine configuration |
QTWSET32/, QTWSETUP/ | QuickTime for Windows 2.x installers (32- and 16-bit; third party) |
SYSTEM/WING* | Microsoft WinG, used by the Windows 3.1 build |
MS*, _INST32I.EX_, SETUP.*, INSTALL.EXE, AUTORUN.EXE | installer and autorun parts (ignored) |
uv run unpack-isz reads InstallShield 3 archives (ZBARCHIV.Z).
Movies and the QkBk codec
Movies
The game plays one movie, Data\Logo025.MOV (the intro logo; decomp/town.cpp), and it's the only one either build names: the Windows 95 build's strings and the Win16 ZOOMBINI._EX's both name only Logo025.MOV. The disc holds four (LOGO025, LOGO025B, LOGO027, LOGO027B, 5.8 to 7.3 MB), all laid out the same way: moov (mvhd, a video trak, a sound trak, udta), then mdat.
-
Video: 640x480, sample description
QkBkwith vendorBrod.Logo025.MOVhas 1,392 frames of 60 units each at a timescale of 600 (10 fps), 83,520 units in all (139.2 s). -
Sound:
twos, 8-bit mono at 11,025 Hz (1,503,811 samples). -
No other decoder: ffmpeg's QuickTime tag table (
libavformat/isom_tags.c) has noQkBk, so ffmpeg-based players can read the sound but not the video. The only decoder is Broderbund's QuickTime codec component:qb32.qtc(PE32 DLL, 45 KB, inZBARCHIV.Z, installed next to the game), andQB.DECin the disc's root, the same component for Win16 (NE, 24 KB). -
What the codec exports: both copies export the same functions.
THNGIDENTIFYis QuickTime for Windows' component registration. The rest areQBSetLogFileName,QBSetCallBackProc,QBActivateChannel/QBDeactivateChannel,QBActivateCast/QBDeactivateCast,DrawMHBkGndToOffworldandCopyMHPortToOffworld("MH" presumably Mohawk). Both contain the stringsBckR,FrtRandXFrm, perhaps chunk tags inside frames.QB.DECsays "Copyright 1994-1995 Broderbund Software";qb32.qtccarries Apple's "Copyright 1988-1995" (from QuickTime's component library, presumably).qb32.qtc's PE linker version is 2.50, not TLINK32's 2.25, and it imports a C runtime's usual startup functions, so it wasn't built with the game's Borland toolchain. -
What
QkBkis: not a pixel codec but a scene compositor, like Macromedia Director's score: a frame says which sprites (bitmaps from a cast) sit where, in 256 colours, and the codec draws them, so a frame that changes nothing is 420 bytes. The layout is documented informats/qkbk.py(its docstring is the reference;uv run ghidra codecwrites the C it was read from tobuild/ghidra/qb32.c). The evidence, inqb32.qtc:THNGIDENTIFYregisters adcmpcomponent of subtypeQkBk, vendorBrod(at0x1000c038); its entry,0x10008568, dispatches on the selector inbxto QuickTime's image decompressor calls: 0GetCodecInfo(0x10001b40), 5PreDecompress(0x10001b70, which requires 8-bit depth), 6BandDecompress(0x10001dd0) and 7Busy(0x10002360, which does nothing). Ghidra doesn't find the handlers that take the selector that way;uv run ghidra codeccreates them.BandDecompressswaps the frame's big-endian fields in place (0x10008000swaps a u16,0x10008010a u32; bit 31 of the flags marks a frame already swapped) and walks the structure: header, sprite slots, cast items, then the blocks the flags ask for (BckR,FrtRandXFrm, which it compares as little-endian integers). Cast items are registered by ID (0x10002860); the compositor (0x100035f0,0x10002f70,0x10002db0) draws the slots in order. The dispatcher has no compress calls and the DLL nothing that writesQkBk: the codec only decompresses, so the encoder is inferred from the data.- A bitmap's rows are drawn by
0x10008030: each is{u16 length, data}, and a control byte with the top bit set repeats the next byte(c & 0x7f) + 1times (skipping, not drawing, when that byte is 0) while one without copiesc + 1literal bytes. Palettes are copied to aLOGPALETTEbyte by byte (0x100033f0): the high byte of each 16-bit colour channel. - Names and semantics of the header words are from their uses; some are unknown (
f4,f6,f23informats/qkbk.py, and three words of each bitmap's header). They're kept as they are. The codec also has a performance log (QBPLAYER.prf: dropped frames, palette transitions), callbacks (blocks taggedPreFandPstF, flag 2) and a hook that draws a Mohawk background under the frame (DrawMHBkGndToOffworld,CopyMHPortToOffworld); none of the game's movies uses the callbacks, and the game doesn't load the codec itself (QuickTime does).
-
The encoder is inferred, and checked: all 3,156 bitmaps of the four movies (178,380 rows) re-encode byte for byte with one rule set: runs of at least 8 pixels of one colour are run tokens, shorter ones literals; 0 (transparent) always runs, never appears in a literal; runs longer than 128 are cut at 128 and the remainder, if under 8, joins the next literal; literals are at most 128; rows pad to an even length with a zero byte. The four movies share one structure (1,392 frames, 35 sprite slots, 34 palettes and 789 bitmaps each, cast item IDs unique and never re-sent) and differ in bitmaps and sound; sprite sizes always equal their bitmaps'.
-
The containers (
formats/mov.py):moovthenmdat; the tables' chunking isn't regular (a sound chunk and one to several frames, alternating), so the order of the chunks is recorded.mdatstarts with 788 (804 inLOGO027) bytes no chunk refers to, which look like run-length data, andLOGO027andLOGO027Beach have a stray byte among the chunks. The sync-sample table (stss, 175 frames) isn't derivable from the frames (it isn't the frames that define cast items). The sound is signed 8-bit mono (twos) at 11,025, 22,050, 11,127 and 22,254 Hz in the four files, with an edit list that delays it 0.8 s and leaves a 2 s gap in it. The timestamps differ between files; everything else inmvhd,tkhd,mdhd, the handlers and sample descriptions is the same standard QuickTime boilerplate. -
What a frame says, and what's derived: the two rects in a frame's header are the union of its sprites' rects (
bounds) and the union of the old and new rects of the slots that differ from the previous frame's (dirty, all slots in the first): exact in all 5,568 frames, so the converter works them out and doesn't store them. The codec usesdirtyas the area to redraw (BandDecompresstakes it from the frame when the frame follows the last; when it doesn't, it works the same union out itself,0x100016c0), so an edited frame needs it right.f4andf6have no pattern we could find (the frame's number or 0 with 1 or 0, and other values in frames that define cast items); the decoder never reads them. -
Checked:
uv run assets verifyrebuilds all four movies fromassets/movies/and they match the disc's files byte for byte.uv run movie-checkruns the originalqb32.qtcunder emulation (Unicorn,qb32.py:PreDecompress, thenBandDecompressfor each frame over the last, with the few Windows functions it calls answered in Python) on the samples built fromassets/, and every frame of all four movies comes out pixel for pixel asformats/qkbk.pydraws it (sprites in slot order on the background colour, colour 0 transparent), and the 56 times the codec sets the palette are where the frame's palette ID changes, to the palette's colours 10 to 245 (it asks the system whether its 20 static colours are kept: if so it realizes 236 colours, the palette's 10 to 245; if not, 255, the palette's 1 to 255, at the same device indices, since colour 0 stays black. The port's player and the emulator both answer for whichever the system says; it's static while the intro plays). An edited movie (sprites moved over 21 frames) decodes as predicted too. The codec's output buffer is in 64 KB segments of 102 rows with a 256-byte gap, a leftover from 16-bit code.
The movies' modern form is in assets/movies/, and the port plays it from a flat scene file (formats/scene.py, port/glue/quicktime.cpp).
QuickTime for Windows glue
QuickTime for Windows glue (0x46cca0-0x46d754)
Between the game's code and its support library sits the glue from Apple's QuickTime for Windows 2.x SDK, statically linked (src/zbtools/quicktime.py). It loads QTIM32.DLL (QuickTime) and CMGR32.DLL (its component manager) with LoadLibrary and fetches each one's single _EntryPoint (and _CMgrInitialize/_CMgrTerminate) with GetProcAddress; QTIM32.DLL's file version is read through version.dll. Every API function is a hand-written assembly stub (16-bit register use gives it away) that loads a selector into bx and calls the dispatcher through a pointer: 102 QTIM stubs (13 bytes each, pointer at 0x4a7f84) and 24 CMGR stubs (17 bytes, also setting ax = 1, pointer at 0x4a7f90). Until a DLL is loaded its pointer holds a fallback (0x46ce80, 0x46cca0) that calls QTInitialize and retries; 0x46d3dc is QTInitialize(long *version) and 0x46d745 is QTTerminate().
Nothing maps selectors to API names: QTIM32.DLL exports only _EntryPoint, a few helpers (Flip16, GetMemory, ...) and unnamed ordinals, and the Win16 build imports QTIM.DLL by ordinal. So the stubs are named by selector (qtim_39) until the way the game uses one identifies it. The glue isn't Broderbund's code: the inventory puts it in its own quicktime region, as library code, and uv run ghidra label names it.
Zoombi32.CFG and installer settings
Zoombi32.CFG
Zoombi32.CFG ships in ZBARCHIV.Z as an empty file. The game reads two keys from its [INSTALL] section at startup (the config module) and reports "unable to read file Zoombi32.CFG" if they're missing:
| Key | Meaning | Example |
|---|---|---|
INSTALLFROMDIR | Root of the game CD. The game appends Data\ to find the .MHK archives, so it needs a trailing backslash. | D:\ |
INSTALLTODIR | Install directory. | C:\Program Files\Zoombi32\ |
The original InstallShield script (SETUP.INS) writes these values. vm install-game and the port ship filled-in copies instead (src/zbtools/game_install.py, port.py's _CFG). The same function checks that <INSTALLFROMDIR>Data\Zoombini.mhk can be opened, which is how the game decides whether the CD is in.
QuickTime installer settings
The 32-bit QuickTime for Windows 2.1 installer on the disc (QTWSET32/QT32B42.EXE) reads its options from an INI named after itself, QT32B42.INI, not the QT32INST.INI shipped beside it (which belongs to the older installer in OLD32INS.EXT/). With QT32B42.INI, PromptToBegin=0 and friends suppress every dialog, which is what lets vm install-game run unattended; the other name is ignored entirely.
Repository layout
decomp/ the decompiled game and engine: <module>.cpp / <module>.h, zoombinis.h,
modules.toml (the module map), matching.txt (what matches)
glue/ C++ standing in for what the game links but we don't have (QuickTime's SDK glue)
assets/ the game's resources in modern formats, one directory per Mohawk archive,
plus movies/, and zoombi32/ (the executable's icon and installed files)
port/ the port: CMake, miniwin, the host layer, the web page
src/zbtools/ the Python tooling
tests/ pytest, for everything checkable without bring-your-own files
docs/ this book (book.toml, src/, theme/)
data/ your ISOs and .env's neighbour (gitignored, read-only)
build/ every generated file (gitignored): disc, VM disks, toolchain, Ghidra, report, port, book
decomp/
One .cpp per original object file (about 150 in all: 42 game modules, 6 OS-layer modules, the engine's many small ones). Each has an optional .h. Special files:
| File | Role |
|---|---|
zoombinis.h | shared types (View, Snoid, Scene, Party…), the engine's classes as the game's calls show them, and every global several modules use |
modules.toml | the curated module map: start address, name, evidence |
matching.txt | functions known to match, written by uv run match --update |
<module>.h | that module's prototypes and the globals and types only it (and its callers) use |
See Source modules and Modules and scenes for what each does.
glue/ and port/glue/
glue/quicktime.cpp re-creates Apple's QuickTime for Windows SDK glue (selector stubs written as raw __emit__ bytes that load QuickTime and forward calls). It isn't decompiled code, has no markers, match ignores it, and it may use inline assembly. The port replaces it with port/glue/quicktime.cpp, which plays the intro movie itself. Any file in port/glue/ or port/decomp/ replaces the same-named file when the port builds.
assets/
assets/<ARCHIVE>/archive.toml where the archive goes on the disc; resources in stored order
assets/<ARCHIVE>/<TYPE>/<id>.<ext> one file per resource, in its modern format
assets/movies/<NAME>/ frames.toml, palettes.toml, bitmaps.toml, casts/, sound.wav, movie.toml
assets/zoombi32/ ICON/, resources.toml, installed/ (mohawk.w32, CORNER.TTF, Zoombini.who)
Archives: BASECAMP, BCTWO, BRIDGE, CAVES, FERRY, FLEENS, HOTEL, LILLY, MAP, MAZE2, MIDIMAP, MIDIMPC, NET, PICKER, PIZZA, SLIDES, SMOKE, TOWN, TUNNELS, XFER, ZOOMBINI.
src/zbtools/
See the tool reference.
Architecture
The game is five layers deep. Each calls only the ones beneath it, and the port replaces the bottom two.
┌──────────────────────────────────────────────────────────────────────┐
│ The game scenes (puzzles, map, camps), Zoombinis, dialogs, save │ decomp/<scene>.cpp, snoids,
│ files, input groups, views and scripts │ features, view, focus, ...
├──────────────────────────────────────────────────────────────────────┤
│ e2 layer the game's own wrappers over the engine: handles, │ e2memory, loading, graphics,
│ resources, shapes, sounds, fonts, fade, errors │ sound, anim
├──────────────────────────────────────────────────────────────────────┤
│ Mohawk engine QuickDraw-style ports and regions; Mac-style memory │ 0x4764bc-0x494000: baseport,
│ and resource managers; audio objects, WaveMix; async │ newhandle, resourcefile, ...
│ files; INI reader; timers │
├──────────────────────────────────────────────────────────────────────┤
│ Mohawk OS reference counts, local memory, deferred calls, │ os_*.cpp (0x46d754-0x46f7a4)
│ layer cooperative threads on fibers, timers, window hooks │
├──────────────────────────────────────────────────────────────────────┤
│ Win32 / C RTL KERNEL32, USER32, GDI32, WINMM, DSOUND (by name), │ Borland CW32.LIB, the Windows
│ QuickTime QuickTime for Windows 2.x (by name, via SDK glue) │ DLLs; miniwin + SDL2 in the port
└──────────────────────────────────────────────────────────────────────┘
Why it looks like a Mac game
Logical Journey of the Zoombinis was a Mac title first, and Broderbund's Mohawk engine imitates the Mac Toolbox so that game code could move across. The evidence is everywhere: handles and purgeable memory, a resource manager with typed IDs, regions and Rect/Point, GrafPort-style ports, transfer modes, 'CURS'/'tBMP'/'tMID' resource types, Mac message strings, 16-bit-style short arithmetic, and big-endian resources byte-swapped on load. The Windows layer below it is thin. This is also why the port is cheap: it implements Win32 for the engine, and the game never notices.
What each layer owns
| Layer | Owns | Key objects | Read |
|---|---|---|---|
| Game | the rules of every puzzle; the journey; saved games | Scene, View, Snoid, gameState, input Groups | Game layer, Entities |
| e2 | loading and lifetime of game resources | resource handles, ImageBank, SoundEntry | Game layer |
| Engine: graphics | drawing | basePort and subclasses, DIB, regions, Palette, Color | Graphics |
| Engine: memory/resources | storage and Mohawk archives | handles, 'BM' blocks, 'RMap' maps | Memory and resources |
| Engine: audio | sound | audioObj, wavebuf, wmxMixer | OS layer, timers, audio |
| Engine: files, INI | the file system | fileSpec, asyncAPI, INI reader | OS layer, timers, audio |
| OS layer | pseudo-threads, timers, deferred calls | thread, sync, DeferLock | OS layer, timers, audio |
C++ classes
Only the engine and OS layer are polymorphic (46 classes with RTTI); the game's own logic is plain functions and structs, which is why most game structs carry a /* +0x.. */ comment and no vtable. See Classes.
Control flow in one picture
WinMain
└─ loop: mainLoopUpdate() enterNextScene if pendingScene != -1; handle an event or the mouse
mainLoopEvents() handleWaitingMessage (the Win32 queue → mainWindowProc → events)
└─ frameHook = gameFrame scenes[currentScene]->frame() ← each scene's logic runs here
See Startup and The main loop.
Startup: WinMain
WinMain (0x4546f8, decomp/game.cpp) is reached only through the startup code's table at 0x4a0044 (which is why Ghidra misses it). It does, in order:
- Single instance. Registers
shutDownAtExitwithatexit; if a window whose class is the program's own file name exists, brings it forward and returns; also returns if there's a previous instance or the global atomZoombiniexists. Otherwise adds the atom (deleted once graphics are up, and at exit). A crash leaves the atom behind. - Mode and hooks. A command line starting with
dswitches on debug mode. SetsclockInTicks, installs the frame hook (gameFrame), fatal hook (shutDownGame), click hook (refreshCursor) and about hook (showAboutBox). - The engine, step by step, each failure fatal with a message naming the step:
osStartup(the OS layer, given a 0x5f50-byte buffer for its thread stacks),initTimers,initMemory, a free-memory check (about 1.6 MB, 3.7 MB on anything newer than Windows 3.11, plus a physical-memory check of 6 MB),initFiles,initIni,initResources,initSound, then checks for at least one wave and one MIDI device. - Game data.
enterGameDirectory, reads the saved-games list (Zoombini.who), andfindGameData(theconfigmodule:Zoombi32.CFG, and asks for the CD ifData\Zoombini.mhkisn't there). - Display.
initGraphicsfor 640×480, 256 colours (checkDisplayModefinds the smallest mode that fits, falling back to 512×384;createMainWindowmakes a borderless popup covering the screen), realises the palette, loads the CornerStone font at 13 and 18 points. - Game state. Allocates
gameState(0xae05 bytes), fills the roster header, applies the player's settings,initViews,loadSnoids(the Zoombinis' images), and loads cursors 1-5 from'CURS'resources. - QuickTime.
QTInitializemust report version 2.3 (0x2300) or later, and the component manager must start, else a fatal message. - Run.
pendingScene = 0(the intro) and loop:while (mainLoopUpdate() && !quitRequested) mainLoopEvents();, thenquitSilently.
Messages are named strings
Startup messages sit in a table of their own (0x4a4da4-0x4a4f7a, declared msg… in game.h) alongside Mac-only messages the Windows build never shows ("Virtual Memory must be turned off.", "Requires Sound Manager 3.1 or later to be installed.", "GetVol failed. Boot drive."). The original pushes each message by its own address instead of addressing literals from a pooled base, so they're arrays in the source.
Shutdown
shutDownAtExit → shutDownGame closes every scene (scenes[i]->close()), saves the roster if asked, stops sound and movies, and releases the engine. A fatal error calls the fatal hook then exits; showError prefixes and formats the message into a message box.
The "d" switch and debug mode
With d, debug mode is on, breakpoints requested by the engine really break (debugBreak). A second level, debugMessagesOn, is unlocked in game by a typed cheat code (the codes are compared by hash, see isCheat) and enables the debug keys in mainloop.cpp:gameKey (step mode, view labels, FPS, memory statistics, palette chart, "ALL in party").
The main loop, events and input
One pass
WinMain runs mainLoopUpdate() and mainLoopEvents() alternately until quitRequested is set.
mainLoopUpdate() (mainloop.cpp)
if gameActive:
if pendingScene != -1 → enterNextScene() (net.cpp: the scene switch)
every ~3600 ticks → halve snoidIdleDelay (Zoombinis fidget more)
if cursor is the busy one → return (nothing else this pass)
if handleNextEvent() → event queue, else the Win32 queue (handleNextMessage)
a key → postKeyEvent → gameKey a click → postMouseEvent → focus system
else → handleMouse(cursor position): hover tracking
mainLoopEvents() (debug.cpp)
handleWaitingMessage() (pumps one Win32 message through mainWindowProc)
if !loadingAnimation: frameHook() = gameFrame()
debug breakpoints, starvation check
gameFrame() (game.cpp)
setPort(workPort); scenes[currentScene]->frame(); setPort(saved)
draw memory statistics; step the animated cursor every 12 ticks
Everything in a scene happens in its frame callback, reached through gameFrame, or in a callback a view or input group invokes. There are no game threads.
Time
The clock counts 60ths of a second (clockInTicks = 1) through clockTime(). Views have their own clock (viewClock, resetViewClock) so a pause or a dialog doesn't age them. Timed behaviour is polled: a scene compares clockTime() with a stored due time in its frame. The engine's multimedia timers exist (timers) but the game rarely uses them directly.
Events
events.cpp holds a 32-entry ring buffer of Mac-style events (key and mouse button). The Windows procedure turns WM_CHAR/WM_KEYDOWN and button-down messages into events (handleMessage, mainWindowProc); handleNextEvent takes one and dispatches it. waitForEventFor(timer, ticks, type, discard) is the blocking wait used by animations and dialogs: it pumps messages until an event of the type arrives or the time passes.
Keys
gameKey (mainloop.cpp) notes each key for the cheat tracker, then:
- gives it to the dialog if one is open (
dialogFlags), - else to the scene's
keycallback, which returns whether it handled it, - else treats it as one of the game's own keys.
| Key | Action |
|---|---|
| Ctrl-N / Ctrl-L / Ctrl-S | new game / load game / save game |
| Ctrl-Q | quit (asks first) |
| Ctrl-B / Ctrl-D | music / sound on-off |
| Ctrl-G | "less action" / "more action" (Zoombinis fidget less) |
| Ctrl-H | hide / show the drag cursor |
| Ctrl-J | sticky / non-sticky mouse (click-to-drag vs hold) |
| Ctrl-T | screen transitions on/off |
| Ctrl-U | auto-sticky on/off |
| Ctrl-V | the about box |
/ or ? | help dialog |
Each toggle shows a name tag from toggleTexts (town.cpp). A saved player's settings are applied at startup (applyPlayerSettings).
Mouse and input groups: focus.cpp
On-screen controls are items (InputItem: bounds, hot spot, key, flags) arranged in groups (Group: handlers, items), which are listed in a group list (GroupList: groups, a click callback). Each scene calls setGroupLists(groups, count, flags) when it opens. The focus system:
- tracks the pointer (
handleMouse), highlighting the item under it through the group's handlers (enter,leave,hitTest…); - tracks a press like the Mac's
TrackControland calls the list's click callback with the item number when the button is released over the item that was pressed; - moves focus with Tab / Shift-Tab and each item's
key; - supports on/off and exclusive items.
A scene's button array is therefore not just a list of rectangles: the scenes' …Buttons[3] arrays are the input group's items (two buttons and the whole screen), and the scene's …Clicked(which) function is the group list's callback. When a puzzle seems to have no code for "the user clicked the Go button", look for <scene>Clicked.
Graphic buttons (buttons.cpp) draw an item from its group's image pair (normal and lit); most scenes instead draw their own buttons (drawBridgeButton, drawPizzaButton, …) from a button image bank and redraw them from a view's update callback.
Dragging
Zoombinis are dragged with the mouse. A drag cursor is a view (viewTail) that follows the mouse (trackDragCursor, drawDragCursor in view.cpp); "sticky mouse" makes a click pick up and a second click drop. viewAt(point, mask, backwards) is the hit test over the view list.
Scene switching
pendingScene is set by anything that wants to go somewhere (sceneDue is a scene's own "I'm done, go to X" flag, copied to pendingScene by its frame). enterNextScene (in net.cpp) then:
- records which camp a finished puzzle group should lead to (
puzzleLeft, per-group "left" bits ingameState); - decides whether to show the journey map in between (
viaMap: not in practice mode, not whenskipJourneyMaportransitionsOnis set, and only when leaving a puzzle, camp or the isle for another place), settingjourneyFrom/journeyToand sending the game to scene 2; - sets
journeyFrom/currentScene, notes that a new puzzle group has started (gameState+0x54, on entering scenes 7, 10, 13 or 16, the first puzzle of each group), marks the roster changed, and calls the new scene'sopen().
A scene closes itself: its frame (or click handler) calls its own close… function (freeing its images, sounds, views and groups) and then sets pendingScene, so by the time enterNextScene runs the old scene is gone. shutDownGame closes whatever is still open.
See Modules and scenes for the scene table.
How the game talks to Windows
The decompiled code calls the Win32 API directly, in a small number of places. This chapter lists them; they are also exactly what miniwin has to implement for the port.
The window (platform.cpp)
- One window.
createMainWindowregisters a window class named after the program's own file name (that is also how a second instance finds the first withFindWindow) and creates a borderless popup covering the whole screen, shown maximised. The game's 640×480 area is centred on larger screens (placeGamePort,alignRect;gameRect/shownGameRect). mainWindowProc(0x45605e) handles:WM_CHAR/WM_KEYDOWNandWM_L/R/MBUTTONDOWN(turned into game events),WM_SETCURSOR,WM_ACTIVATEAPP/WM_NCACTIVATE/WM_SETFOCUS/WM_KILLFOCUS(activateApp),WM_SYSCOMMAND(screen savers and minimising are blocked or handled),WM_PALETTECHANGED/WM_QUERYNEWPALETTE(realizeFullScreenPalette),WM_PAINT(throughpaintHook), andWM_CLOSE/WM_DESTROY/WM_QUIT/WM_ENDSESSION(a fatal "error" that quits). While a movie is showing, QuickTime's component manager sees each message first. Messages are also logged to a ring of 1,024 (logMessage) and dumped tomsgNNN.txton request (dumpMessages).- The message pump is the game's own:
PeekMessage/GetMessageinpumpMessage,handleNextMessage,waitWhilePaused, called from the main loop and from every blocking wait. There is noGetMessageloop inWinMain. - Activation. Deactivating (Alt-Tab) shuts down the screen port and the sound driver, and in some setups minimises the window; reactivating rebuilds them (
activateApp,gameActivated), asking to retry while the sound device is missing. - Display modes.
checkDisplayModeasks for 640×480 at 256 colours (falling back to 512×384, or another depth) andsetDisplayModeswitches. The game requires a palettised mode.canUseDisplayMode/getDisplayModein the engine use Windows 95's 0x94-byteDEVMODE.
Graphics (GDI)
All drawing goes through the engine's ports (see Graphics), which wrap GDI:
| Port | Backed by |
|---|---|
windowPort, displayPort | a window or screen DC |
memoryPort | a compatible bitmap |
DIBPort, DIB8Port | a DIB section; DIB8Port also writes its bits directly (assembly blitters, ported as functional C++) |
The game draws every scene off screen into workPort and copies changed regions to screenPort; e2MapSave saves screen areas for restoring. Palettes: the game builds its own 256-colour logical palette so that realising it is the identity, animates PC_RESERVED entries in place for fades and colour cycling (fadeInViews, cycleColors), and brightens colours made for the Mac's lighter gamma (brightenPalette: c + 31 - c/8). A 20-colour static-colours question (does the system keep them?) decides whether 236 or 255 colours are realised.
Sound and music (WINMM, DirectSound)
- Wave sounds go through WaveMix-style objects: either straight to
waveOut(wmxWaveOut) or mixed in software (wmxMixer) into one output buffer per device, written throughwaveOut(wavebufWO, a polling thread) or a looping one-second DirectSound buffer (wavebufDS, when[WaveMix] fEnableDirectSoundis on andDSOUND.DLLloads). Long sounds stream from their file in 4 KB buffers. - Music is MIDI. The engine sequences it itself (
midisound.cpp: tempo, loop andSetup endmarkers, program changes) and sends channel messages tomidiOutas they fall due, through a MIDI map (MIDIMAP.DAT, controller resets for all 16 channels) that adapts to the device. The device list comes from the registry's Sound Mapper (Software\Microsoft\Multimedia\Sound Mapper) orMOHAWK.INI's[Audio]section. - Timers are multimedia timers (
timeSetEventat the finest resolution,timeBeginPeriod); their callbacks only post to aDeferLock, so procedures run on a thread that holds or releases it.timeGetTimeis the clock.
Files and the CD
fileSpec/files model Mac FSSpecs over Win32. Reads that could block (the CD) go through an asyncAPI family with a worker thread above normal priority; the port runs that on its cooperative scheduler. The game finds its data through Zoombi32.CFG and asks for the CD if Data\Zoombini.mhk is missing. Drive::setLocked locks and unlocks removable media through VWIN32's DOS IOCTL 440Dh/0848h, whose 2-byte parameter block has to be a 2-byte local (a 1-byte one overwrote the saved frame pointer). MOHAWK.W32 (installed beside the program; mohawk.w32 in assets/zoombi32/installed/) is the INI that configures the engine.
Memory
The engine's Mac-style memory manager sits on GlobalAlloc/GlobalLock (moveable blocks whose first word Win32 makes point at the memory, which the engine relies on). Startup checks virtual memory and, on anything newer than Windows 3.11, total physical memory (6 MB, via GlobalMemoryStatus). The game installs a grow-zone procedure that notes an out-of-memory condition.
Threads and fibers
The Mohawk OS layer runs its own cooperative threads on the one Win32 thread, each on a Win32 fiber it finds in KERNEL32 at run time (Borland C++ 4.5's headers predate them; Windows 98 and NT 4 have them, Windows 95 doesn't). Its scheduler runs on calls into the layer and every 20 ms, picks the highest-priority runnable thread, and pumps messages while idle. Real threads exist only where Windows forces them: waveOut callbacks, multimedia timers, the preload thread. See OS layer.
Run-time loaded libraries
| DLL | Loaded by | Used for |
|---|---|---|
DSOUND.DLL | LoadLibrary in wavebufDS | DirectSound output |
QTIM32.DLL, CMGR32.DLL | the QuickTime glue | the intro movie (QuickTime for Windows 2.x) |
VERSION.DLL | the QuickTime glue | reading QuickTime's file version |
| KERNEL32 fiber functions | GetProcAddress | the engine's contexts |
The movie's video codec qb32.qtc is loaded by QuickTime, not by the game.
Global atom
The atom Zoombini is the single-instance lock: added at startup, deleted once graphics are up and at exit.
The game layer: views, scripts, animation and resources
Between the engine and the scenes sits the game's own framework. Learn this once and every puzzle's code reads the same way.
Views (view.cpp)
A view (View, 0xbc+0x30 bytes) is anything on screen that can change: a Zoombini, a button, a troll, a plank, a piece of scenery. Views live in a doubly linked list between two sentinels, viewHead and viewTail, drawn back to front; sortViews orders them by depth when requestViewSort was called. Each has:
| Field | Meaning |
|---|---|
id | looked up with findView(id); scenes keep ids in …Views globals |
draw, update | callbacks: draw(view) paints it; update(view, region) adds the area it changed to a region |
notify(view, event) | told of its script's events (-1: the script ended) |
placed(view) | called after its script places its cels (so a scene can adjust them) |
kind, body.script, body.scriptGroup | its current SCRB/SCRS script and the image bank it uses |
body.cels[24] | what it draws now: {image, x, y} triples |
interval, nextUpdate | frame timing (in ticks); flags (1: a Zoombini, 2: large body, …) |
updateViews (called every frame) steps each view whose time has come (runViewScript for scripted views), collects the changed regions, redraws the affected views clipped to them into the work port, and copies the region to the screen. A scene's own code only creates views (startView, addSmokeSnoidView…), gives them scripts (setViewScript), reacts to their notifications and moves them (moveView, groupViews, pairViews for views that move together).
Scripts (SCRB, SCRS)
A view's animation is a script from a Mohawk archive: a list of frames, each a list of cels (image number from the view's bank, x, y) and an end word that is either 0xff00 + event (tell the view's owner) or 0xfe00 + event followed by a sound to play. runViewScript and runViewCels step them; queueViewSound plays sounds a script asks for. A scene loads its scripts when it opens (loadScripts(first, count)) and its images as banks (loadImageBank), and finds a script by id (findScript). A SCRS script is a Zoombini's: the same, with the way the Zoombini faces. The format is documented in Sounds, images and scripts.
The pattern in every puzzle is therefore:
user click ──▶ <scene>Clicked(which) set state, start a view's script
script frame ──▶ view->notify(view, event) the script reached a marked frame: the game's logic
<scene>Frame() each tick sequencing, timers, "everyone has crossed", fidgets
Event numbers are the contract between the art (the script data) and the code (the …Notify switch). They are why the …Notify functions are full of numeric cases (case 3:, case 20:): the numbers come from the scripts in assets/<ARCHIVE>/SCRB/.
Features and Zoombini layers (features.cpp, snoids.cpp)
Despite the name, features.cpp holds the image banks by group, cel drawing, and the dialogs; the "features" of a Zoombini (hair, eyes, nose, feet) are in snoids.cpp. A Zoombini on screen (Snoid) draws layers of images chosen from per-feature tables (hairImages, eyesImages, noseImages, feetImages, with alternate tables for the second walking pose and the other facing) and walks paths from the NODE/PATH tables.
Animations (anim.cpp)
An older, self-contained animation player from the engine's lineage: a script of opcodes (see stepAnim) moving up to 32 sprites (cast members, images from resources) over a background, drawing through lists of changed rectangles. Nothing else in the decompiled game calls it (playAnim and playAnimation have no callers), so it is dead code kept by the linker; the game's moving things are all views.
The e2 layer (e2memory.cpp, loading.cpp, graphics.cpp, sound.cpp)
Error messages name it: e2AllocHandle, e2SetupAnim, e2GetShapes, e2MapSave. It wraps the engine for the game's use:
- Memory and resources (
e2memory): handles and pointers, resources (asking for the CD if it's missing), shapes and lists of them, palettes, sound lists, fonts; counts the memory used.setFreeAtOncemakes a scene free as it loads. - Graphics (
graphics): the screen port and the off-screen work port, the palette, images, saved screen areas (e2MapSave), clip regions. - Sound (
sound,basecamp): wave sounds by key (loadWave,playWave,waitForWave) and MIDI, in up to four channels per type (soundChannels), withloadSound/unloadSoundslists a scene uses. - Loading and errors (
loading): a smallprintf-like formatter (%Lfor text in locked resources) and the error reporter that composes "Unable to load …" messages (joinText,reportJoinedError). - Fades and wipes (
basecamp):runWipe/startScreenWipeandrunBlinds/startScreenBlindsare the two screen transitions (wipe and venetian blinds);fadeInViews/fadeOutViewsfade palettes.
Hints and remarks
Each scene has a pool of sound ids for the remarks it makes (bridgeSounds, pizzaSounds, … in net.cpp, indexes of 'tWAV' resources), and "used" bitmasks (bridgeSoundsUsed) so a remark isn't repeated until the pool is exhausted. replayHint (features.cpp) repeats the scene's introduction.
Dialogs (features.cpp)
loadDialogs/showDialog and the askKeepParty, askNewGame, askLoadGame, askSaveGame and askQuit helpers build modal dialogs out of views and scripts from ZOOMBINI.MHK, with text from dialogTexts (289 strings, e.g. "THE CURRENT PARTY OF ZOOMBINIS WILL BE LOST IF YOU GO TO THE MAP"). While one is open, dialogFlags is set and gameKey routes keys to it.
Entities and how they relate
The game's data model
gameState (0xae05 bytes: the saved game)
│
┌─────────────────────┼──────────────────────────────┐
│ │ │
Party ───────────┐ records (16) sceneFlags / puzzleLevels
count │ (group, level, date) (per-scene progress)
Traveller[32] │
│ │
│ 1 : 1 │ waitingParties / savedParty (camps, isle)
▼ ▼
Traveller ──features──▶ hair, eyes, nose, feet (1-5 each: 625 kinds)
│ place, onboard, name
│
│ one per Traveller, created by each scene when it opens
▼
Snoid (a View's body + more: 0x103 bytes)
│ layers[16]: images chosen from the feature tables
│ path / pathIndex: where it is walking on NODE/PATH tables
│ action, pose, facingLeft, chosen (in the party), idleTicks
▼
View ──────────────▶ ViewBody: cels[24], script, frame, group, clip
│ draw / update / notify / placed callbacks
│ prev / next: the view list (viewHead … viewTail), drawn back to front
▼
Script (SCRB / SCRS from a Mohawk archive) ──events──▶ notify(view, event)
Scene ── open / close / frame / key ──▶ owns its views, its scripts, its image banks,
│ its sounds, and one GroupList of input items
│
├── GroupList ─ Group ─ InputItem[] on-screen controls (focus.cpp)
├── SceneButton[] (the same items, with their rectangles)
└── puzzle state FeatureRule(s), level tables, counters
Type reference
| Type | Defined in | What it is |
|---|---|---|
Scene | zoombinis.h | {open, close, frame, unknownC, key}: five callbacks. scenes[22] is the table. |
View, ViewBody, ViewCel | zoombinis.h | an animated thing; see Game layer |
Snoid | zoombinis.h | a Zoombini's view body: features, layers, path, action, pose, name |
Traveller, Party | zoombinis.h | a Zoombini on the journey and the group of them (19 bytes; 0x266 bytes) |
CampSlot, Camp | zoombinis.h | a stored Zoombini and the camp's 625 slots with their scroll row |
FeatureRule, FeatureRules | zoombinis.h | a puzzle's rule(s) about features: which side, how many features, which values (bridge, tunnels) |
ImageBank | zoombinis.h | images in one block: count and each one's offset (from 1) |
InputItem, Group, GroupList, InputHandlers | zoombinis.h | the focus system's items, groups and callbacks |
SceneButton | zoombinis.h | a button's rectangle plus 0x1c bytes of focus-system fields |
SavedGame, SavedGameList | zoombinis.h | the saved-game list's entries |
SoundEntry, SoundChannel, SoundChannels | zoombinis.h | loaded sounds, the four channels per type, and the sounds views asked for in an update |
Wipe, Blinds | zoombinis.h | screen transitions in progress |
DisplayMode, MemoryInfo | zoombinis.h | what WinMain asks of the display and learns of memory |
Rect, ShortRect, Point, Color | zoombinis.h | the engine's QuickDraw-style value types, as the game's calls show them |
Ownership and lifetime
- A scene creates its views, groups and resources in
open…and destroys them inclose….clearViews/removeDeadViews/closeViewsfree views;freeScripts,freeFeatureGroups,unloadSoundsfree the rest. Scenes keep view ids in globals (not pointers) and look them up withfindView, so a view can be deleted underneath them safely. - A Snoid is part of its view:
viewSnoid(view)casts the view's body,snoidView(snoid)goes back (-0x30). - Resources are loaded through the engine's resource manager as handles that the game locks while in use and the engine may purge; archives are opened per scene (
openGameFile(&hotelFile, "Hotel.MHK")) and closed when it closes. - A party is value data in
gameState; scenes copy it into travellers and write back.
Pairs and groups of views
Several puzzles move views together: groupViews(a…f) sets a shared group (1-16) so a script's motion applies to all, pairViews(a, b) links two. The camp's and the book's second input groups hold campAreaItems and bookAreaItems, the areas Zoombinis are dragged onto.
Game state, saved games and the roster
gameState
All persistent state is one block, gameState (char *, 0xae05 bytes, allocated in WinMain, snoids.cpp). It's written to disk as a saved game and read back whole, so its layout is the file format. The decompilation reads it through accessor functions and a few raw offsets (the offsets still marked *(short *)(gameState + 0xca) are fields not yet given names):
| Offset | Accessor / meaning |
|---|---|
+0x20 | the "less action / more action" setting (fidgeting) |
+0x46 | a counter the town uses to pick its script (townScript) |
+0x50, +0x51, +0x52 | which puzzle groups have been left at each level, as bits: the camp's unlock conditions (gameState[0x50] & 0xf, …) |
+0x54 | "a new puzzle group has started" (set on entering scenes 7, 10, 13, 16) |
+0x56 | sceneFlags(): per puzzle scene, bits 0-3 left at level 0-3, bits 4-7 passed at level 0-3 |
+0x62…+0xb2 | the records: sixteen groups passed (year, month, day, group, level), shown in Zoombiniville's monuments |
+0xc0 | puzzleLevels(): the level reached in each group (1-4) |
+0xca, +0xcc | the scene the player came from; savedScene(): the scene to resume in |
+0xa1fc | waitingParties(): parties waiting in scenes 3, 4 and 5 |
+0xa462 | savedParty(): the camp's party |
+0xa92e | party(): the Zoombinis setting out (a Party: count and 32 Travellers) |
+0xa934 | travellers() |
+0xab94 | zoombiniCounts(): how many Zoombinis of each of the 5⁴ = 625 kinds exist (hair, eyes, nose, feet, each 0-4) |
Zoombinis
A Zoombini is four feature values, packed in a long (Traveller::zoombini, Snoid::features[4]): hair, eyes, nose, feet, each 1-5 on screen (0-4 in the counts), so there are 5⁴ = 625 possible Zoombinis, which is why Zoombiniville fills at 625 (townFull) and the camp has 625 CampSlots. A Traveller also records where it stands (place), whether it's on board and its name (a 10-character name built from vowel sounds, consonants and endings, snoids.cpp).
The player roster and saved games (roster.cpp)
ZBUser.txtis the default user file, used until the player saves a game under a name; a saved game gets its own file namedZOOMplus the next four-digit number (newSaveFileName), the name the player typed being kept only in the list. Every file holdsgameState(checked for version 107 on load).Zoombini.who(next to the program) is the saved-game list: a little-endian version (107), next id, count and 50 slots of a 23-byte name and 9-byte file name.assets/zoombi32/installed/Zoombini.whois the installer's initial one, stored as TOML (see Formats).readRoster,saveRoster,readWriteRoster,readWriteSavedGamesandfillRosterHeaderread and write them;rosterChangedmarks unsaved changes (enterNextScenesets it, andsaveRosterwrites it unless the default file is in use). Failures say "Could not Open/Create Roster file."- The
rostermodule also holds Lion's Lair (scene 16): the two share an original source file. See Modules and scenes.
Parties
The party is the set of Zoombinis currently on the journey. Zoombini Isle makes them, the camps store the ones that wait, and most puzzle scenes begin by taking party() and creating one view per traveller (addSmokeSnoids, addMazeSnoids, …). When a journey ends in a scene (strandParty), the party waits there if the scene is Zoombini Isle, the first camp or the second camp (their waitingParties() / savedParty() slots), and is otherwise emptied. Leaving a puzzle with Zoombinis still in it first asks whether to keep them (askKeepParty: "the current party of zoombinis will be lost if you go to the map").
Levels
Each puzzle scene reads sceneLevel() (1-4: the level of its group, from puzzleLevels()) and picks its rules from it; "practice mode" (practiceLevel, from the map's practice hotspot) plays a puzzle at a chosen level without touching the saved journey.
Modules and scenes
The scene table
scenes[22] (game.cpp, 0x4a26e8) maps a scene number to its Scene {open, close, frame, key}. currentScene is the running one (-1 before the first), pendingScene the next. The map's hotspots (picker.cpp:pickHotspot) are what send the player to a puzzle.
| # | Scene | Player-facing name | Module (decomp/…) | Archive (assets/…) | Open / frame / click |
|---|---|---|---|---|---|
| 0 | intro | the logo movie, then on | town.cpp | (Data\Logo025.MOV) | openIntro / introFrame / introClicked |
| 1 | map | the world map | picker.cpp | MAP | openMap / mapFrame / mapClicked |
| 2 | journey | travelling between places | xfer.cpp | XFER | openJourney / journeyFrame / journeyClicked |
| 3 | isle | Zoombini Isle (making Zoombinis) | isle.cpp | PICKER | openIsle / isleFrame / featureButtonClicked, isleButtonClicked |
| 4 | camp | Shelter Rock | basecamp.cpp | BASECAMP | enterCamp / campIdle / campButtonClicked |
| 5 | camp 2 | Shade Tree | bctwo.cpp | BCTWO | openCamp2 / camp2Frame / camp2Clicked |
| 6 | town | Zoombiniville | town.cpp | TOWN | openTown / townFrame / townClicked |
| 7 | bridge | Allergic Cliffs | bridge.cpp | BRIDGE | openBridge / bridgeFrame / bridgeClicked |
| 8 | tunnels | Stone Cold Caves | tunnels.cpp | TUNNELS | openTunnels / tunnelsFrame / tunnelsClicked |
| 9 | pizza | Pizza Pass | pizza.cpp | PIZZA | openPizza / pizzaFrame / pizzaButtonClicked |
| 10 | ferry | Captain Cajun's Ferryboat | ferry.cpp | FERRY | openFerry / ferryFrame / ferryClicked |
| 11 | lilly | Titanic Tattooed Toads | lilly.cpp | LILLY | openLilly / lillyFrame / lillyClicked |
| 12 | slides | Stone Rise | slides.cpp | SLIDES | openStoneRise / stoneRiseFrame / stoneRiseClicked |
| 13 | fleens | Fleens! | fleens.cpp | FLEENS | openFleens / fleensFrame / fleensClicked |
| 14 | hotel | Hotel Dimensia | hotel.cpp | HOTEL | openHotel / hotelFrame / hotelClicked |
| 15 | net | Mudball Wall | net.cpp | NET | openNet / netFrame / netClicked |
| 16 | caves | The Lion's Lair | roster.cpp | CAVES | openCaves / cavesFrame / cavesClicked |
| 17 | smoke | Mirror Machine | game.cpp | SMOKE | openSmoke / smokeFrame / smokeClicked |
| 18 | maze | Bubblewonder Abyss | maze.cpp | MAZE2 | openMaze / mazeFrame / mazeButtonClicked |
| 19, 21 | catch | a hidden throwing mini-game (cheat only) | picker.cpp | PICKER | openCatch / catchFrame / catchClicked |
| 20 | targets | a hidden target-shooting mini-game (cheat only) | picker.cpp | PICKER | openTargets / targetsFrame / targetsClicked |
How the table was established: the player-facing names are the strings of placeNames (picker.cpp), which pickHotspot maps to scene numbers (hotspots 2-4 → 7-9, 6-11 → 10-15, 13-15 → 16-18, 5/12/16 → the camps and the town, 1 → the isle). Which module is which puzzle comes from the archive each opens and from its backdrop and its sound set (scenes 16-18 share a sound-pool set, and 13/12 share another). Module names that don't match the puzzle (net, roster, game, maze, picker) are named after the strings that identified the module, not the puzzle it contains.
The module town holds scene 0 (the intro) as well as scene 6; picker holds the map, the two hidden mini-games, and the practice-mode machinery; game holds WinMain and the Mirror Machine; roster holds the saved-game code and the Lion's Lair; isle was split from net by its data. Scene numbers 19-21 are reached only by typing a cheat code on the map.
Group structure of the journey
| Group | Puzzles | Camp after |
|---|---|---|
| 1 | Allergic Cliffs (7), Stone Cold Caves (8), Pizza Pass (9) | Shelter Rock (4) |
| 2 | Captain Cajun's Ferryboat (10), Titanic Tattooed Toads (11), Stone Rise (12) | Shade Tree (5) |
| 3 | Fleens (13), Hotel Dimensia (14), Mudball Wall (15) | Shade Tree (5) |
| 4 | Lion's Lair (16), Mirror Machine (17), Bubblewonder Abyss (18) | Zoombiniville (6) |
Groups 2 and 3 are alternatives chosen at Shelter Rock (its two set-out buttons go to scenes 10 and 13); finishing either opens Shade Tree. (Taken from each scene's sceneDue assignments and enterNextScene, which records puzzleLeft when the last puzzle of a group sends the party to a camp. The town's monument texts, featTexts, are written per group and level.) Each group has four levels (sceneLevel() 1-4).
The 42 game modules
| Address | Module | What it is |
|---|---|---|
0x41008c | anim | the opcode animation player (unused by the game) |
0x411350 | sound | game-level sounds: channels, 'tWAV'/'tMID' by key |
0x4121cc | buttons | graphic buttons |
0x4124a4 | focus | input groups, hover and press tracking |
0x413c24 | jointext | error-message joining |
0x413dc0 | events | the 32-entry event ring |
0x4144d0 | graphics | screen and work ports, palette, saved areas |
0x414f30 | loading | formatter and "Unable to load" errors, MIDI sound functions |
0x415514–0x415604 | random, skipstrings, nthstring | RNG (an LCG seeded from time()), string helpers |
0x415604 | debug | breakpoints, starvation warning, main-loop events |
0x415a30 | basecamp | Shelter Rock, plus wave-sound helpers, wipes, blinds, the cheat tracker |
0x418698 | bctwo | Shade Tree and its book of waiting Zoombinis |
0x41a404 | bridge | Allergic Cliffs |
0x41c09c | roster | saved players, plus the Lion's Lair |
0x41f8cc | ferry | Captain Cajun's Ferryboat |
0x42160c | fleens | Fleens! |
0x424274 | hotel | Hotel Dimensia |
0x4281b0 | lilly | Titanic Tattooed Toads (lily pads) |
0x42f920 | picker | the map, practice mode, the hidden mini-games |
0x433510 | maze | Bubblewonder Abyss |
0x439560 | net | Mudball Wall; enterNextScene; the per-scene remark pools |
0x43e620 | isle | Zoombini Isle |
0x4402c0 | pizza | Pizza Pass |
0x44695c | config | Zoombi32.CFG, findGameData |
0x446bf8 | slides | Stone Rise |
0x44b550 | game | WinMain, the Mirror Machine, cheats, memory statistics |
0x455530 | platform | the Windows layer: window, procedure, input, cursor |
0x456c00 | snoids | Zoombini creation, names, drawing, paths, drag |
0x45c0f4 | town | the intro scene and Zoombiniville |
0x45e2d8 | tunnels | Stone Cold Caves |
0x4623b8 | mainloop | one main-loop pass, game keys, cursors, about box |
0x463034 | view | the view list and scripts |
0x465bd0 | features | image banks, cels, dialogs, save/load prompts |
0x4696f0 | xfer | the journey scene |
0x46be28 | e2memory | the e2 layer over memory and resources |
The OS layer (os_fixed … os_contexts) and the engine's 149 modules are described in OS layer, timers and audio, Graphics, Memory and resources and The engine's modules.
C++ classes (RTTI)
The Mohawk engine and OS layer are polymorphic C++; the game's own logic is not (only fileSpec among its types has methods, and it has no vtable). Borland C++ emits RTTI type descriptors for each polymorphic class, and uv run classes recovers 46 of them (how). The names are the engine's own and are used exactly.
basePort ports: QuickDraw's GrafPort over a GDI DC (37-39 virtual methods)
├─ displayPort
│ ├─ windowPort a window's DC
│ └─ memoryPort an off-screen bitmap
└─ DIBPort GDI into a DIB section
└─ DIB8Port also draws into the bits itself (8-bit blitters)
DIB helper around a DIB section (no vtable parent)
audioObj sounds (22-slot vtable; the base implements seven)
├─ midiObj
├─ waveObj
└─ wavestreamObj streamed from the resource's file
wavebuf a waveOut-like output buffer
├─ wavebufWO WaveOut, written ahead by a polling thread
└─ wavebufDS a looping DirectSound buffer
wmxObject WaveMix
├─ wmxMixer software mixing
└─ wmxWaveOut straight to waveOut
asyncAPI asynchronous file operations
└─ async… (12 classes) asyncCreateFile, asyncReadFile, asyncFindFirstFile, …
sync the OS layer's synchronisation objects
├─ event
├─ mutex
└─ thread cooperative thread on a fiber
xmsg ─ xalloc, string::lengtherror, string::outofrange Borland's exceptions
typeinfo, Bad_cast, Bad_typeid, string, TStringRef Borland's runtime
fileSpec the game's file specifier (no vtable)
Conventions that follow from them
- A vtable is in
DATA, preceded by a pointer to its class descriptor and two zero words; slot 0 is the virtual destructor. Constructors store the vtable address into the object; destructors take a hidden flags argument (bit 0: also free). - The engine's methods are
__cdecl(they don't pop their arguments) and takethisas the first stack argument. - A class that declares no destructor under a base with a virtual one gets an implicit destructor, emitted in the last module that needs the class's vtable (
displayPort::~displayPortlives inwindowPort's module).@zoombi32-implicitmarkers name them andmatchmeasures them. - The port classes' sizes matter:
DIBPortis declared under#pragma pack(push, 4)sonewPortallocates0xc8bytes for it and0xc6for the derivedDIB8Port. - An object created by a global's constructor or destructor (
iniState,files) is run by unnamed<startup>/<exit>functions listed in the object's_INIT_/_EXIT_segments.
The Mohawk engine's modules and options
TLINK32's padding splits the engine (0x4764bc to the import thunks) into 149 object files, many holding one function. QuickDraw-style helpers (emptyRect, offsetRect, sectRect) each sit alone, as in a library built for smart linking. They use the game's -p -k- (Pascal order, arguments popped, like the Mac Toolbox calls they imitate); the rest of the engine uses -p -x- (no -k-, so leaf functions get frames, and no exception frames) and its C++ classes' methods (about 240) don't pop their arguments, so they're __cdecl. Each module's @flags comment says which.
Exceptions are off in the engine: functions with fileSpec locals have no __InitExceptBlock. A fileSpec built from a directory and a name returns this in eax, which a by-value operator+ wouldn't.
Regions are rectangle lists in a handle ('Rngr' tag, bounds, capacity grown 16 at a time, count, rectangles) with the last region call's error in regionErrorCode.
For compiler details see The compiler: the engine is built with 4.5 or something very close, probably against newer Windows headers, and its remaining near-misses are register-allocation differences.
Engine: memory manager and resource manager
The memory manager
The engine has a Mac-style memory manager (0x48e5ec-0x48f660, decomp/newhandle.cpp onwards) on top of Win32's Global* functions. Its state is a struct at 0x4b9cdc (heap: error, ready flag, purge and grow-zone callbacks, handle table). Handles are 1-based indexes into a table of 8-byte entries, whose first word is bitfields: lock count (7 bits), age (4 bits: 15 when used, counted down by purges, least recently used purged first), state (2), in use, never purge, purgeable. Every block starts with an 8-byte header: 'BM' (0x4d42), its handle, then a 31-bit size and a moveable bit. The header declares the size as a 31-bit unsigned long field and the moveable bit as an unsigned short field, which is the only declaration that reproduces both allocBlock and blockOf. Blocks are reached through a master pointer. For a handle's block that's the Windows handle of a GMEM_MOVEABLE block, whose first word Win32 makes point at the memory, and the engine relies on that. For a fixed block (newPtr) it's a GMEM_FIXED block holding a pointer to just past itself. The game installs a grow-zone procedure (noteOutOfMemory, 0x455013). Its "not enough physical memory" check compares total physical memory (MemoryInfo +0xc, from GlobalMemoryStatus) with 6 MB.
The resource manager
The engine reads Mohawk archives (ScummVM's name for the MHWK format) through a Mac-style resource manager (0x48f660-0x492dc4, decomp/resourcefile.cpp onwards). Its state is a 0x24-byte struct at 0x4b9d8c (resources), cleared by initResources (0x4922c6). That function reads [Resource] fShareReadOnly from MOHAWK.INI, installs a purge procedure, and opens SYSTEM.W32 (else SYSTEM.MHK) from the program's directory.
- Files. Everything is big-endian. A 0x1c-byte header (
'MHWK', size,'RSRC', version 0x100, a "compacted" flag, file size, directory offset, then the directory's size and the file table's size, both 16-bit) is followed by the data, then the directory and the file table. The directory is a type table (u16offset of the names,u16count, then{u32 type, u16 resource table, u16 name table}), and each type's resource table and name table (u16count, then{u16 id or name offset, u16 index}), sorted and searched by bisection. The file table is au32count and 10-byte entries (u32offset, a 24-bit size, a flags byte, and au16that is 0 on disk and holds the loaded data's handle in memory).0x49210band0x4923f3byte-swap the directory and file table in place; they are swapped back to write them out (writeMapHeader,0x492483, which Ghidra split into a 6-byte prologue and0x492489). - Maps. Each open file has a map in a handle, tagged
'RMap': a list of maps, a ring of those with preloads pending, a use count, the file, whether it can be read in the background (from its volume) or only read, the directory's handle, a handle of per-resource counts (loads and preloads), then the file table. A resource ID is a long: its file-table index (from 1) in the high word and its map's handle in the low word. - Loading. A loaded resource's data is in a handle given a handle state of its own (
countHeapUp). The purge procedure (purgeResource,0x490570) lets the memory manager purge such handles (writing back modified data first), marking the entry purged so the data is read again when needed. Other handles go to the previous procedure. - Preloads. Requests (56 bytes from
malloc, tagged'RQRq') read resources ahead of use, a chunk at a time within a time budget (servicePreloads,0x4906b4), or on a thread (preloadThread,0x49138e) for files that can be read in the background. They are kept in rings per map, sorted by position in the file. A request's provider supplies the buffer and hears the outcome; the default one (0x490ff3) reads into a new handle that becomes the resource's data. - Writing. Resources can be added to and removed from the directory (
0x491c64,0x49194e) and rewritten in place or at the end of the file (0x492a3c). Closing a file (0x48f660) can compact it, squeezing out deleted resources and gaps.
Engine: graphics ports
Ports
basePort(0x486318-0x4887f4). Ports are QuickDraw's GrafPorts over a GDI DC: 0xc0 bytes, tagged'Port'after the vtable pointer, in a list fromgraphics.ports. The port keeps its state (palette, background colour, font, pen, clip region) in its own fields and selects it into the DC only while locked:setupDC(slot 4) reads the DC's depth, maps the frame onto the bounds (MM_ANISOTROPICwhen their sizes differ), and selects the palette, colours, pen and font;cleanupDC(slot 7) takes them out again, andunlock(slot 34) deletes the DC at the last unlock. The clip region is converted to anHRGNlazily (prepare, slot 8, before each drawing call). The pen (+0x54) is a position, a colour, a mode (setMode, slot 15, maps modes 0-7 toR2_codes) and a width (the engine'ssetPenWidth,0x48d90c, formerly thought to set a mode). Transfer modes 0-7 map to raster operations through two tables:0x4a8b18for bitmaps (SRCCOPY,SRCAND,SRCPAINT, ...) and0x4a8af8for brushes (PATCOPY, ...). Mode 8 draws with colour 0 transparent (drawMasked, slot 35: AND with a mask, then OR). The base class's slot 25 (lock) is the runtime's pure-virtual stub.DIB(0x489148-0x48988a). A helper class (RTTI nameDIB, 0x30 bytes, 9 virtual methods, no destructor) around a DIB section, which ports use for packed pixels and for flipped stretches that devices might not do. It keeps two headers: one with palette indices for colours (DIB_PAL_COLORS), one with RGB colours. The module also holds the 16.16 fixed-point helpers (makeFixed,fixedToInt,fixedFraction,fixedRound,0x48988a-0x4898b6).- Inline functions out of line. The ports' modules call small functions that are inline in any sensible header:
Rect()(0x48ab89),Rect::operator=(0x48ab91), aRectfrom a plain rectangle (0x48a667, amemcpyof 8 bytes, used where the game's code expands the same constructor inline),Pt()(0x488786),Pt::operator=(0x4887ab) and the pen's constructors (0x48878e,0x4887c4). Each exists once, in the module of the last port class that uses it, which is how virdefs (Borland's out-of-line copies of inline functions) would be kept if the linker kept the last copy.Color's copy constructor, meanwhile, is expanded inline in the same modules.decomp/zoombinis.hdeclares theRectconversion out of line whenRECT_OUT_OF_LINEis defined (the port modules) and inline otherwise. DIBPortandDIB8Port(0x48a720,0x48990c). ADIBPortdraws with GDI into itsDIB's section;DIB8Port(kind 1) also draws into the bits itself, one rectangle of the clip region at a time, for copies between 8-bit DIB ports sharing a palette (through a 38th virtual method,drawBits), plain, 1-bit and packed pixels, and filled rectangles, callingGdiFlushfirst if GDI has drawn since. Its blitters (0x48990cwith its inner loop0x4899ed,0x489a2d,0x489aad) are hand-written assembly (rep movs/stos,loop, a jump throughebxto pick the loop, byte-swapped dwords for 1-bit images) and decompiled as functional C++.- Startup and exit functions. A global object with a constructor or destructor (
iniStateandfiles, whose members arefileSpecs) makes BCC32 compile the calls into unnamed functions after the module's code, listed in the object's_INIT_and_EXIT_segments (6-byte entries: a calling convention byte, a priority byte, the address) and run by the startup code; in the game they sit at the end of their modules (0x480763/0x480773,0x484191/0x4841c2).files(0x4b9b6c) is defined in the file layer's module, whose startup code builds its fourfileSpecs. - Implicit destructors. Classes that declare no destructor under a base with a virtual one get one from the compiler (37-38 bytes: if
thisisn't null, call the base destructor with flags 0, then the class'soperator deleteif bit 0 of the flags is set). BCC32 emits it in each object that needs the class's vtable, and the copy linked is the last module's (displayPort::~displayPort,0x48dfff, sits inwindowPort's module). All 17 in the port and async file classes match, measured through@zoombi32-implicitmarkers; a modern compiler generates its own from the same declarations.
Engine: OS layer, timers and audio
Threads, timers, sounds and mixing
- The OS layer's threads (
0x46e2a4-0x46f7a4). The Mohawk OS layer runs threads of its own, cooperatively, on the application thread, as on the Mac: each (RTTI classthread) has a stack carved from a bufferWinMaingivesosStartup, and a saved register context. The scheduler (schedule,0x46e90e) runs at calls into the layer and every 20 ms (an OS-layer timer, running only while two or more threads are started): it picks, round the ring from the current thread, the first of the highest priority that isn't sleeping, suspended or waiting, ending waits that have timed out, and idles (running timers) when none can run. Priorities above 1 are urgent: while one is runnable, the timer fires at every chance. Mutexes (recursive, released when their holder ends, with a deadlock check) and events (set and reset through the layer'sDeferLock, so they can be posted from real threads such as waveOut callbacks) are the other sync objects (sync, tagged'sync'), waited for withwaitSync. The classes' methods are__cdecland the module was compiled without exception handling (-x-): with it, BCC32 counts every object a constructor makes, which the original doesn't. A thread deleting itself switches to the next thread's stack before deleting its own (deleteSync). - The OS layer's manager (
0x46da64-0x46e2a4) holds inline assembly (the atomic operations, the breakpoint), so it went through TASM32:osStartup'ssub ax, 1has TASM's accumulator encoding (66 2d 01 00) where BCC32 emits66 83 e8 01. - Deferred calls (the OS layer,
0x46d7f8-0x46d95c). ADeferLockis a lock whose holders count up and down (enterLock/leaveLock, with theInterlockedfunctions). While it's held, calls posted to it (deferCall) wait in a lock-free queue, and the lastleaveLockruns them in the order they were posted, each as many times as it was posted. Locks can be listed, and0x46dc45takes all the listed ones at once. - Timers (
0x492dc4-0x49324c). Timer events (72 bytes, tagged'TEvt') wrap multimedia timers (timeSetEvent, at the finest resolutiontimeGetDevCapsallows, set withtimeBeginPeriod). The multimedia callback only posts the event's call to the timers'DeferLock, so procedures run on whichever thread holds or releases it, never concurrently with code holding it (lockTimers). Delays longer than the device's maximum are counted down in steps. A periodic event that falls behind switches to one-shot timers, each shortened by how late the last one was. - Sounds (
0x4764bc-0x4778b0). MIDI and wave sounds are C++ objects of classaudioObj(subclassesmidiObj,waveObjandwavestreamObj, named by their RTTI descriptors, which sit in the code after each module's methods), tagged'AObj'after the vtable pointer, and handed around as handles (their addresses). They're created from MohawkMIDIorWAVEfiles in a handle, or streamed from a resource's file. Their vtable has 22 slots, and the base class implements seven of them (activating, opening, rate, volume, play, stop, close; for MIDI the rate scales the tempo and the volume picks a velocity curve). The abstract base's vtable (0x4a8448) fills the rest with the runtime's pure-virtual stub. Subclasses' vtables are at0x4a81f4(MIDI),0x4a8278(wave) and0x4a84ac.MOHAWK.INI's[Audio]section picks the default devices (DefaultMidiDeviceandDefaultWaveDevice, by number or name, else the Sound Mapper's playback device from the registry), can keep them open (fCacheDefault...Device), and maps wave rates a device can't play to others (fTranslateWaveRateOnError,[Audio.WaveRateTranslations]). A settings section can also list keys with versions ("name";1.2+), looked up byfindIniEntry(0x47690f). - Streamed wave sounds (
0x47cb30-0x47e0eb). A wave sound can play straight from its resource's file (or a file of its own):newStreamedWavereads the WAVE header and theCue#chunk (byte-swapped once, in memory), and optionally preloads the first part. Opening the device starts a thread (streamThread, 4 KB of stack) that waits on an event; each time a buffer finishes playing (the waveOut callback posts it to the sound'sDeferLock), the event is set and the thread reads more, in buffers of 4 KB, until about three seconds are queued. Buffers are split at cue points and at the loop's ends; a loop that fits in one buffer is looped by the device (WHDR_BEGINLOOP/WHDR_ENDLOOP), otherwise the loop's first buffer is kept and the reading goes back to the loop's start. Starting the device raises the calling and reading threads' priority while the reader queues the first buffers. - WaveMix's output buffers (
wavebuf,0x47af1c-0x47cb30). Wave sounds don't call waveOut directly: they go through a waveOut-like API (wavebufOpen,wavebufWrite, ...) over WaveMix objects (tag'WMix'), which either pass straight through to waveOut (wmxWaveOut, when[WaveMix] fEnableis off or the device can't be mixed) or are mixed in software (wmxMixer) into one output buffer per wave device (wmxDevice). The output buffer is awavebuf:wavebufWOkeeps a ring of waveOut blocks written ahead by a polling thread (sizes from[WaveMix.DeviceInfo], per device name and driver version, elsedefault, ornot supported), andwavebufDSuses a looping one-second DirectSound buffer when[WaveMix] fEnableDirectSoundis set andDSOUND.DLLloads (this path getsDirectSoundCreateandDirectSoundEnumerateAwithLoadLibrary/GetProcAddress). The mix's format comes from[WaveMix]ulFrameRate(11025, 22050 or 44100),ulFrameSize(8 or 16) andfStereo. - WaveMix's mixer (
0x47e0ec-0x480028). EachwmxMixerresamples its queued blocks by a 16.16 step (the device's rate over the object's rate times its playback rate) and mixes them into the device's buffer when the buffer asks for more; the first object mixed copies, the rest add with saturation, and 8-bit samples go through a 256-byte volume table per output channel (left and right levels fromsetLevels, likewaveOutSetVolume's, scaled by the fixed-point volume). Loops (WHDR_BEGINLOOP/WHDR_ENDLOOP) are mixed by position arithmetic, capped so a loop's total stays under 2^30 samples. The inner loops (0x47fae8-0x47fd5b) are hand-written assembly (xlatthrough the table,joto saturate,rep movs/stos), decompiled as portable functional C. 16-bit samples are kept big-endian (as on the Mac): the mixing loops swap bytes, andwmxWaveOutswaps a 16-bit block's bytes when it's prepared or unprepared (inlinexchg ah, al). - The mixer's objects call the owner's callback as waveOut would (
WOM_OPEN,WOM_DONE,WOM_CLOSE, and0x8000each time a loop comes round unless bit 30 of the open flags is set; that bit also makes the message a 32-bit value rather than a word). A header'slpNextholds its mixer block andreservedthe tag'WMix'.
Gameplay and the code
This part walks through the game in the order a player meets it, and for every screen says: what scene it is, which module and archive it comes from, which functions open it, run it and respond to clicks, which globals hold its state, and where its rules live. Use it to go from "I'm looking at this" to the code, or the reverse.
The journey in one picture
start ─▶ intro logo movie (0) ─▶ Zoombini Isle (3): make the Zoombinis ─▶ Allergic Cliffs (7)
│ group 1
Stone Cold Caves (8) ◀────┘
▼
Pizza Pass (9) ─▶ Shelter Rock (4) camp
┌──────────────── set out ────────────────┐
button 1 ▼ ▼ button 2
group 2: Captain Cajun's Ferryboat (10) group 3: Fleens! (13)
Titanic Tattooed Toads (11) Hotel Dimensia (14)
Stone Rise (12) ──────┐ Mudball Wall (15) ──┐
▼ ▼
Shade Tree (5) camp, the second ◀──────────────────────┘
│ set out
▼
group 4: The Lion's Lair (16) ─▶ Mirror Machine (17) ─▶ Bubblewonder Abyss (18) ─▶ Zoombiniville (6)
Between most scenes the journey scene (2) shows the party travelling on the map; the map (1) is
reachable from anywhere; a puzzle's "back" button returns to it.
Within a group each puzzle's frame sets sceneDue to the next one when the last Zoombini is through (openBridge → … sceneDue = 8, 9, 4; sceneDue = 11, 12, 5; …). Shelter Rock's two "set out" buttons start group 2 (scene 10) or group 3 (scene 13); either finished group opens Shade Tree, whose button leads to group 4 (scene 16). The practice mode of the map (Ctrl-P) lets the player visit any puzzle at a chosen level without touching the saved journey. Progress is kept in gameState (puzzleLeft, +0x50-0x52, sceneFlags()).
The skeleton every puzzle shares
All twelve puzzle scenes were written to one template, and recognising it makes any of them quick to navigate:
| Piece | In the code |
|---|---|
| three input items: button 1 (back to the map), button 2 ("go": send the party on), and the whole screen (drag Zoombinis) | …Buttons[3] and …Groups; the click callback …Clicked(which) switches on 1, 2, 3 |
| button 1 | plays sound 999, sceneDue = 1 (the map), then askKeepParty() |
| button 2 | only when …GoReady; plays 996, sendSnoids(x, y, n) walks the party off, sceneDue = the next scene |
open… | loads the archive (openGameFile), sounds, images and scripts, adds the views, the party (one view per Zoombini), sets up the level's rules, says an introduction |
…Frame | updateViews(); if sceneDue is set and sound 996 has finished, close…() and pendingScene = sceneDue; otherwise the puzzle's sequencing and idle fidgets |
…Notify | a view's script event: where the puzzle's logic reacts |
| level | sceneLevel() (1-4) or the module's own …Level global picks the rules |
| remarks | a pool of sound ids per scene (net.cpp's bridgeSounds, pizzaSounds, …) with a "used" mask so lines don't repeat |
When a puzzle's code seems to have no "Go" button, look for sceneDue = in its …Clicked and …Frame.
Reading a page
Each puzzle page has the same sections:
| Section | Contents |
|---|---|
| On screen | what the player sees, with screenshot placeholders |
| Scene facts | scene number, module, archive, backdrop and sound set |
| Entry points | the functions the engine calls (open, frame, click handler, key handler) with addresses |
| Where the rules live | the functions that decide what's right and wrong, per level |
| State | the globals worth knowing |
| Things to know | quirks, hidden behaviour, links |
Addresses are into zoombi32.exe; find the source with grep -rn 0x41a506 decomp/. Player-facing names come from the game's own text (placeNames, featTexts); how a puzzle's rules work is summarised from the code's comments and has not been re-verified by play, so treat the prose as a guide to the code, and correct it when you check it against the running game.
Screenshots
Boxes like this one mark where an image of the running game belongs. Each has an id (isle-queue), a description, and a capture hint. See Screenshots for the full list, the capture workflow, and how to replace a placeholder with an image.
📷 Screenshot:
journey-overviewThe map screen with all sixteen hotspots visible and the map's text box showing "choose a level". Capture: from the map (scene 1), in practice mode.
"Where is the code for…?" quick lookup
| I see… | Look at |
|---|---|
| the game starts, a logo movie plays | Starting the game: openIntro, playMovie |
| a dialog asking about saving, loading, quitting | Dialogs and saved games: showDialog, askQuit |
| I'm building a Zoombini from hair/eyes/nose/feet buttons | Zoombini Isle |
| I'm clicking places on the world map | The map and the journey |
| a path/grid fills in as my Zoombinis travel | Journey scene: xfer.cpp |
| I'm dragging Zoombinis between slots, a scrolling camp | The camps |
| a Zoombini sneezes at a cliff | Allergic Cliffs |
| four doors and guard characters | Stone Cold Caves |
| trolls and a pizza with toppings | Pizza Pass |
| a riverboat captain and seats | Captain Cajun's Ferryboat |
| a grid of lily pads and toads | Titanic Tattooed Toads |
| stones and light paths | Stone Rise |
| strange creatures lined up | Fleens |
| a hotel of rooms | Hotel Dimensia |
| a wall of stones and a mudball | Mudball Wall |
| a lion's paw and golden path | The Lion's Lair |
| a mine with a boulder, two lines of Zoombinis | Mirror Machine |
| a dark chasm and bubbles | Bubblewonder Abyss |
| a town with monuments, a clock and a population sign | Zoombiniville |
| an unexplained feature I only get by typing something | Hidden scenes and debug keys |
Starting the game
On screen
📷 Screenshot:
start-logoThe intro logo movie playing full screen (a frame fromLogo025.MOV), at 640×480. Capture: the first seconds after pressing Play in the port; oruv run assets frames LOGO025 1 701for stills from the converted movie.
After the logo the game goes to Zoombini Isle (a fresh game) or straight back to the scene the saved game was in.
Scene facts
| Scene | 0 (the intro) |
| Module | decomp/town.cpp (shares the module with Zoombiniville) |
| Movie | Data\Logo025.MOV (QuickTime, video in Broderbund's QkBk codec; see Movies) |
| Before it | WinMain sets pendingScene = 0 |
Entry points
| Function | Address | Role |
|---|---|---|
openIntro | 0x45c12e | resets the intro state, installs the whole-screen click group |
introFrame | 0x45c212 | step 0: builds installDir + "Data\\Logo025.MOV" and calls playMovie; then pumps it with idleMovie; when the movie ends, or on a click (introClicked), sets sceneDue = sceneToReturnTo() |
introClicked | 0x45c391 | any click skips the logo (introSkip) |
closeIntro | 0x45c175 | stops the movie, realises the game palette, reloads the Zoombinis (loadSnoids(0)) and the dialogs (loadDialogs), sets rosterReady, shows the cursor |
playMovie, idleMovie, stopMovie, loadMovie | 0x45537f, 0x455229, 0x455273, 0x4552fd | the movie player over QuickTime (game.cpp) |
sceneToReturnTo | 0x454c10 | where to go next |
sceneToReturnTo returns the saved scene (and sets skipJourneyMap) if the saved game was on the isle, the camps, the town, the map, or in a puzzle with a party; otherwise it returns scene 3, Zoombini Isle.
Things to know
- The movie is the only QuickTime use. If QuickTime isn't installed the game refuses to start (
WinMainrequires 2.3 or later); if the movie fails to start (logoFailed) the cursor is hidden and the next frame goes straight on tosceneToReturnTo(). In the port,port/glue/quicktime.cppplays a scene file instead; see QuickTime in the port. - While a movie shows (
movieShowing), QuickTime's component manager sees every window message first (mainWindowProc).
Dialogs and saved games
📷 Screenshot:
dialog-keep-partyThe dialog "the current party of zoombinis will be lost if you go to the map" with its LOSE 'EM / KEEP 'EM buttons. Capture: from a puzzle, click the map button while Zoombinis are in the party.
📷 Screenshot:
dialog-gamesThe saved-games dialog (LOAD / SAVE) listing games. Capture: press Ctrl-L (or Ctrl-S) at any time.
📷 Screenshot:
dialog-optionsThe options/help dialog with the ON/OFF toggles (music, sound, less/more action, hide cursor, sticky mouse…). Capture: press?or/.
| Function | Address | Role |
|---|---|---|
showDialog | 0x466d7e | builds a modal dialog out of views and scripts from ZOOMBINI.MHK |
dialogClick | 0x46879e | which of the dialog's 17 hot spots was hit |
askNewGame, askLoadGame, askSaveGame, askQuit | 0x469556, 0x4695e5, 0x469627, 0x469669 | Ctrl-N, Ctrl-L, Ctrl-S, Ctrl-Q |
askKeepParty | 0x466d3d | the keep-the-party question |
The dialogs' text is dialogTexts[289] in features.cpp and the toggle names are toggleTexts in town.cpp. The game keeps one default file (ZBUser.txt) and one file per named saved game (ZOOMnnnn.txt); see Game state. While a dialog is open dialogFlags is non-zero and gameKey sends keys to dialogKey.
Zoombini Isle
Where the game begins: the player builds the Zoombinis who will make the journey.
On screen
📷 Screenshot:
isle-overviewZoombini Isle: the panel of feature buttons (four rows of five) at lower left, the Zoombini being made in the middle, and the queue of finished Zoombinis waiting along the shore. Capture: start a new game; you arrive here after the logo.
📷 Screenshot:
isle-feature-panelClose-up of the feature panel: hair, eyes, nose and feet choices (5 each) and the seven panel buttons below it. Capture: same scene, crop to the panel (isleButtons, x 3-198, y 304-478).
📷 Screenshot:
isle-sending-offA party of Zoombinis walking off toward the map after the player clicks the "send off" button. Capture: make at least the minimum party, click button 6.
Scene facts
| Scene | 3 |
| Module / archive | decomp/isle.cpp / PICKER (Picker.MHK) |
| Input | isleGroups[2]: feature buttons → featureButtonClicked, panel buttons → isleButtonClicked |
| Roles | makes Zoombinis; holds a waiting party (waitingParties()); starting point of the journey |
Entry points
| Function | Address | Role |
|---|---|---|
openIsle | 0x43e6b6 | loads Picker.MHK's backdrop, images, scripts and sounds; adds views and the queue's places; brings the waiting party back; sets up the Zoombini being made; offers to load a saved game first if asked; plays a hint (or, from the camp, a remark about how many Zoombinis are left to make) |
isleFrame | 0x43ebf4 | leaves once asked (sceneDue) and sound 996 has finished |
featureButtonClicked | 0x43ecbb | feature buttons 1-20 (four groups of five): pick or drop that feature for the Zoombini being made |
isleButtonClicked | 0x43ee5d | panel buttons 1-7 (below) |
isleKey | keyboard shortcuts | |
closeIsle | frees everything |
The panel's seven buttons
| Button | Does |
|---|---|
| 1 | makes the chosen Zoombini and sends it to the queue's first free place (while fewer than 625 exist) |
| 2 | the Zoombini says something |
| 3 | renames it |
| 4 | picks another at random (with Ctrl, fills the queue with random ones) |
| 5 | goes to the map |
| 6 | with a full enough party, sends it off and leaves; otherwise may remark on Zoombinis left to make |
| 7 | takes a Zoombini from the queue back to be remade |
Where the rules live
- How many are enough:
checkEnoughChosensetsenoughToLeaveChosenwhen the chosen count reachesenoughToLeave, or the whole population (625) has been made. - Which kinds exist:
zoombiniCounts()ingameStatecounts how many of each of 5⁴ kinds have been made;pickZoombiniMadepicks random features until it hits a kind with fewer than two. - Drawing the Zoombini being made:
drawZoombiniPartscomposes it from the feature images in the isle's image bank (drawIsleImage, by hot spot);drawFeatureButtonsanddrawIsleButtonsdraw the panel fromisleButtonImages. - The queue:
isleQueue(0x43ffd5) gives each place the nearest spot no earlier place has taken (isleQueuePlaces).
Things to know
isleAllowsFeatureallows every feature, and the "refused" sound (1008) is never played: a hook for something the shipped game doesn't do.- With Ctrl held and debugging on, button 1 first sets the count to 624, to test the "population full" ending.
- Names are built from syllable tables in
snoids.cpp(vowelSounds,consonants,nameEndings,consonantPairs).
The map and the journey
The map (scene 1)
📷 Screenshot:
map-overviewThe map with its sixteen hotspots drawn on the terrain and the text box at upper left. Capture: from Zoombini Isle click the map button (panel button 5).
📷 Screenshot:
map-practice-levelsThe map in practice mode: the level list (1-4) and the "snoids to practice with" count in the text box. Capture: open the map with no saved journey so every hotspot is available.
| Scene / module / archive | 1 / decomp/picker.cpp / MAP (Map.MHK) |
| Input | pickerGroups: 17 items → mapClicked; hotspot 17 is "the rest of the screen" |
The map has sixteen hotspots (placeNames), each a place:
| # | Place | Goes to | # | Place | Goes to | |
|---|---|---|---|---|---|---|
| 1 | Zoombini Isle | 3 | 9 | Fleens! | 13 | |
| 2 | Allergic Cliffs | 7 | 10 | Hotel Dimensia | 14 | |
| 3 | Stone Cold Caves | 8 | 11 | Mudball Wall | 15 | |
| 4 | Pizza Pass | 9 | 12 | Shade Tree | 5 | |
| 5 | Shelter Rock | 4 | 13 | The Lion's Lair | 16 | |
| 6 | Captain Cajun's Ferryboat | 10 | 14 | Mirror Machine | 17 | |
| 7 | Titanic Tattooed Toads | 11 | 15 | Bubblewonder Abyss | 18 | |
| 8 | Stone Rise | 12 | 16 | Zoombiniville | 6 |
| Function | Address | Role |
|---|---|---|
openMap | 0x42fa4b | loads the sounds, backdrop and saved areas, makes the map's views, loads sounds 998-999 |
mapFrame | 0x42feaf | per-frame |
pickHotspot | 0x430030 | picks hotspot n and redraws the "open hotspots" view: in the game only the isle (1) and the camps and town (5, 12, 16) are selectable, and 5, 12 and 16 only once the per-group "left" bits say they've been reached (gameState+0x50, +0x52, +0x51); in practice mode every place is, subject to the same camp bits |
mapClicked | 0x43010b | the click handler: maps a hotspot to its scene (the code in the table above), plays 998, closes the map and sets pendingScene |
mapKey | 0x43041f | Ctrl-P starts practice mode (level 1); 1-4 then pick the level. With debugging on: +/- set the practice party size (1-16), T then a letter a-p previews the journey transition for that route |
leavePractice | 0x430724 | returns from practice to the game |
drawMapBox, makeMapViews | 0x430878 | the text box (mapTexts) and the hotspot views |
Practice mode (practiceLevel, 1-4; started with Ctrl-P) lets any place be visited at the chosen level with a practice party (practicePartySize, 1-16); it never changes the saved journey (enterNextScene skips the progress bookkeeping). Puzzles are not hotspots in the real game: a puzzle is reached by setting out from a camp (the camp's buttons 1 and 2, see The camps). The "help" button (helpButtonRect) plays sound 999 and opens the help dialog. Two hotspots (8 and 9) go to hidden scenes instead if a cheat code was typed (Hidden scenes).
The journey scene (scene 2)
Between most scenes the game shows the party travelling across the map, and the map's grid filling in.
📷 Screenshot:
journey-travelA map screen mid-journey: Zoombinis walking along a path, with the map's name and the grid of places visited. Capture: leave a puzzle for the next without "transitions off" (Ctrl-T).
📷 Screenshot:
journey-population-signThe "zoombiniville population N" sign. Capture: travel to or from Zoombiniville.
| Scene / module / archive | 2 / decomp/xfer.cpp / XFER (xfer.MHK) |
| Input | one whole-screen item → journeyClicked (a click skips ahead) |
| Function | Address | Role |
|---|---|---|
openJourney | 0x4697f1 | picks the map the next place is on (xferMap: 0 isle, 1-4 the maps, 5 the town), the backdrop, views and sounds, and fills in the grid under the map's name |
journeyFrame | 0x46ace4 | after 300 ticks (and xferSound) goes on to journeyTo; now and then starts the next Zoombini walking, or an ambient view |
journeyClicked | 0x46b00e | leaves for the scene due, or on to journeyTo |
setUpGrid, markGridCell, spreadGridMarks | 0x46b872 | the grid of visited places |
drawPopulationSign | 0x46b761 | the population sign |
readPlaceLevels | which places are done at which levels |
journeyRoute (1-16) names the route from journeyFrom to journeyTo; enterNextScene sets them (viaMap). It's skipped when skipJourneyMap or transitionsOn is set, in practice mode, and when moving between scenes that don't leave a place.
Things to know
- The map text strings
levelTextsandmapTexts(picker.h,zoombinis.h) include "terrain key", "choose a level", the month names (used for the records) and "when traveling was". - Because
pickHotspotlimits the real game to the isle, camps and town, the map in a normal game is a way to return to those; the puzzle hotspots are mainly for practice mode.
The camps: Shelter Rock and Shade Tree
Between the puzzle groups the party rests at a camp, where Zoombinis that have been left behind are kept and the player chooses who sets out next.
Shelter Rock (scene 4)
📷 Screenshot:
camp1-overviewShelter Rock: the scrolling rows of camp slots, the Zoombinis standing in them, and the buttons at the right edge. Capture: finish Pizza Pass (or use practice mode off with a saved game that has).
📷 Screenshot:
camp1-dragA Zoombini picked up from its slot and being dragged toward the "party" area. Capture: click and hold on a Zoombini in the camp.
| Scene / module / archive | 4 / decomp/basecamp.cpp / BASECAMP (BaseCamp.MHK) |
| Input | campGroupLists[2]: buttons → campButtonClicked; the camp area → campMouse |
| State | Camp (625 CampSlots: Zoombini, rectangle, name) from gameState, campEnoughChosen, campPopulationFull |
| Function | Address | Role |
|---|---|---|
enterCamp | 0x416789 | loads the slots from gameState and BaseCamp.MHK, returns the party from the journey (returnToCamp) and picks a greeting by how the journey went |
campIdle | 0x417000 | per-frame: leaves once asked and sound 996 is done; tracks which of the scroll buttons (3-6) the cursor is over |
campButtonClicked | 0x417108 | buttons 1 and 2 set out (button 1 to scene 10, group 2; button 2 to scene 13, group 3) if enough Zoombinis are chosen, else say why (a random one of three remarks); 3 leaves (to the map); 4-7 scroll while held |
campMouse | 0x417350 | action 1 (down) / 2 (up): pick a Zoombini up from its slot, drop one in a slot or back, click the other things |
scrollCamp, drawCamp, compactCamp, insertCampRow | 0x417cf1… | the scrolling grid of slots |
leaveCamp | 0x416ede | saves the party that set out and frees everything |
returnToCamp | 0x4184cd | puts the Zoombinis back from the journey into the camp's slots |
Setting out sends the Zoombinis walking off (sendSnoids(0x2a8, y, 0x2d), markPlacedSnoids) and plays sound 996.
Shade Tree (scene 5)
📷 Screenshot:
camp2-bookShade Tree: the "book" of Zoombinis waiting there, with its scroll arrows. Capture: finish Stone Rise or Mudball Wall.
| Scene / module / archive | 5 / decomp/bctwo.cpp / BCTWO (bctwo.mhk) |
| Input | campGroups[2]: buttons → camp2Clicked; the book area → campDragged |
| Function | Address | Role |
|---|---|---|
openCamp2 | 0x4186dc | the book of Zoombinis waiting here (kept at gameState+0x3688), the camp's Zoombinis, the party (which joins the book when it doesn't carry on), and a hint |
camp2Frame | 0x418e62 | per-frame |
camp2Clicked | 0x418fa7 | button 1 sets out for scene 16 (group 4) if enough are chosen, else a random one of three remarks (sounds 20084…) |
campDragged | 0x41914d | dragging Zoombinis in the book; clicking elsewhere starts the camp's "thing" there (campThingRects: ambient animations) |
drawBook, scrollBook, bookEntryAt, makeBookRoom, refreshBook, dropEmptyBookRows | 0x419c3a… | the book |
addPartyToBook | 0x41a23b | adds the party after the last taken entry, else into free ones |
Things to know
- The population cap. A camp can hold 625 Zoombinis, the whole population;
campPopulationFullchanges the remarks. - Group order. Shelter Rock offers group 2 or group 3; both end at Shade Tree, which leads to group 4.
- Unlocking. Each camp's map hotspot is open only when the "left" bits in
gameStatesay its group was finished (gameState[0x50] & 0xf,+0x52,+0x51). - The camps share their input items with the isle's style: two button items and a whole-screen item, plus
campAreaItems/bookAreaItemsfor the drag areas.
Group 1: the first three puzzles
Reached from Zoombini Isle (isleButtonClicked button 6 sets sceneDue = 7) and chained: Allergic Cliffs → Stone Cold Caves → Pizza Pass → Shelter Rock.
Allergic Cliffs (scene 7)
📷 Screenshot:
cliffs-overviewThe cliffs: two bridges, upper and lower, with Zoombinis waiting at the left and the buttons at lower right. Capture: start a new game, make a party, click button 6, wait for the journey.
📷 Screenshot:
cliffs-sneezeA Zoombini turned back by a sneezing cliff. Capture: send a Zoombini across the wrong bridge.
| Scene / module / archive | 7 / decomp/bridge.cpp / BRIDGE (bridge.mhk) |
| Group list | bridgeGroups → bridgeClicked |
| Level | bridgeLevel (0-3), chosen from sceneLevel() |
Entry points
| Function | Address | Role |
|---|---|---|
openBridge | 0x41a506 | loads bridge.mhk, sounds, images and scripts; the two placed spots at the bridges' ends; the views; the party; the level's rule |
bridgeFrame | 0x41aa24 | updateViews; leaves when sceneDue is set and sound 996 is done; fidgets |
bridgeClicked | 0x41af4c | button 1: map; button 2 (when bridgeGoReady): send on to scene 8; item 3: drag a Zoombini to a bridge (limited to six sent back: sentBackCount >= 6) |
bridgeKey | 0x41b203 | |
closeBridge | 0x41a9d7 |
Where the rules live
makeBridgeRule(0x41b812) builds the level's rule from the party's actual features (ChosenSnoids): a rule is aFeatureRule(FeatureRules, at0x4ab804) saying which side a Zoombini with certain feature values is sent to. Level 0 builds 20 single-value masks; the other levels build pair masks (the table0x12 … 0x45lists feature pairs) and count how many chosen Zoombinis match each, then pick one that suits the party.bridgeSnoidNotify(0x41b453) is the logic of a crossing: script event 10 starts the crossing script for the Zoombini by how it's going (crossingEvent1000-1016, upper or lower bridge bycrossingBridge); events 1-2 and 4-5 setreactingView; 3 (lower) and 6 (upper) mean it got across; 20 means it was sent back (sentBackCount).turnedBack(0x41c00c) andbridgeTimer(0x41a40f, started bystartBridgeTimer): helpers for the sent-back Zoombinis and for timing.
Things to know
- The module's string
"Upper bridge accepts:"(a debug message) is what identified it as the cliffs. - Zoombinis on the bridge are stacked in
lowerViews/upperViews; a cheer plays when all are over, and more fidgets are allowed as the chosen Zoombinis run out (bridgeFidgetsAllowed).
Stone Cold Caves (scene 8)
📷 Screenshot:
caves-overviewThe caves: four doors in the rock face, the characters at them, and Zoombinis queuing. Capture: complete Allergic Cliffs.
📷 Screenshot:
caves-remarkA guard speaking one of its remarks. Capture: wait on the screen.
| Scene / module / archive | 8 / decomp/tunnels.cpp / TUNNELS (Tunnels.MHK) |
| Group list | tunnelsGroups → tunnelsClicked |
| Level | rules by level; the number of turn-backs allowed (16-22) |
| Function | Address | Role |
|---|---|---|
openTunnels | 0x45e441 | the level's turn-backs allowed and rules, Tunnels.MHK, the backdrop, images, scripts; views (four placed at the doors, the characters, the buttons); the party; a first remark |
tunnelsFrame | 0x45ea81 | sequencing and fidgets |
tunnelsClicked | 0x45eff0 | buttons and dragging |
makeOneFeatureRule | 0x460e3d | level 1: counts chosen Zoombinis having each of the 20 feature values, leaves out one count if others remain, looks for a count about half the party's size and picks that value at random as the rule, which the door accepts or refuses at random |
makeOneValueRules, makeTwoValueRules, makeTwoFeatureRules | 0x461135, 0x4612b1, 0x461bec | the higher levels' rules (two doors' rules, two features) |
sendThroughDoors | 0x45f9c9 | sends up to four waiting Zoombinis (tunnelQueue) off through their doors, freeing the four places by the doors |
tunnelsSnoidNotify | 0x45fb50 | the Zoombini's script events |
turnedBackAtDoor | 0x460c41 | a Zoombini refused at a door goes back; counted against the turn-backs allowed |
sayTunnelRemark, queueRemark, tunnelRemarkNotify | 0x460571 | the guards' remarks: pools speaker0BackLines, speaker0Replies (sound ids from 0xfa0) |
Things to know: this scene has the most elaborate remarks in the game (speakers with lines for being turned back, replies, and so on, in the speaker… pools); its rules use the same FeatureRule type as Allergic Cliffs (FeatureRules at 0x4b7f18 for the caves).
Pizza Pass (scene 9)
📷 Screenshot:
pizza-overviewPizza Pass: the pizza being assembled in the middle with the topping buttons to its left, and the trolls waiting. Capture: complete Stone Cold Caves.
📷 Screenshot:
pizza-trollsThe trolls Arno, Willa and Shyler, each with a thought bubble of what they want. Capture: higher levels have more trolls.
📷 Screenshot:
pizza-yuckA troll reacting to a pizza it dislikes. Capture: serve a pizza with a topping the troll doesn't want.
| Scene / module / archive | 9 / decomp/pizza.cpp / PIZZA (Pizza.MHK) |
| Group list | pizzaGroups → pizzaButtonClicked; 13 items in pizzaButtons (the topping buttons are items 3-13) |
| Level | pizzaButtonsLevel0-3 pick which buttons exist |
| Function | Address | Role |
|---|---|---|
openPizza | 0x4402c0 | resets state; the level's buttons; Pizza.MHK backdrop, images, features, scripts; the pizza, the topping views and the trolls at the level; the toppings and what each troll wants (shareToppings); the party; an introduction |
pizzaFrame | 0x4411f2 | sequencing, troll fidgets (trollFidget) |
pizzaButtonClicked | 0x441e78 | buttons and topping clicks (toppingButton) |
shareToppings | 0x442ea2 | shares the toppings picked (pickToppings) among the trolls at random (from level 2 each troll gets at least one); at levels 1 and 3 shows four pizzas made from two toppings the troll with the fewest wants and one each the others want |
judgePizza | 0x44338b | what troll n (0-2) makes of the pizza: 0 if it has one topping the troll doesn't want, 4 if more; else 2 if it has all the troll wants, 1 if not all (3 is never returned) |
servePizza | 0x445153 | a pizza is served: counts down pizzas left; the troll up takes it and the Zoombini at the pizza gets its notify |
trollReacts | 0x444c62 | records the pizza as tried and has the troll whose turn it is react (8020, 9026 or 10030), placed by placeTrollToppings; Willa's turn is skipped while lastPizzaEaten |
trollsEat, trollVerdict, showJudgedPizza | 0x444391 | the eating and verdict animations |
bringNextZoombini, pizzaZoombiniNotify | 0x445789, 0x444e0c | the Zoombini carrying each pizza |
placeArnoToppings, placeWillaToppings, placeShylerToppings | draw each troll's wants | |
sayIntroduction | the introduction |
Things to know
- The three trolls are Arno, Willa and Shyler; the debug string
"Arno 1 0 …"(a line per troll of eight 0/1 wants) is built bysprintfin the module. pizzaTried/recordPizzaTriedkeep a record of combinations already tried so the same pizza isn't offered twice.- Finishing the group sends the party to Shelter Rock (
sceneDue = 4) and sets bit1 << levelingameState[0x50](enterNextScene, case 9).
Group 2: ferry, toads and stone
Chosen at Shelter Rock (button 1 → scene 10). Chained: Captain Cajun's Ferryboat → Titanic Tattooed Toads → Stone Rise → Shade Tree.
Captain Cajun's Ferryboat (scene 10)
📷 Screenshot:
ferry-overviewThe river with Captain Cajun's ferry, the landing places and the Zoombinis waiting on the bank. Capture: from Shelter Rock, set out with button 1.
📷 Screenshot:
ferry-crossingThe ferry mid-river carrying Zoombinis, with Captain Cajun at the helm. Capture: place some Zoombinis on the ferry's seats and let it cross.
| Scene / module / archive | 10 / decomp/ferry.cpp / FERRY (Ferry.MHK) |
| Group list | ferryGroups → ferryClicked |
| Level | ferryLevel (0-4), with 16-20 Zoombinis (forcedFerryCount overrides) |
| Function | Address | Role |
|---|---|---|
openFerry | 0x41f97c | loads Ferry.MHK; Captain Cajun (the first time sound 1803, then one of cajunGreetings); the views; the places for the level (layOutFerryLevel) and the party; a hint or greeting |
ferryFrame | 0x41ff89 | once everyone has crossed (ferryLeaving), Captain Cajun speaks and the group ends (sceneDue = 11) |
ferryClicked | 0x4203b3 | buttons and dragging a Zoombini to a place |
layOutFerryLevel | 0x4211a3 | the level's places and the number of Zoombinis: SCRB scripts 1510-1529 |
layOutFerry | 0x420cce | lays out the places from a script: its first two frames are two lists of parts; each part is a view at a point, 1-3 places to stand, 4-10 scenery |
findFerryPlace | 0x4214ab | finds a free waiting place |
linkFerryPlaces | 0x42121c | links places (which seats are paired) |
startNextCrosser, crosserNotify, moveFerryOn | 0x420f85, 0x420a60, 0x4209b8 | each crossing: the ferry's views play scripts 1604-1607 and the Zoombini's script by route |
slideFerryViews, ferryHelperNotify | the ferry sliding across |
Things to know: Captain Cajun has pools of lines for greetings, idle remarks and for good and bad placing (cajunGreetings, goodPlacingRemarks, badPlacingRemarks, sound ids 0x708-0x723). The ferry's routes use slots allocated with returnRoutesUsed.
Titanic Tattooed Toads (scene 11)
📷 Screenshot:
toads-overviewThe river with the grid of lily pads and toads. Capture: complete the ferry.
📷 Screenshot:
toads-hopA Zoombini hopping across lily pads. Capture: start the crossing once the board is set.
| Scene / module / archive | 11 / decomp/lilly.cpp / LILLY (Lilly.MHK) |
| Group list | lillyGroups → lillyClicked |
| Level | lillyLevel (set when the scene opens, from the level reached); LillyActor is its large view body |
The module is 46 KB, the biggest puzzle (its boundary with hotel is found from the data, not from padding).
| Function | Address | Role |
|---|---|---|
openLilly | 0x4281b0 | opens Lilly.MHK at the level reached |
lillyFrame | 0x428d84 | per-frame |
lillyClicked | 0x429943 | buttons; drag pieces |
setUpBoard | 0x42cc75 | turns and mirrors the level's grids, leaves out some pieces, deals the squares and fills them in, noting starting squares on the first row (lillyStarts) at levels 3 and 4 |
dealSquares | 0x42ca39 | deals out the twelve squares' contents from three sets (3, 4, 5 entries: squareSetA/B/C) |
turnGrid, mirrorGrid | 0x42d6e9, 0x42d875 | rotate and reflect a grid |
swapSquares | 0x42c6dc | swaps the attributes of two squares and replans every actor whose kind either now has |
planWay, searchLayer, searchStep | 0x42ec2a | plans an actor's way across (kind 0) or down (1): from each square, the least visited neighbour that's free and of its kind, at most 200 steps |
dragLillyPiece | 0x42d9c5 | dragging a square |
addLillyActors, lillyNotify30/44/49/54/60/70, hopperNotify, hopNotify | 0x42b857, 0x42f49d | the hoppers and their script events |
checkLillyArrivals | 0x42e6b5 | have they got across |
Stone Rise (scene 12)
📷 Screenshot:
stonerise-overviewThe cliff of stones with the Zoombinis waiting at the bottom and the cells above. Capture: complete the toads.
📷 Screenshot:
stonerise-lit-pathA path of lit stones between Zoombinis that share a feature. Capture: place Zoombinis in adjacent cells.
| Scene / module / archive | 12 / decomp/slides.cpp / SLIDES (Slides.MHK) |
| Group list | slidesGroups → stoneRiseClicked |
| Cheat | with a typed code, map hotspot 8 opens scene 20 instead (Hidden scenes) |
| Function | Address | Role |
|---|---|---|
openStoneRise | 0x446bf8 | opens Slides.MHK, builds the board (all cells empty), the party |
stoneRiseFrame | 0x447171 | per-frame; after finishing goes to Shade Tree (sceneDue = 5) |
stoneRiseClicked | 0x447528 | buttons and dragging a Zoombini onto a cell |
lightPath | 0x448f02 | lights the path: from the listed cell, walks back along the links: Zoombinis' cells go to 508; a feature stone (510-513) lights when the Zoombinis on either side share its feature; a plain stone lights on level 1 after a Zoombini's cell; the walk ends at an empty or blocked cell or a stone that doesn't light |
sharedStone | 0x449f96 | whether two cells' Zoombinis share a feature, checking from a random feature on (510-513 for hair, eyes, nose, feet; 0 if none) |
groupInThrees | 0x44986f | groups the party in threes, each sharing a feature with the last where one can (the intended solution) |
pairByFeatures, seatPair, tryMove, tryMovesAround, followRoutes, lightFromStarts | 0x4494b3, 0x44abce, 0x44a674, 0x44accc | the board-building and checking helpers |
markCellAt, linkCells | 0x44b0fc | the board's cells |
Things to know: this scene and Fleens share a remark set (stoneRiseSounds equals fleensSounds); finishing sets bit 1 << level in gameState+0x52 and sends the party to Shade Tree.
Group 3: fleens, hotel and wall
Chosen at Shelter Rock (button 2 → scene 13). Chained: Fleens! → Hotel Dimensia → Mudball Wall → Shade Tree.
Fleens (scene 13)
📷 Screenshot:
fleens-overviewThe Fleens scene: a row of Fleens (small creatures) beside a line of Zoombinis. Capture: from Shelter Rock, set out with button 2.
📷 Screenshot:
fleens-pickA Zoombini dragged beside a fleen; the pair walking on together. Capture: drag a Zoombini to a fleen.
| Scene / module / archive | 13 / decomp/fleens.cpp / FLEENS (Fleens.MHK) |
| Group list | fleensGroups → fleensClicked |
| Level | fleensLevel (new rules on a new game at levels 1 and 3) |
| Function | Address | Role |
|---|---|---|
openFleens | 0x421738 | opens Fleens.MHK, sounds, images and scripts, the views and the party |
fleensFrame | 0x421d1e | counts Zoombinis walking up to their fleen (lineSnoids, lineFleens, pickedFleensFound); every so often (fleensFidgetInterval) sends a waiting Zoombini on, up to fleensFidgetsAllowed; moves the line on (moveFleenZoombinisOn); at the end goes to scene 14 |
fleensClicked | 0x422192 | 1 leaves (asking to keep the party); 2 sends the Zoombinis on (once fleensGoReady and fleensEntered), counted in snoidsOnTheirWay; 3 (while nothing is moving) drags a Zoombini: a placed one freely, another only when its fleen is idle; with practice mode a click on a fleen makes its Zoombini jump |
addFleens | 0x422e90 | makes the fleens, one for each traveller, with their features changed by the level's rules; picks up to three to stand apart (pickedFleens, placed by table 5000), the others by table 5001 |
layOutFleen | 0x422747 | lays out a fleen's cels for its script's frames |
fleensLeaderNotify, fleensExtraNotify | 0x42365a | the leader fleen's script events: turn, move views into order, send the picked fleens on or back, move the line, add a random extra |
fleensWalkerNotifyA/C/E, fleensMovingOnNotify | the walking Zoombinis |
Things to know: a Fleen is drawn like a Zoombini (a Snoid, laid out by the fleens' own layOutSnoid over fleen images and hot spots); its scripts are separate (loadFleenScripts).
Hotel Dimensia (scene 14)
📷 Screenshot:
hotel-overviewThe hotel front: rows and columns of rooms, with labels along the top and left, and Zoombinis arriving. Capture: complete the Fleens.
📷 Screenshot:
hotel-roomsZoombinis sent into rooms; a room that doesn't fit stays dark. Capture: send a Zoombini to a room.
| Scene / module / archive | 14 / decomp/hotel.cpp / HOTEL (Hotel.MHK) |
| Group list | hotelGroups → hotelClicked |
| Level | hotelLevel |
| Function | Address | Role |
|---|---|---|
openHotel | 0x424274 | opens Hotel.MHK at the level reached |
hotelFrame | 0x424b2d | per-frame; leaving goes to scene 15 |
hotelClicked | 0x426230 | buttons and clicking a room |
setUpHotelPuzzle | 0x425dde | picks which features the rows, columns (and layers) sort by, so that the chosen Zoombinis fit; at level 2 places some pieces at random in squares no Zoombini can take |
fitsRoom, fitsRoom3d | 0x426aff, 0x427217 | whether a Zoombini fits a room (2D, and the 3D levels' layers) |
sendSnoidToRoom | 0x42790a | works out where it stands and the area it covers, and starts its script (by level, square and feet) |
hotelSnoidNotify, roomViewNotify | 0x4276d0, 0x427e1a | script events |
drawFeatureLabels, drawIdBox, layOutRoomView | the labels and room art | |
darkenPalette | the lights going out |
Mudball Wall (scene 15)
📷 Screenshot:
mudball-overviewThe wall of 5×5 stones with a rope along its top and the pond below; a Zoombini on the rocks. Capture: complete the hotel.
📷 Screenshot:
mudball-codesThe codes box showing the chosen row and column values. Capture: click the controls to set a code.
| Scene / module / archive | 15 / decomp/net.cpp / NET (Net.MHK) |
| Group list | netGroupList → netClicked (18 items in netButtons) |
| Level | netLevel (0-3): levels 0-1 use 5×5 codes (columns and rows), 2-3 add a third dimension (5×5×5, markerPlaces3d[125]) |
| Function | Address | Role |
|---|---|---|
openNet | 0x43b20c | picks three random codes, loads the backdrop (by level), features, scripts and images, brings the party in, sets up the codes and the net, shows the hint |
netFrame | 0x43b86d | per-frame; on completion sends the party to Shade Tree (sceneDue = 5), sets bit <<4 in gameState+0x52 |
netClicked | 0x43c48b | clicks on the controls |
setUpCodes | 0x43c9e2 | picks for each of five rows a column value and a row value (and from level 2 a third) that no row has yet, fills codeColumns and codeRows (5×5) or also codeLayers (5×5×5), at levels 1 and 3 rotates rows by 2-3 places, places the party's Zoombinis (netGroups) at random free entries, picks the order of the codes |
chooseCode | 0x43d0b4 | a code chosen: sets the first, second or third code to a value and shows it; once all the codes the level needs are set, shows the marker and the net's view; 0 sends the marker off |
markerPlaced, flyMarker, landMarker, updateFlyingMarker | 0x43d70d, 0x43e435, 0x43da30 | the marker flying to its entry in the wall and landing |
crossingNotify | 0x43de4d | the Zoombini's script events as it crosses |
splitIntoGroups, sendNextToNet, stepMazeSnoid | 0x43e370, 0x43cfc3, 0x43a2c8 | the party's walk to the net |
Things to know
net.cppis a big module with several roles: besides this puzzle it holdsenterNextScene(the scene switch), the per-scene remark pools (bridgeSounds, …smokeSounds), and walking helpers whose names mention the maze (layOutMazeCels,stepMazeSnoid,mazeZoombinisMeet).splitIntoGroupscan write more ofnetGroupsthan the 12 it clears, overcurrentNetPlaceand what follows: a genuine overrun in the original (see Data layout).
Group 4: the last three puzzles
Reached from Shade Tree (camp2Clicked button 1 → scene 16). Chained: The Lion's Lair → Mirror Machine → Bubblewonder Abyss → Zoombiniville.
The Lion's Lair (scene 16)
📷 Screenshot:
lion-overviewThe lair: the lion's paw over the golden stepping stones across the chasm, with Zoombinis at the left. Capture: from Shade Tree, set out.
📷 Screenshot:
lion-placesZoombinis standing on stones that match the feature the lion wants. Capture: drag Zoombinis onto the stones.
| Scene / module / archive | 16 / decomp/roster.cpp / CAVES (Caves.MHK) |
| Group list | caveGroups → cavesClicked |
| Level | cavesLevel |
The module is named for its strings, since it also holds the saved-game code; Caves.MHK is the lion's lair.
| Function | Address | Role |
|---|---|---|
openCaves | 0x41c09c | loads the state, scripts and sounds, the views for 20 places (the party's, and the cave's rows), the roster's resources and a line by the level |
cavesFrame | 0x41ca44 | per-frame; leaving goes to scene 17 |
cavesClicked | 0x41d3f4 | buttons; dragging; a click on a feature glyph (changeCaveFeature) |
setUpCaves | 0x41e0e3 | picks the roster's features and lays out the places for them |
layOutCaves, pickCaveFeatures, countByCaveFeatures | 0x41e5e1, 0x41e273 | which places want which values |
pickCave | 0x41e771 | the place for the Zoombini of a view: n if it's free and wants the Zoombini's values of the roster's features, else a free one that does, at random; 1 if none |
changeCaveFeature | 0x41e920 | changes the roster's first feature (the second moves on if they'd be the same), lays the places out again and walks the Zoombinis that had places to their new ones |
sendToCaves, walkToSpots, walkNext | 0x41eb43, 0x41ec69 | walking Zoombinis to places |
drawGlyph, drawGlyphs, drawFeatureTable, placeGlyphs | the feature glyphs and the table |
Mirror Machine (scene 17)
📷 Screenshot:
mirror-overviewThe mine: a boulder wedged overhead, wooden trestles and a rail track, with two rows of Zoombinis facing each other. Capture: complete the Lion's Lair.
📷 Screenshot:
mirror-gridThe hex grid of Zoombinis filling in, neighbours sharing features. Capture: at higher levels.
| Scene / module / archive | 17 / decomp/game.cpp (with WinMain) / SMOKE (Smoke.MHK) |
| Group list | smokeGroups → smokeClicked |
| Level | smokeLevel (1-4): levels 1-2 take the features of one Zoombini picked at random, levels 3-4 two of the party |
| Function | Address | Role |
|---|---|---|
openSmoke | 0x44e494 | resets state, picks the level and scripts, opens Smoke.MHK, adds views and the Zoombinis', takes the features of the Zoombini (or two) that the puzzle is built from, sets the last two of the party walking in, starts the opening scripts |
smokeFrame | 0x44f25d | per-frame; leaving goes to scene 18 |
smokeClicked | 0x44fa57 | buttons; drags to cells and the "deal" button |
startRound | 0x4507e0 | empties the slot views and deals new Zoombinis for the level |
dealFeatures, dealRandomFeatures, giveSlotFeatures | 0x45222d, 0x452258 | deals features to four Zoombinis |
recordSlotFeatures, applySlotFeatures | 0x4513ac | the left and right feature slots |
startNextCrossing, startNextMove | 0x45162e, 0x4514f6 | the crossing and the movers' scripts |
startGrid, growGrid, settleCells, fillFreeCells | 0x44cc51, 0x44ce56, 0x44d127, 0x44d5f5 | the hex grid at higher levels: puts the first Zoombini on it (the next one alike), then grows it |
shareFeature, placeAlike, markSharedFeature | 0x44cd71, 0x44d3b8 | whether two Zoombinis share a feature, and placing the alike ones side by side |
dragSnoidToSpot | 0x453e8c | dragging a Zoombini to a cell |
Things to know: several views are named after their ids (view11036, view11019…) because their purpose isn't known yet; the dealer button's lit/pressed/dim states are lightDealButton, pressDealButton, dimDealButton.
Bubblewonder Abyss (scene 18)
📷 Screenshot:
bubble-overviewThe dark chasm with the Zoombinis' bubbles rising over it. Capture: complete the Mirror Machine.
📷 Screenshot:
bubble-linesThe squares and lines the sequence is set on. Capture: at higher levels.
| Scene / module / archive | 18 / decomp/maze.cpp / MAZE2 (Maze2.MHK) |
| Group list | mazeGroups → mazeButtonClicked |
| Level | mazeLevel (0-4; level 3 with fewer than five Zoombinis plays as 4) |
| Function | Address | Role |
|---|---|---|
openMaze | 0x433510 | resets state, loads images, scripts and tables, picks the level and layout (loadHotSpotTable), adds the views of the layout's pieces and lines, sets the puzzle up, brings the Zoombinis in |
mazeFrame | 0x43490e | per-frame; the last puzzle sends the party to Zoombiniville (sceneDue = 6) and sets bits in gameState+0x51 |
mazeButtonClicked | 0x435264 | buttons and dragging |
setUpMaze | 0x436abf | sets the maze up for a level: the squares' kinds, the lines' values shuffled, a sequence of values by one of five ways for the level, and the Zoombinis' views |
chooseSequence1-5 | 0x438396…0x439190 | the five ways of choosing the sequence from the chosen Zoombinis' features |
takeRarestValue, takeCommonestValue, takeRareRow | 0x43780d, 0x437b7b | picks the value with the fewest/most counts |
addMazeSnoids, moveSnoidToSquare, mazeSnoidNotify | 0x436c71, 0x43596d, 0x43638b | the Zoombinis in the maze |
countValuesPresent, valueCount, indexOfLargestExcept | 0x437390 | counting the party's values |
Things to know: this is the last puzzle; finishing it records a completed journey in the roster (recordParty, the town's monuments).
Zoombiniville
The final destination, and the memorial of every journey.
On screen
📷 Screenshot:
town-overviewZoombiniville: one of the six 320-pixel-wide screens of the town, with townsfolk walking and the settled Zoombinis around. Capture: finish Bubblewonder Abyss, or open the town from the map (hotspot 16).
📷 Screenshot:
town-monumentA monument's plaque open: "this monument was made to honor the zoombinis who:" and the journey it records. Capture: click a building in the town.
📷 Screenshot:
town-clockThe clock tower, with hands showing the real time; clicking it winds them. Capture: click the clock (townsfolkViews[0]).
| Scene / module / archive | 6 / decomp/town.cpp / TOWN (Town.MHK) |
| Input | townGroups6: one button and the whole screen → townClicked |
Entry points
| Function | Address | Role |
|---|---|---|
openTown | 0x45c52e | adds the travellers to the population (townFull at 625) and the town's slots, builds the four town views (the highest cel shown, highestTownCel, depends on the population), walkers for every 37 over 20 (up to 16), the last 20 Zoombinis to settle, scrolls to the screen last shown, and picks the first sound (a hint, a greeting, or 3003 when the town is full) |
townFrame | 0x45d07e | removes townspeople who have walked off and adds more; every 150-300 ticks plays the next remark; occasionally has a settled Zoombini do something; sets the cursor by what it's over (a hotspot, the sides to scroll) |
townClicked | 0x45d468 | any click closes an open plaque; button 1 leaves; in the town: winds the clock, drags a Zoombini (who stays where dropped on the ground, y 410-475), opens the plaque of the hotspot under the cursor, or scrolls at the sides |
scrollTown | 0x45ce80 | moves every view with flag 2 a screen (320) left or right, wrapping around the town's 1920 pixels |
drawClock, readClock | 0x45cd27 | the clock (re-read every 1800 ticks) |
settleTravellers | 0x45dfb1 | turns the party into settled townspeople |
addTownsperson, townsfolkNotify | 0x45e06e | townspeople walking on and off |
placeRecordHotspots, findTownHotspot | 0x45db25, 0x45d715 | the buildings' hotspots, one per record |
The records
Each building is a monument (monumentBuildings[16], monumentTexts[16]: "this monument was made to honor the zoombinis who:", "this city hall celebrates…", "this clock tower was constructed for…", "this paper clip museum…", "this courthouse…") recording a journey the player completed: the 16 records in gameState (recordYears, recordMonths, recordDays, recordGroups, recordLevels) say when, which group and which level. The plaque's feat text comes from featTexts[16] (one per group and level, e.g. "ambled past allergic cliffs, cruised on by stone cold caves, and appeased arno the almost omnivorous"), and the date from levelTexts (months). The population shown ("zoombiniville population N") counts the Zoombinis that have settled.
Things to know
- The town is 1920 pixels wide in six screens; every scrolling view has flag 2.
- The window's cursor changes over hotspots and at the edges (
cursorFrame,setCursorMode). - The same module holds the intro scene (0), so a "town" bug may be in the logo code and vice versa.
- Greetings are sounds 3000-3002 in turn; when the town is full, 3003.
Hidden scenes and debug keys
Two hidden mini-games (scenes 19-21)
picker.cpp contains two scenes that the game never reaches by playing it:
- Scene 19 / 21 — catching (
openCatch,catchFrame,catchClicked;Picker.MHK): set up for "9 throws of 99" with Zoombinis crossing (catchCrossers,nextCatchSendTime) and a score of how many are caught (caughtNotify,placeCatchScore). - Scene 20 — targets (
openTargets,targetsFrame,targetsClicked): targets that burst when hit (fireShot,placeShot,startTarget,burstNotify,bigTargetOut,placeTargetScore,driftView).
📷 Screenshot:
hidden-catchThe hidden catching game. Capture: on the map, type the cheat code thatisCheat(0x469110d3, 0x1e1c32f2)tests for, then click hotspot 9.
📷 Screenshot:
hidden-targetsThe hidden targets game. Capture: on the map, enter the code thatisCheat(0xc07a877d, 0xedfa7273)tests for, then click hotspot 8.
mapClicked sends hotspot 8 (Stone Rise) to scene 20 and hotspot 9 (Fleens) to scene 19 when the matching cheat code has just been typed. Cheat codes are not stored: noteCheatKey (basecamp.cpp) hashes the last keys typed into two words, and isCheat(hash, code) compares against constants, so the code words themselves appear nowhere in the decompilation.
Debug keys
Typing a code (compared by hash, again) at a space key switches on debugMessagesOn, with the name tag "you got it". Then (mainloop.cpp:gameKey):
| Key | Does |
|---|---|
| Space | with another code: toggles the MIDI test (midiTest); in the test, Space with a modifier held steps through the 18 tracks |
^ | toggle memory statistics |
@ | mark every puzzle group as left at level 1 (unlocks the camps) |
= | redraw the whole game area |
& | draw the palette as a chart of squares |
* | no idle delay (Zoombinis fidget at once) |
[, ] | step mode off / on; ] steps one view at a time |
| Ctrl-E, Ctrl-F, Ctrl-X, Ctrl-Y | label views (all / actors only; by position / by id) |
| Tab | put every Zoombini in the party ("ALL in party") |
N | draw the walking paths |
P | toggle the frames-per-second display |
S | sound tests |
| Ctrl-R | show positions |
| Ctrl-Z | fill the views |
On the map, +/- change the practice party size and T then a-p previews a journey transition. The memory-statistics display (drawMemoryStats) and the message log (dumpMessages writes msgNNN.txt) are the other debug aids.
The d switch on the command line is separate: it turns on the engine's debug mode (Startup).
The port: overview
port/ builds the decompiled game, unchanged (decomp/ and glue/), for a modern system with CMake and SDL2: WebAssembly in a browser (the main target), headless under Node (for testing), and 32-bit native.
decomp/*.cpp ─┐ ┌─ web Emscripten + Asyncify ─▶ zoombinis.html/.js/.wasm
glue/ or ├─▶ library "game" ─┐ │
port/glue/* ─┘ (clang, with ├─▶ zoombinis ────┼─ headless same wasm under Node, no screen or sound
prelude.h) │ │
port/miniwin/ ──▶ library "miniwin"┤ └─ native SDL2 from the system (32-bit only)
port/host/ ──▶ library "host" ──┘
port/main.cpp ──▶ the entry point: sets up the drives, then calls the game's WinMain
The three layers
| Layer | Directory | Role |
|---|---|---|
| The game | decomp/, glue/ | compiled as is, in Borland's dialect (via prelude.h) |
| miniwin | port/miniwin/, port/include/ | the Win32 subset the game uses, implemented over SDL (miniwin) |
| the host layer | port/host/ | the few things that differ per target: fibers, yielding to the browser, message boxes (host layer) |
A source in port/decomp/ or port/glue/ replaces the same-named file in decomp//glue/. port/glue/quicktime.cpp does (QuickTime); no port/decomp/ file exists yet, because every decompiled module compiles as it is.
What runs where
- Windows and messages: the game's own message loop, window procedure, timers and hooks run as on Windows.
- GDI: software drawing into a 640×480 8-bit screen shown through a simulated system palette.
- Sound: waveOut devices mixed into SDL audio; midiOut sent to a General MIDI synth (TinySoundFont + GeneralUser GS).
- Threads: Win32 threads and the engine's own fibers switch cooperatively on one host thread.
- Files: Windows paths map to drives
C:(installed game and saves) andD:(the CD).
Commands
uv run port setup # the pinned Emscripten SDK (~1.8 GB) into build/emsdk/, and the SoundFont
uv run port build [web|headless|native] [--debug]
uv run port package # the site: build/port/site/
uv run port serve [--port 8000] # then http://127.0.0.1:8000/
uv run port run [--headless] [--seconds N] [--screenshot F.bmp] [--click MS:X,Y]… [--record F.wav]
build defaults to web. Plain CMake works too: emcmake cmake -S port -B build/port/web && cmake --build build/port/web.
Third-party pieces
| Piece | Used for | Licence note |
|---|---|---|
| SDL2 | window, input, audio | zlib |
| stb_truetype | the game's font | public domain |
| TinySoundFont | the MIDI synthesizer | MIT |
| GeneralUser GS (S. Christian Collins) | the General MIDI SoundFont, downloaded by port setup into build/soundfont/ (32 MB) | free to use and redistribute in software |
Cornerstone (CORNER.TTF) | the game's font, in assets/zoombi32/installed/ | free for personal use; replace it if you use the project commercially |
All are fetched at pinned versions and checksums (port.py, port/CMakeLists.txt).
Limits
- 32-bit only. The decompiled code assumes 4-byte
longs and pointers (it keeps pointers inlongs in places), so CMake refuses a 64-bit native target unless-DZB_ALLOW_64BIT=ON(the game then won't work). WebAssembly is 32-bit. Untangling this is a roadmap item. - The game assumes Windows 95 behaviour in places; miniwin reproduces it where relied on (see Quirks).
Status
The game starts and reaches Zoombini Isle (the scene most exercised); music plays through the synthesizer; sound effects are mixed but not yet checked by ear; the intro movie plays from its converted scene with its sound.
miniwin: Win32 over SDL
miniwin is to this project what DevilutionX's miniwin was to Diablo: a re-implementation of just the part of Win32 the game calls, so the game's source needn't change. The headers are in port/include/ (windows.h, mmsystem.h, Borland's dir.h/dos.h; everything in namespace miniwin); the implementation is port/miniwin/.
The model
One program on one screen, as the game expects of Windows 95 on a 640×480, 256-colour display.
- One host thread. Win32 threads are fibers scheduled cooperatively (
threads.cpp), as on a uniprocessor: a thread runs until it waits or wakes a thread of higher priority, which then runs at once (the game's file worker, above normal priority, relies on this to finish its call before its caller looks). service()does what Windows does behind the program's back: pumps SDL events into the message queue, fires multimedia timers, mixes audio, and presents the screen. The functions a program waits or polls in call it: the message functions,Sleepand the waits, and the time functions. It runs at most about once a millisecond and gives the host a turn every 16 ms (hostYield).- The screen is an 8-bit framebuffer shown through a system palette (
screen.cpp,palette.cpp); GDI draws into it or into bitmaps in software.
Files
| File | Covers |
|---|---|
miniwin.cpp | startup, shutdown, service() |
user.cpp | USER32: windows, the message queue (posted messages, then WM_PAINT, then WM_TIMER), timers, hooks, input state, cursors, metrics and system colours. Every retrieved message passes WH_GETMESSAGE hooks first |
gdi.cpp | GDI objects and device contexts, bitmaps and DIB sections (all surfaces are 8-bit or monochrome; handles are the objects' addresses) |
blit.cpp | colour matching, raster operations, BitBlt, StretchBlt, StretchDIBits, PatBlt, ScrollDC, GetPixel (on a palette device, GDI works in pixel values) |
draw.cpp | FillRect, FillRgn, InvertRect, Rectangle, Ellipse, Polygon, lines |
region.cpp | regions as bands of rectangles, combined band by band |
palette.cpp | the system palette and logical palettes; realising puts colours where Windows does (static colours matched exactly, the rest in free entries from the first), animating PC_RESERVED entries in place |
text.cpp | the game's TrueType font (CornerStone) with stb_truetype: unhinted, no antialiasing (a 256-colour display's text had none), GDI's layout rules, Windows-1252 |
screen.cpp | the SDL window: the framebuffer scaled to the window in whole multiples where they fit, the game's cursor drawn into the image so it scales with it; SDL input to window messages and key state; screenshots |
threads.cpp | Win32 threads and fibers on host fibers; priority-based wake-ups; events and waits |
kernel.cpp | KERNEL32 and ADVAPI32's registry: errors, version (Windows 95), modules, memory, atoms, time |
files.cpp | drives, paths, directories (see below) |
mmsystem.cpp | WINMM: waveOut, midiOut, multimedia timers |
midi.cpp | the General MIDI synthesizer (TinySoundFont) |
borland.cpp | Borland runtime extras: itoa, case-blind compares, getdisk/chdir, gettime |
internal.h | what the parts share (and the model, in its header comment) |
Files and drives
Each drive letter main() adds is a host directory: C: the installed game and its saves, D: the CD (--drive C=…, --cdrom D=…,LABEL,SERIAL). Windows paths are matched against the host's names without regard to case, as Windows does, one component at a time; there is one current directory, which may be on any drive. uv run port lays C: out from assets/zoombi32/installed/ (the settings mohawk.w32, the font CORNER.TTF, the saved-game list Zoombini.who, a Zoombi32.CFG that points at D:) and D: from assets/ packed back into archives.
Sound
Sound plays by the wall clock: service() mixes as many frames as time has passed from every open waveOut device (each through an SDL audio stream that converts format and rate) and queues them, so the game sees buffers finish in real time even where the host isn't playing yet (a browser waits for the user's first click). Finished headers are reported through the device's callback as a driver would from its interrupt. midiOut hands channel messages to the synth; the engine sequences MIDI itself, so the synth only sees what a MIDI port would. The game's MIDI map (MIDIMAP.DAT, mohawk.w32's "unknown device (port)") treats the device as a General MIDI port: drums on channel 10, channels 6, 7 and 16 muted.
Unsupported calls
A Win32 function the game calls that miniwin doesn't implement is reported once on the console (miniwin::unsupported), so new gaps show up while testing.
Adding to miniwin
Implement only what the game calls. Match Windows 95's behaviour where the game relies on it: 16-bit 0xFFFF counts and device ids, a click activating a window. Put code that differs per host in port/host/, never in miniwin, and never include miniwin's headers there.
The host layer
port/host/host.h is the interface; emscripten.cpp, posix.cpp and windows.cpp implement it for the three kinds of target. miniwin uses SDL for everything SDL covers (window, input, audio, timing); the host layer is the rest.
| Function | Meaning |
|---|---|
hostFiberCurrent, hostFiberCreate(stackSize, entry, arg), hostFiberSwitch, hostFiberDelete | fibers: execution contexts switched cooperatively. They carry both the engine's threads and miniwin's Win32 threads |
hostYield(ms) | give the host its turn (the browser's event loop) for about ms at most (0: just a turn) |
hostMessageBox(title, text, buttons, count) | show a message and wait for a button; its index |
hostTrace, hostTraceStack | diagnostics on the host's console |
hostFilesChanged | called once miniwin has flushed files it wrote, to persist them where the host must (IndexedDB) |
Nothing in port/host/ includes miniwin's headers, so a host file can use a host's own Win32 API.
Per target
| Target | Fibers | hostYield | Persistence |
|---|---|---|---|
web (emscripten.cpp) | Emscripten's fibers, built on Asyncify (each fiber has a C stack and an Asyncify stack of 256 KB, the game's deepest calls go through a few hundred frames) | returns to the browser through Asyncify and comes back | C: is mounted on IndexedDB (pre.js) and hostFilesChanged calls Module.zbPersist (at most once a second) |
POSIX (posix.cpp) | ucontext | does nothing | the files are on disk |
Windows (windows.cpp) | Win32 fibers | does nothing | disk |
Why Asyncify
The game runs its own blocking loops (waitForEventFor, the message pump, preloads), which a browser can't wait out: the page would freeze and nothing would draw or play. Asyncify unwinds the call stack at a hostYield, returns to the browser, and rewinds on the next turn. The cost is code size and speed; the benefit is that the decompiled control flow stays exactly as it is.
Compiling Borland's dialect with clang
The decompiled code is Borland C++ 4.5 C++. port/CMakeLists.txt builds it with clang so that decomp/ needs no changes:
| Mechanism | Effect |
|---|---|
-include miniwin/prelude.h | force-included before every decompiled source |
#pragma pack(1) in the prelude (after the standard headers, which keep the host's packing) | the game's structs are byte-packed as BCC32 packs by default; they mirror data files and each other's sizes, so their layout must be the original's |
miniwin/types.h | Borland's calling-convention and memory-model keywords (__pascal, __cdecl, __stdcall, far, near, huge, _export…) are defined away; Win32 types have Win32's sizes on every host (DWORD 32 bits even where long is 64) and handles are pointers to distinct incomplete types |
miniwin/borland.h | the Borland runtime's extras (itoa, stricmp, getdisk/setdisk, getcwd/chdir, gettime, _dos_getdate; Borland's global struct time shares a name with the C library's function) |
#define fopen miniwin::fopen (and getcwd, chdir) | the game's Windows paths go through miniwin's drives |
-fsigned-char | BCC32's char is signed (it isn't by default on ARM) |
-fno-exceptions -fno-rtti | the game uses neither: BCC32's exception frames were compiled into the original's objects, but the decompiled code only constructs and destroys objects |
-fno-strict-aliasing -fwrapv | the code aliases freely and relies on wrapping signed overflow |
-w -Wno-register -Wno-c++11-narrowing | modern C++ warns about much BCC32 accepted; register and narrowing in case labels are errors by default and are turned off |
The consequence is that -w hides real warnings; the matcher, not the compiler, is the check of correctness.
Things the dialect hides
longis 4 bytes in the original. The decompiled code keeps pointers inlongs in places, which is why the port is 32-bit only. Typing those pointers as pointers (a standing rule for new code) is the way out.- The game's own placement
operator new(zeroing) isaudioObj's, because standard C++ reserves the global placementoperator new(size_t, void *)and compilers skip replacements of it. __emit__and inline assembly only exist inglue/(not built for the port;port/glue/quicktime.cppreplaces it).
QuickTime in the port
The original game plays one movie, Data\Logo025.MOV, through QuickTime for Windows 2.x, whose video decoder for the codec QkBk is a DLL (qb32.qtc) that only that QuickTime can run. The port cannot use QuickTime, so it plays a converted form of the movie itself.
The scene file
QkBk is not a pixel codec but a scene compositor: each frame says which sprites (from a cast of bitmaps) sit where, in a palette, on a background colour (Movies). So "decoding" is drawing sprites, and the port needs no decoder. uv run port package turns assets/movies/LOGO025/ into a flat, little-endian scene file DATA/LOGO025.SCN (src/zbtools/formats/scene.py):
"ZBSC", u32 version (1), u16 width, u16 height, u32 ms per frame, u32 frames,
u32 sound rate, u32 sound samples, u32 cast items
frames: u8 background colour, u8 sprites, u16 palette (a cast item id),
then each sprite: u16 cast item id, i16 x, i16 y (slot order)
cast items: u16 id, u8 type, u8 0, then
palette (1): 256 colours of u8 r, g, b
bitmap (2): u16 width, u16 height, u32 size, rows of {u16 length, RLE data}
sound: 8-bit unsigned mono samples, with the movie's edit list applied (one track from time 0)
Only the movie's look and sound are kept: what the codec does with the rest of a frame's fields doesn't change what it draws. The player's drawing is checked against the original codec running under emulation: uv run movie-check runs qb32.qtc in Unicorn on the movies built from assets/ and compares every frame and palette.
The player (port/glue/quicktime.cpp)
It replaces glue/quicktime.cpp and answers the calls the game makes of the QuickTime SDK glue, by selector (qtim_…, cmgr_…; names in src/zbtools/quicktime.py):
| Game's need | Selectors |
|---|---|
| open the file and make a movie of it | qtim_2c, qtim_2a, qtim_02 |
a controller over a window (cmgr_0d to reuse one), place it | qtim_38, cmgr_0d, cmgr_0e |
play (cmgr_01 with action 8), let it run (cmgr_09 on each pass of the loop), ask whether it still plays (cmgr_05, flag 0x40) | |
| stop and dispose | qtim_31, qtim_07, qtim_37, qtim_0c |
It answers QTInitialize with version 0x2300 (the least the game accepts). The sound is one track from time 0, played through waveOut; the video follows the clock (frameMilliseconds, 10 fps), drawing the sprites of the current frame with its palette. The .MOV files themselves are left out of the site.
In the rebuild (VM) instead
glue/quicktime.cpp (not the port's) loads the real QuickTime and forwards each call through raw-byte stubs that put the selector in bx, as Apple's glue does; the rebuilt game plays the .MOV packed from assets/ (byte for byte the disc's). See QuickTime glue.
The web build, packaging and hosting
The page
port/web/shell.html is the page around the game: a splash with a description of the project, a link to the repository, the disclaimer that it's an unofficial fan project, a loading status and a Play button. Clicking Play starts the game and lets the browser start sound (browsers wait for a user gesture). port/web/pre.js runs before the game (--pre-js):
- mounts
C:on IndexedDB (FS.mount(IDBFS, …, '/c')) and loads what was saved, so saved games survive a reload;Module.zbPersistwrites it back at most once a second (called throughhostFilesChanged); - waits for Play (a run dependency);
- once the data packages have loaded, joins files that were split into parts, and copies the installed files the data package carries (
/c-default) intoC:where it lacks them (saved files win).
Query switches: ?noalert sends the game's message boxes to the console instead of an alert (automated testing); ?screenshot has the game write /screenshot.bmp to the page's file system. The last 5,000 lines the game prints are kept in window.zbLog for scripts.
Build flags (port/CMakeLists.txt)
-sASYNCIFY -sASYNCIFY_STACK_SIZE=262144 -sSTACK_SIZE=1048576 -sINITIAL_MEMORY=134217728 -sALLOW_MEMORY_GROWTH -sFORCE_FILESYSTEM -lidbfs.js -sEXIT_RUNTIME=1. The headless target uses the same Asyncify settings with -sENVIRONMENT=node -sNODERAWFS.
uv run port package
Builds the web page and writes everything a static host needs, and nothing else, to build/port/site/:
| Piece | Notes |
|---|---|
index.html, zoombinis.js, zoombinis.wasm | the build's page renamed so a host serves it at / |
favicon.ico, icon.png | from the executable's icon in assets/zoombi32/ |
zoombinis-config.js | Module.zbArguments: the drives and the SoundFont |
zoombinis-data-<n>.data, zoombinis-data.js | the game's files packed with Emscripten's file packager, and their loaders |
The data packages hold: D: = the CD's DATA/ packed from assets/ (as uv run assets pack does) plus the intro movie as a scene file (DATA/LOGO025.SCN; the .MOV files are left out), MIDIMAP.DAT; C: = build/port/data/c/ laid out from assets/zoombi32/installed/ (mohawk.w32, CORNER.TTF, Zoombini.who) and a Zoombi32.CFG pointing at D:; and the SoundFont. package needs only the repository, not the disc.
File size limit. No file in the site is over 24 MiB (port.SITE_FILE_LIMIT), for hosts that cap file sizes (Cloudflare Pages allows 25 MiB). Files are grouped into packages under that limit (first fit, largest first); a file bigger than the limit (the 32 MB SoundFont) is split into NAME.part0, NAME.part1, … that pre.js joins back at start-up. package checks that no file is over the limit.
The browser keeps the packages in IndexedDB, so a return visit doesn't download them again.
Hosting
Upload build/port/site/ as it is to any static host. CI deploys it to Cloudflare Pages on pushes to main (wrangler-action, secrets CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, variable CLOUDFLARE_PAGES_PROJECT); the book is separate.
Native and headless
uv run port build native needs a 32-bit SDL2 (the system's, or a pinned release built from source by CMake). uv run port run lays out the drives from build/port/data/ (C:) and the packed archives (D:), then runs either build.
Debugging the port
The headless build is the main tool: the same WebAssembly under Node, with no screen or sound, reading the drives' directories directly, so a whole run can be scripted and its output examined.
uv run port run --headless --seconds 30 --screenshot build/port/shot.bmp
uv run port run --headless --seconds 40 --click 12000:320,240 --click 14000:600,420 --screenshot build/port/shot.bmp
uv run port run --headless --seconds 20 --record build/port/audio.wav
| Option | Does |
|---|---|
--seconds N | quit after N seconds (--run-for ms for zoombinis) |
--screenshot F.bmp | write the 640×480 screen to a BMP about once a second (and at exit) |
--click MS:X,Y | click at a point of the screen MS ms after starting; MS:X,Y:press, :move, :release make a drag |
--record F.wav | write everything the game plays to a WAV file |
--soundfont F.sf2 | the General MIDI SoundFont (without one the music is silent) |
The zoombinis executable itself takes --drive C=<dir>, --cdrom D=<dir>[,label[,serial]], --program <Windows path> and -- <game command line> (for example -- d for the game's debug switch). uv run port run fills these in.
What to look at when something is wrong
- The console prints what miniwin doesn't support (
unsupported, once each), no-display notices, and the game's own error messages (showError). In a browser they're in the developer console andwindow.zbLog. - A crash or hang in the game's code: the engine's fibers make stacks unusual;
hostTraceStackprints the call stack where the host can. - A wrong picture: take a headless screenshot and compare with the VM's (
uv run vm screenshot) at the same moment. Differences in colours usually mean palette realisation (palette.cpp), in text GDI layout (text.cpp), in pixels a raster operation (blit.cpp). - Timing: miniwin's clock is the wall clock; scripted clicks use the same clock, so slow builds need more
--seconds. - Decompiled code that doesn't match the original can show up in the port as a behaviour difference; check
uv run matchanduv run near-missesfor the function.
Tests
tests/test_port.py covers the site packer (splitting, grouping, drive layout). Everything that needs the Emscripten SDK is checked by building (uv run port build web, which CI runs on every pull request) and by headless runs.
Quirks of the port
Things that look odd and aren't bugs.
- One host thread, many "threads". Win32 threads and the engine's fibers are all host fibers switched cooperatively. A thread that waits yields; a thread woken at a higher priority runs at once (as Windows would preempt). If the game seems to do things in a surprising order, it is almost always the priority wake-up rule.
- Time is polled in
service(). Nothing runs "in the background"; timers, audio and input arrive when the game calls a wait, a message function or a time function. A tight loop in the game that polls none of those would starve the browser; every blocking loop in the engine does call one. - Asyncify. Anything that can reach
hostYieldunwinds and rewinds the stack, so code that can reach it is slower and bigger. Don't be surprised by the code size. - The screen is 8-bit. Palette animation (fades, colour cycling) is real palette animation on the simulated system palette; the framebuffer is converted to RGB on presentation. The simulated system keeps Windows' 20 static colours (10 at each end), so the game's palette starts at index 10 and
PC_RESERVEDentries animate in place. - Windows 95 behaviour is reproduced where relied on: 16-bit
0xFFFFcounts and device ids, a click that activates the window. Don't "fix" these. - Case-insensitive paths.
D:\Data\Zoombini.mhkfindsDATA/ZOOMBINI.MHKon any host. - Text is drawn unhinted and unantialiased with the original font (Cornerstone, free for personal use, included for this non-commercial project: replace it if you use the project commercially).
- The intro movie is not QuickTime (QuickTime in the port);
qtim_*selectors the game never calls aren't implemented. - Saved games live in IndexedDB in the browser (
C:), and inbuild/port/data/c/for native/headless builds (removed only byuv run clean port-data). - MIDI goes to a General MIDI synthesizer, not a Windows MIDI device: the game's MIDI map picks that profile ("unknown device (port)").
- 32-bit only. See Overview.
Screenshots
The gameplay chapters (and a few others) mark where an image of the running game belongs with a placeholder: a block quote that begins
> 📷 **Screenshot: `isle-feature-panel`**
> *What the image shows.*
> *Capture:* how to reach that moment.
Ids are lower-case words with hyphens, unique across the book. The table below is generated from them.
Capturing
| From | How |
|---|---|
| the port in a browser | uv run port package && uv run port serve, play, and use the browser's screenshot tool on the canvas (the game's area is 640×480 and scales in whole multiples) or open the page with ?screenshot and read /screenshot.bmp from the page's file system |
| the headless port | uv run port run --headless --seconds 60 --click 12000:320,240 --screenshot build/port/shot.bmp: scripted clicks (--click MS:X,Y, or :press/:move/:release to drag) reach a scene without a window; the BMP is the 640×480 screen. Convert to PNG with any tool |
| the original, in the VM | uv run vm run, play, uv run vm screenshot (a PNG of the VM's screen): useful for comparing the port with the original |
| movies | uv run assets frames LOGO025 1 701 draws frames of the converted intro as PNGs in build/movie-frames/ |
| backdrops and art | the images in assets/<ARCHIVE>/tBMP/ are the game's own pictures (scene backdrops are the 640×480 ones, 5000.png and nearby); use them for a clean, UI-free view |
Reaching a given scene quickly: from Zoombini Isle, make a party and set out; the map's practice mode (Ctrl-P, then 1-4) opens every place at the chosen level without touching a real journey; and uv run port run --headless with --click can script the whole route. Hidden scenes need the cheat codes described in Hidden scenes.
Use PNG, at the game's native 640×480 where you can, named <id>.png.
Adding one
- Save the image as
docs/src/images/<id>.png. uv run book screenshots --embedinsertsabove the placeholder and leaves the placeholder as the caption.uv run book screenshots --updaterefreshes the index below (a test checks it is current), anduv run book screenshotslists what is still missing.
Index
| Id | Chapter | What it shows | How to capture it |
|---|---|---|---|
camp1-overview | gameplay/camps | Shelter Rock: the scrolling rows of camp slots, the Zoombinis standing in them, and the buttons at the right edge. | finish Pizza Pass (or use practice mode off with a saved game that has). |
camp1-drag | gameplay/camps | A Zoombini picked up from its slot and being dragged toward the "party" area. | click and hold on a Zoombini in the camp. |
camp2-book | gameplay/camps | Shade Tree: the "book" of Zoombinis waiting there, with its scroll arrows. | finish Stone Rise or Mudball Wall. |
cliffs-overview | gameplay/group1 | The cliffs: two bridges, upper and lower, with Zoombinis waiting at the left and the buttons at lower right. | start a new game, make a party, click button 6, wait for the journey. |
cliffs-sneeze | gameplay/group1 | A Zoombini turned back by a sneezing cliff. | send a Zoombini across the wrong bridge. |
caves-overview | gameplay/group1 | The caves: four doors in the rock face, the characters at them, and Zoombinis queuing. | complete Allergic Cliffs. |
caves-remark | gameplay/group1 | A guard speaking one of its remarks. | wait on the screen. |
pizza-overview | gameplay/group1 | Pizza Pass: the pizza being assembled in the middle with the topping buttons to its left, and the trolls waiting. | complete Stone Cold Caves. |
pizza-trolls | gameplay/group1 | The trolls Arno, Willa and Shyler, each with a thought bubble of what they want. | higher levels have more trolls. |
pizza-yuck | gameplay/group1 | A troll reacting to a pizza it dislikes. | serve a pizza with a topping the troll doesn't want. |
ferry-overview | gameplay/group2 | The river with Captain Cajun's ferry, the landing places and the Zoombinis waiting on the bank. | from Shelter Rock, set out with button 1. |
ferry-crossing | gameplay/group2 | The ferry mid-river carrying Zoombinis, with Captain Cajun at the helm. | place some Zoombinis on the ferry's seats and let it cross. |
toads-overview | gameplay/group2 | The river with the grid of lily pads and toads. | complete the ferry. |
toads-hop | gameplay/group2 | A Zoombini hopping across lily pads. | start the crossing once the board is set. |
stonerise-overview | gameplay/group2 | The cliff of stones with the Zoombinis waiting at the bottom and the cells above. | complete the toads. |
stonerise-lit-path | gameplay/group2 | A path of lit stones between Zoombinis that share a feature. | place Zoombinis in adjacent cells. |
fleens-overview | gameplay/group3 | The Fleens scene: a row of Fleens (small creatures) beside a line of Zoombinis. | from Shelter Rock, set out with button 2. |
fleens-pick | gameplay/group3 | A Zoombini dragged beside a fleen; the pair walking on together. | drag a Zoombini to a fleen. |
hotel-overview | gameplay/group3 | The hotel front: rows and columns of rooms, with labels along the top and left, and Zoombinis arriving. | complete the Fleens. |
hotel-rooms | gameplay/group3 | Zoombinis sent into rooms; a room that doesn't fit stays dark. | send a Zoombini to a room. |
mudball-overview | gameplay/group3 | The wall of 5×5 stones with a rope along its top and the pond below; a Zoombini on the rocks. | complete the hotel. |
mudball-codes | gameplay/group3 | The codes box showing the chosen row and column values. | click the controls to set a code. |
lion-overview | gameplay/group4 | The lair: the lion's paw over the golden stepping stones across the chasm, with Zoombinis at the left. | from Shade Tree, set out. |
lion-places | gameplay/group4 | Zoombinis standing on stones that match the feature the lion wants. | drag Zoombinis onto the stones. |
mirror-overview | gameplay/group4 | The mine: a boulder wedged overhead, wooden trestles and a rail track, with two rows of Zoombinis facing each other. | complete the Lion's Lair. |
mirror-grid | gameplay/group4 | The hex grid of Zoombinis filling in, neighbours sharing features. | at higher levels. |
bubble-overview | gameplay/group4 | The dark chasm with the Zoombinis' bubbles rising over it. | complete the Mirror Machine. |
bubble-lines | gameplay/group4 | The squares and lines the sequence is set on. | at higher levels. |
hidden-catch | gameplay/hidden | The hidden catching game. | on the map, type the cheat code that isCheat(0x469110d3, 0x1e1c32f2) tests for, then click hotspot 9. |
hidden-targets | gameplay/hidden | The hidden targets game. | on the map, enter the code that isCheat(0xc07a877d, 0xedfa7273) tests for, then click hotspot 8. |
journey-overview | gameplay/index | The map screen with all sixteen hotspots visible and the map's text box showing "choose a level". | from the map (scene 1), in practice mode. |
isle-overview | gameplay/isle | Zoombini Isle: the panel of feature buttons (four rows of five) at lower left, the Zoombini being made in the middle, and the queue of finished Zoombinis waiting along the shore. | start a new game; you arrive here after the logo. |
isle-feature-panel | gameplay/isle | Close-up of the feature panel: hair, eyes, nose and feet choices (5 each) and the seven panel buttons below it. | same scene, crop to the panel (isleButtons, x 3-198, y 304-478). |
isle-sending-off | gameplay/isle | A party of Zoombinis walking off toward the map after the player clicks the "send off" button. | make at least the minimum party, click button 6. |
map-overview | gameplay/map-and-journey | The map with its sixteen hotspots drawn on the terrain and the text box at upper left. | from Zoombini Isle click the map button (panel button 5). |
map-practice-levels | gameplay/map-and-journey | The map in practice mode: the level list (1-4) and the "snoids to practice with" count in the text box. | open the map with no saved journey so every hotspot is available. |
journey-travel | gameplay/map-and-journey | A map screen mid-journey: Zoombinis walking along a path, with the map's name and the grid of places visited. | leave a puzzle for the next without "transitions off" (Ctrl-T). |
journey-population-sign | gameplay/map-and-journey | The "zoombiniville population N" sign. | travel to or from Zoombiniville. |
start-logo | gameplay/start | The intro logo movie playing full screen (a frame from Logo025.MOV), at 640×480. | the first seconds after pressing Play in the port; or uv run assets frames LOGO025 1 701 for stills from the converted movie. |
dialog-keep-party | gameplay/start | The dialog "the current party of zoombinis will be lost if you go to the map" with its LOSE 'EM / KEEP 'EM buttons. | from a puzzle, click the map button while Zoombinis are in the party. |
dialog-games | gameplay/start | The saved-games dialog (LOAD / SAVE) listing games. | press Ctrl-L (or Ctrl-S) at any time. |
dialog-options | gameplay/start | The options/help dialog with the ON/OFF toggles (music, sound, less/more action, hide cursor, sticky mouse…). | press ? or /. |
town-overview | gameplay/zoombiniville | Zoombiniville: one of the six 320-pixel-wide screens of the town, with townsfolk walking and the settled Zoombinis around. | finish Bubblewonder Abyss, or open the town from the map (hotspot 16). |
town-monument | gameplay/zoombiniville | A monument's plaque open: "this monument was made to honor the zoombinis who:" and the journey it records. | click a building in the town. |
town-clock | gameplay/zoombiniville | The clock tower, with hands showing the real time; clicking it winds them. | click the clock (townsfolkViews[0]). |
Glossary
| Term | Meaning |
|---|---|
| Asyncify | Emscripten's transformation that lets code with blocking loops hand control back to the browser and resume; what makes the port's fibers and hostYield work |
| BCC32 / TLINK32 | Borland C++ 4.5's 32-bit compiler and linker |
| bank (image bank) | many images in one tBMP resource, each with its own header; a feature's frames |
| cel | one image placed by a script frame: {image, x, y} |
| e2 | the game's layer over the engine (e2AllocHandle, e2SetupAnim…) |
| feature | a Zoombini's hair, eyes, nose or feet (value 1-5); also, in the code, a view with a script |
| frame hook | the callback the main loop calls once a pass: gameFrame |
| functional | decompiled, complete and portable but not byte-exact (@zoombi32-functional) |
| group (input) | a set of input items with handlers, listed in a group list |
| group (puzzle) | a set of three puzzles, each with four levels |
| handle | the engine's Mac-style movable memory block reference |
| marker | the /* @zoombi32 0x… */ comment before a function |
| match | a decompiled function compiles to the original's exact bytes |
| MHK / Mohawk | Broderbund's archive format and engine |
| miniwin | the port's Win32 subset over SDL |
| near-miss | a function that nearly matches |
| pending / due scene | pendingScene is the next scene to enter; sceneDue is a scene's own request to leave |
| port (engine) | a QuickDraw-style drawing surface (basePort and subclasses); (the port) the WebAssembly/native build |
| QkBk | Broderbund's QuickTime video codec: a scene compositor, not a pixel codec |
| RTTI | run-time type information; how the engine's class names survive |
| scene | one screen of the game, with open/close/frame/key callbacks |
| script (SCRB/SCRS) | an animation: frames of cels with events and sounds |
| snoid | the code's word for a Zoombini |
| traveller | a Zoombini on the journey (Traveller) |
| view | an animated thing on screen, in the view list |
| WaveMix | the software mixer inside the engine's audio |
| work port | the off-screen port the game draws into; changed regions are copied to the screen port |