wiki:WikiStart

Readingame

My notes about rewriting an engine for the Lectures Enjeu game.

This is a "choose your adventure" game (in French) designed for teaching kids how to learn. It used to be sold commercially but the editor does not sell it anymore. The version I got has a message encouraging free redistribution replacing the license file (both in the documentation and in the app itself).

Some of the adventures were initially published as books by schoolchildren, and adapted into electronic versions here.

I remember playing this at school as a kid, and recently I decided to take a closer look at how it works. I ended up writing an engine to play the games on Haiku, alongside with a debugger and some documentation about how the system works. I also gave a talk at the Capitole du Libre conference, as a relatively simple introduction to reverse engineering.

Introduction to this project

General look

This is a DOS application. A quick look at the ASCII strings in the executable tells us that it was compiled with Borland Turbo C++, and the user interface looks like it is based on Turbo Vision.

The engine loads each game from a separate directory with a set of .DAT files. Hopefully these contain the complete game logic.

Game files

Each game scenario is made of INIT.DAT, MSG.DAT, MSGGEN.DAT, CONDGALE.DAT, CONDSAL.DAT, CONDULTI.DAT.

All files are generally composed of chunks starting with a 16-bit size (self-including) followed by some data. A 16-bit 0 serves as a terminator chunk. The cunks do not appear to have an explicit type marker.

Message files

These two files contain the various messages that can be displayed during the adventure.

The messages are split by "room" chunks.

The first 16-bit word indicate the "room" chunk size

The single-page messages chunks are made of a size, followed by a NULL terminated string.

The complex messages chunks are made of a size, followed by more sub-chunks. Each sub-chunk is itself a size + a null terminated string.

The strings can contain various escape sequences:

  • %p - Replaced by the player's first name.
  • %n - Replaced by the player last name.
  • %b - To insert an inline button (followed by a button ID).
  • \o - Insert an "Ok" button centered on the last line. Can be followed by an ASCII encoded number identifying the event that will be triggered.
  • \! - At the start of a message, do not erase the previously printed message, instead append this one at the end.
  • [NSome text] - Hyperlinks. N is the event number that will be triggered when the link is clicked (ASCII encoded). The hyperlink text can contain other escape sequences.
  • {%f3|%m2~%m3} - Conditionals, if variable f3 is 0, show m2, else show m3.
  • {%f6|%m6~\o1} - The contents of the condition doesn't have to be a message, it can be direct text, or a button, or anything else.
  • {message 1|message 2} - Show message 1 for male players, message 2 for female players.
  • {%c1|%m1|%m2|%m3~%m4} - Show message 1 if c1 is 0, message 2 if it is 1, etc.
  • @ - Gender marker, replaced with an e if the player said she is female, nothing otherwise.
  • $ - Plural marker, replaced with an s if the last referenced counter in the message is not equal to 1.

Variables

This tells us a few things:

  • %m variables refer to submessages in a complex message
  • %f variables refer to boolean flags (such as owning or not owning an object)
  • %c variables refer to counters (such as health points)

where N is an identifier for the link, to be matched with a 0D command.

Ok button

The \o sequence can be on its own at the end of the string, or suffixed with an ASCII encoded number. The number can be matched by a 0D command in the COND files to trigger the appropriate action.

North/South/East/West/Up/Down

TODO: in some games you can move north/south/east/west and up/down, how is thins handled? This is not used in Le Mystère des Lunettes which I have studied now (because it is simpler).

These buttons are special: they are defined in the game executable, not in datafiles. So, do they trigger a 0D command like the others? Which parameter do they pass? Or do they use dedicated commands?

MSGGEN.DAT

MSGGEN.DAT is similar, but contains a single "room" with global ("general") messages that can be triggered from any room (such as global buttons or using objects).

Rehex script

Here is a rehex script to parse these files:

LittleEndian();

// Simple string chunks are defined by a size (self-including) and a
// string (NULL terminated)
struct lenstring
{
	uint16_t len;
	local uint16_t realLen = len - 2;
	char string[realLen];
};

// Room chunks have a size and are filled by string chunks
struct ROOM
{
	local uint16_t start = FTell();

	uint16_t sizeInBytes;

	struct lenstring substrings[0];
	while (FTell() - start < sizeInBytes)
	{
		ArrayExtend(substrings);
	}
};

struct ROOM rooms[0];

while(!FEof())
{
	ArrayExtend(rooms);
}

"COND" files

These implement the game logic.

CONDSAL is the scripts for each room. I found this because it has the same number of toplevel chunks as MSG.DAT, and also because "SAL" can be for "salles" (French for "rooms").

CONDGALE may be "conditions générales" and associated with MSGGEN.DAT? It contains the initialization script that is run at the start of the game and a few other global things.

I don't know what CONDULTI is for yet, but maybe it is for global things and associated with the INIT file?

Each of the COND script contains chunks that contain sub-chunks, in a similar format to the messages files. The content of each script is a sequence of opcodes. Most but not all of them start with the 0D opcode that checks if a specific button was clicked. If this is not the case, the script is not run and the next one is tried. There can be multiple scripts matching a specific button event, usually with extra conditions.

The rehex script, for now:

struct unknownchunk {
	uint16_t size;
	local uint16_t realLen = size - 2;
	if (size > 0) {
		char data[realLen];
	}
};

struct recursechunk {
	local uint16_t start = FTell();
	uint16_t sizeInBytes;

	struct unknownchunk innerchunks[0];
	while (FTell() - start < sizeInBytes)
	{
		ArrayExtend(innerchunks);
	}
};

struct recursechunk chunks[0];

while (!FEof()) {
	ArrayExtend(chunks);
}

The complete list of Opcodes (the ones known so far) in a separate page.

INIT file

I do not understand the start of the file yet. It is apparently made of 40 words of 16 bits each. Here is a comparison of the available adventures:

OffsetLunettesLyonBallonSamediVisitzooFleurSorciereMdemplFolibusHeros
00 0006 0008 0100 0009 0065 Number of byte variables?
01 0001 0000 0100 0020 000c 003c Number of word variables?
02 0003 0001 0002 0004 000f 0001 X position of main area
03 0002 0004 0006 0002 Y position of main area
04 0049 004c 004b 0048 0032 004c Width of main area
05 13 16 10 f 15 f Height of main area
06 Always 0000 Text foreground color
07 Always 0003 Background color of main area
08 Always 0000
09 5 1 5 X of something?
0a d 4 d f d Y of something?
0b 46 4c 46 Width of something?
0c b 5 b 8 b Height of something?
0d-1f Always 0 7 0 1 3 0 2 0 1 1 7 0 2 0 1 5 0 1 7
20 0 7 Related to the direction buttons, maybe enable/disable them?
21 0 13 Hooks for the direction buttons?
22 0 12
23 0 13 14
24 0 12
25 0 17 16
26 0 12
27 0 15

Note several words are completely identical, and most other are "close", hinting that these are counts of something. Comparing the internals of each game will be needed to determine exactly what...

The second part of the file after this header is made of chunks which all start with an ID. They don't have a size, instead they have some kind of termination pattern which is apparently different for each chunk (it is possible that a part of the header is actually chunk 0000, but that value is present several times so it's hard to know which one would be a chunk start. It doesn't matter, handling it as a fixed size header seems fine).

In some games (for example Le Lyon de la Révolution) the main textarea is larger and there are no buttons. Is that defined here? It would make sense to have it near the global buttons.

0001 Widgets

This chunk defines custom buttons and possibly other elements of the GUI.

For the buttons, the layout is, with each value on 16 bits:

  • X position (between 0 and 79?)
  • Y position (between 0 and 24?)
  • Color (using EGA palette)
  • 0000 0001 0007 (always the same?)
  • Button ID or triggered action
  • Button label (null terminated, $ can be used to highlight the shortcut character)
  • Shortcut (also null terminated)

Both the label and shortcut can be empty.

The chunk is terminated by two NULL words (XPos = YPos = 0).

0002 Actions

These are the action buttons that can be associated to objects or characters encountered in the game.

Each action has an identifier byte, and a label and shortcut (same format as the global buttons). The identifier is what is used to trigger hooks in the game scripts.

The section is terminated by a NULL byte.

0003 Objects

Not fully decoded yet.

The section start with two bytes encoding the decimal value 1000 (except in La Maison du Docteur Folbus, where it's 5000)

Each object starts with 8 bytes of which the purpose is currently not known, possibly object characteristics.

The first one always have a value of 1 (indicating that there are more objects)

Each object has two null-terminated strings: a name and a description.

Following them are two groups of 3 bytes linking to the actions that can be taken on the object. The first 3 are for when the object is found in a room (typically there will be a "Take" action), the 3 others are for when the object is in the inventory (including "Drop" if needed).

The section is terminated by a NULL byte.

0004 Inventory descriptions

Descriptions as NULL terminated strings for the "objects" and "inventory" screens.

This section is a list of NULL-terminated strings (possibly empty), the list is terminated by a $ (0x24) byte.

0005 Action results

Some messages for various actions as NULL terminated strings.

This section is a list of NULL-terminated strings (possibly empty), the list is terminated by a $ (0x24) byte.

0006

This section is a list of NULL-terminated strings (possibly empty), the list is terminated by a $ (0x24) byte.

It contains the available compass directions in each room.

For each room the string can be:

  • empty (compass is hidden?)
  • A single _ character (compass is visible but all directions disabled?)
  • A combination of the characters nseohbx, in this order, indicating the available moves: North, South, East, West (Ouest), Up (Haut), Down (Bas) and eXit.

0007

Terminated by a NULL word

0008

Terminated by two NULL words

0009

Not terminated?

Rehex script

Not tested with all the games yet.

LittleEndian();

// A generic struct for a NULL terminated string (until rehex can do it more easily)
struct NullTerminatedString {
	local int64_t start = FTell();
	local int64_t end = start;

	while (ReadU8(end) != 0) {
		end++;
	}

	local int64_t size = end - start + 1;
	char string[size];
};

FSeek(32); // Skip the header, to be decoded later...

struct TextAndShortcut {
	struct NullTerminatedString label;
	struct NullTerminatedString shortcut;
};

struct Point {
	uint16_t xpos;
	uint16_t ypos;
};

enum <uint16_t> Color {
	BLACK,
	BLUE,
	GREEN,
	CYAN,
	RED,
	MAGENTA,
	YELLOW,
	GRAY
};

struct GlobalButton {
	struct Point position;
	enum Color bgcolor;
	uint16_t unknown[3];
	uint16_t buttonID;
	struct TextAndShortcut label;
};

struct ButtonChunk {
	uint8_t undecodedData[48];
	struct GlobalButton globalButtons[0];

	while(ReadU32(FTell()) != 0) {
		ArrayExtend(globalButtons);
	}
	struct Point terminator;
};

struct Action {
	uint8_t id;
	struct TextAndShortcut label;
};

struct ActionsChunk {
	struct Action actions[0];

	while(ReadU8(FTell()) != 0) {
		ArrayExtend(actions);
	}

	uint8_t chunkTerminator;
};

struct Object {
	uint16_t unknowprefix[4];
	struct NullTerminatedString objectName;
	struct NullTerminatedString objectDescription;
	uint8_t actionsWhenFound[3];
	uint8_t actionsWhenOwned[3]; // ID of the matching actions in the actions list
};

struct ObjectsChunk {
	uint16_t unknown;
	struct Object objects[0];

	while(ReadU8(FTell()) != 0) {
		ArrayExtend(objects);
	}

	uint8_t chunkTerminator;
};

struct StringListChunk {
	struct NullTerminatedString stringlist[0];

	while(ReadU8(FTell()) != 0x24) {
		ArrayExtend(stringlist);
	}

	uint8_t chunkTerminator;
};

struct InitChunk {
	uint16_t id;

	if (id == 1) {
		struct ButtonChunk buttonChunk;
	} else if (id == 2) {
		struct ActionsChunk actionsChunk;
	} else if (id == 3) {
		struct ObjectsChunk objectsChunk;
	} else if (id == 4) {
		struct StringListChunk toolscreensChunk;
	} else if (id == 5) {
		struct StringListChunk invalidactionsChunk;
	} else if (id == 6) {
		struct StringListChunk availableDirectionsChunk;
		// Each of the string consists of some of the characters from "nseohbx", each corresponding
		// to a compass direction (North, South, East, West (Ouest), Up (Haut), Down (Bas), eXit)
		// It can also be a NULL string or a single _ char (I guess one of them hides the compass
		// completely and the other just disables all the buttons)
	} else if (id == 7) {
		uint16_t unknown_chunk_7;
	} else if (id == 8) {
		uint16_t unknown_chunk_8[2];
	} else if (id == 9) {
		// This chunk is empty
	} else {
		uint16_t unknown_chunk;
	}
};

struct InitChunk chunk[9];

Global files

COWBOY.DAT and OBJECTS.DAT are ANSI art files. They contain pair of an ASCII character + a byte with a foreground and background color in EGA palette. The pictures are both 16x12 character blocks.

TITRES.DAT is a text file with the list of available adventures.

Last modified 33 hours ago Last modified on May 1, 2024, 3:36:24 PM
Note: See TracWiki for help on using the wiki.