Old Čtyřlístek games on a tablet
In this article
In March I wrote about how I got the old Čtyřlístek games running for my son in Wine inside Docker. They were playable, but it wasn't quite right. Since then Claude Opus has taken apart the games' original executables and written a new engine for them in TypeScript. All eighteen episodes now run in the browser and as an app on an Android tablet. My son has played two of the games so far and loved them.
How I got here
The games are from 2001 to 2005 and need Windows with DirectX 7. This is what I tried, in order:
- Windows 11 – the games installed, but crashed on launch, even in compatibility modes.
- Windows XP in UTM – after an hour and a half of installing, DirectX support was missing.
v86in the browser – Wine in the emulated Linux crashed before the game even started.- Wine in Docker – worked; picture and sound went to the browser over noVNC and WebSocket. That was the March post.
- My own engine in the browser – where things stand today, and what this post is about.
First attempts at running the games
The Wine setup worked, but it had weaknesses that couldn't be tuned away:
- The VNC picture stuttered, dropping to just over 20 frames per second under load.
- Sound took its own path over WebSocket and lagged.
- Menu videos crashed the game, so I had to disable them by patching the exe.
- On an Apple Silicon Mac the i386 image ran under emulation and took about half an hour to start.
The games themselves are simple. Centauri Production's CPAL2 engine is pure 2D – rooms made of images, characters walking on polygons, plus dialogues and an inventory. Emulating all of Windows for that seemed pointless, so I decided to write the engine again. I knew exactly what to do and how, I just didn't have that much time, and I'm a bit lazy. So I told Opus what to do and how, and it did the reverse engineering and wrote the new engine.
Data formats
Everything except the videos is in .res archives (Resource File 2.01). A header comes first, then the uncompressed data of the individual files, and at the end a directory with a 92-byte record per file: path in cp1250, offset and size. Every file inside starts with a text header naming its type and version, such as CP roominfo 2.2, CP talk 2.4 or CP paction 2.5. A room is made of .rom, .spr, .pth, .cbx, .ans, .gfx and .ani files holding the background and sprites, walking polygons, clickable areas and animations.
In the exe each format has exactly one loader function, and it also reads older versions. One Python parser therefore handles all eighteen games. The exporter converts the data for the browser: graphics to WebP atlases, room descriptions to JSON, sounds to Ogg and videos from Intel Indeo 5 to H.264. The older episodes have voices in WAV with MS ADPCM compression, which Chromium can't play, so they are converted to Opus.
Decompilation
The logic isn't in the data. What happens when you click a door, which items can be combined, or when a dialogue starts is compiled into each episode's cdromek.exe. Claude imported each exe into Ghidra and ran it without the GUI, only through command-line scripts. The DecompileAll script writes the whole exe into one C file, each function together with the strings it uses, and that file can then be searched with plain grep.
Room logic is only a small part of the code. In Silák it is about 30 functions and 35 KB, roughly 4 % of the code. The rest is the engine, minigames and video playback. Each room has a procedure that handles Windows messages: a click on an area is message 0x407, a character reaching its destination 0x404, a dialogue line 0x409. A message passes through the global handler, then the room procedure and finally the default procedure, which supplies the generic replies when nothing can be done with an object.
Clicking the bucket in the laboratory in Zítra se bude tapetovat looks like this after decompilation:
case 0x407:
if (param_3 == (RECT *)0x4) {
switch(param_4) {
case (int *)0x7:
uVar3 = FUN_00469730(0x4bd,1,1,-1,0,-1,0);
DAT_004a2a08 = (int *)(int)uVar3;
DAT_004a2a0c = (int)((ulonglong)uVar3 >> 0x20);
return 1;
FUN_00469730 is SAY, meaning a character says something, and 0x4bd is text number 1213. The function returns a 64-bit handle that the room stores in a variable. In TypeScript this becomes:
case CBOX_BUCKET:
game.setVar(V_SAY_BUCKET, game.say(1213, true));
return true;
It wasn't that simple everywhere:
- Ghidra didn't find all functions. Room procedures are referenced only by an address in a
pushinstruction, so auto-analysis missed them. Thegen_starts.pyscript goes through theobjdumplisting, andCreateMissing.javaadds the missing function starts. - A switch on a 64-bit value. Rooms keep the step of their state machine in a 64-bit variable, and the decompiler gives up on such jump tables.
FixJumpTables.javarepairs them from a per-game list. - Silák and Loskutík are built with LTCG. The compiler passes arguments in registers and Ghidra leaves them out of the C. Text and item numbers or sound paths had to be read straight from the disassembly.
- Discs 15 to 20 have cumulative executables. Each also contains the rooms of all previous episodes, only without their data, so room numbers shift between versions.
Functions were matched between executables by strings and by asserts in which the authors left the source file name and line number. Character walking is jr_person.cpp:51 in both Sami doma and Silák. This way 176 functions were matched between those two games.
Each game got a document with the logic of all its rooms, the game variables and a walkthrough. Every claim in it carries an address in the exe, and whatever couldn't be traced is marked as a guess.
Engine
The engine itself is about 9 thousand lines of TypeScript. On top of that each game has on average 2,500 lines of its own scripts: one file per room, a list of all game variables from the exe with their initial values, and global reactions to item combinations. Room logic is compiled into the original, so it has to be rewritten by hand, room by room, which is why the scripts of all games together have about five times as many lines as the engine. The original game's messages map to hooks: 0x407 is onUse, 0x404 is onWalkEnd and 0x409 is onTalkLine. The engine functions the rooms call mean the same as in the original, for example say, walk, talk, showSprite or enableCbox. A room script can therefore be written almost line by line from the decompiled C.
Silák Bobík was the reference game; the engine was built on it. Every other game is described as a difference from it, and the engine only grew by what a new game actually uses. The answer menu in dialogues, for example, first appeared in Pinďa na brigádě.
A few more details:
- Walking finds a path with A* on a 4 px grid over the polygons from
.pthand smooths the resulting route. - Mouth movement during speech is driven by the
mouth.deffile from the original data. - A saved game is JSON holding only what differs from a new game: variables, inventory, the state of visited rooms, timers and running animations.
Where the behaviour couldn't be read from the exe, the engine uses the simplest guess, which is marked in the code with a stand-in comment. Scene transitions, for example, are plain crossfades. Bugs of the original games stayed: in Silák's laboratory you still get another full bowl every time you click the mixer.
Eighteen games
Centauri Production released the Čtyřlístek adventures on the discs of the CD-Romek magazine from 2001 to 2005. I have all eighteen Czech episodes, from Zítra se bude tapetovat to Silák Bobík. Only Čtyřlístek na horách is missing; it was released in Romania only.
All of them can be played from start to finish, including the in-game videos. Games are saved automatically and manually, and the game menu shows how many percent of each are done.
No mouse needed
Last time I wrote that my son would have to learn to use a mouse. Instead I added touch controls to the engine:
- a tap uses an object,
- a long press looks at it,
- dragging an item from the inventory uses it on something in the scene,
- the question-mark button highlights all active spots with their labels for two seconds.
The hint helps mostly with small objects that are easy to miss in a scene.
Besides the web version there is also an Android app that contains all the games and runs in landscape, full screen.
Tests
Playing through eighteen games by hand every time isn't possible. Each game therefore has an automated browser test that plays it from start to finish by the walkthrough at sixteen times the speed. After every step it checks the room, inventory and game variables. Midway it saves the game, plays on a bit and loads the save again; the state and a screenshot must match the save. All eighteen tests run in parallel in about two minutes.
The tests can't tell whether animations line up and sound isn't late. So my son and I test the games together and find things to polish.