= 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. * [[Mode d'emploi]] (not playable, crashes on startup, various missing features starting with real inline buttons) * [[Le mystère des lunettes]] (somewhat playable, some confusion with variables and objects handling and conditions) * [[La maison du docteur Folibus]] (not playable, many unimplemented opcodes) * [[La sorcière du temple maudit]] (crashes on startup) * [[Comment devenir un héros]] (crashes on startup) * [[Visite au Zoo]] (fully playable) * [[Drôle de Samedi!]] (fully playable) * [[À la recherche de ton ballon perdu]] (fully playable) * [[À la recherche de la fleur en or]] (fully playable) * [[Le Lyon de la révolution]] (fully playable) == 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) === Hyperlinks 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: ||=Offset=||=Lunettes=||=Lyon=||=Ballon=||=Samedi=||=Visitzoo=||=Fleur=||=Sorciere=||=Mdempl=||=Folibus=||=Heros=|| || 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 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.