Version 21 (modified by 11 months ago) ( diff ) | ,
---|
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. Maybe I will get as far as rewriting an engine for it so the games can be played on modern systems?
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 %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. \1 - At the start of a message, do not erase the previous ly printed message, instead append this one at the end [NSome text] - Hyperlinks. N is the event number that will be triggered (ASCII encoded) {%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 {%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); }
Opcodes
00: Return
"Return" or "End of line". If this is not present at the end of a line, the execution may continue with the next one?
01 xx: CheckFlag
Check if flag number xx is set. This is used for internal flags tracking the game state.
03 xx: HasItem
Check if the player is carrying item xx.
09 xx yyyy: IfEqual
Compare counter xx with value yyyy and continue executing the line if they are equal
0A xx yyyy: IfLess
Compare counter with value and continue if it is less than the value
0D xx yy: Check event
A lot of the conditions start with 0D
. This matches with an event from a button click.
For example, if a room has a button number 1, 0D 01 00
will match that, and the corresponding action will be triggered.
Every button click is matched with conditions in the current room as well as the ones in CONDGEN and/or CONDULTI.
Actions involving objects use the second parameter, for example 0D 68 05
corresponds to action 68 done on object 05.
0E xx: Check global event
Check global events, for example event 00 is the start of the game.
7E: Or
Used to combine multiple conditions (0D, 09, 0A, ...)
7F: And?
Probably And, also used to combine several conditions.
80 xx: ?
Currently unknown
81 xx: Set flag
Set flag xx
82 xx: Clear flag
Clear flag xx
83 xx: Get item
Add item number x to the player's inventory
85 xx: Remove item
Remove item from the inventory.
87 xx yyyy: Set counter
Set counter xx to value yyyy
89 xx yyyy: Add to counter
Add value yyyy to counter xx
8A xx yyyy: Subtract from counter
Subtract value yyyy from counter xx
8F xx: Go to room
Go to room passed as a parameter (single byte)
90 xx: Go to screen
Go to a screen (in the same room) passed as a parameter.
91 xx: Show global screen
Show one of the global screens (from the unique room in MSGGEN).
93 xx: WithScreen
Combined with a "Go to room", allows to set the target screen in the new room
95 xx yy: Initialize flag
A0 xx yyyy
Compare counter xx with value yyyy. This triggers an evaluation of the scripts with matching of the corresponding IfLess/IfEqual operations
FA: stop interpreter
Do not test the remaining script lines
FC: Game over
Trigger the "game over" and stop interpreting. This will ask the user if they want to restart from the last saving point or from the start of the adventure.
FD: Continue
Continue execution with the next script line.
FE xx: Goto
Call script number xx in the current room
FF: Winning the game
The game is won if you manage to execute this opcode!
INIT file
Not fully decoded yet.
I do not understand the start of the file. Could it be opcodes to init the game?
06 00 01 00 03 00 02 00 49 00 13 00 00 00 03 00 00 00 05 00 0D 00
(this could be the initial room to start the game in) 46 00 0B 00 00 00 07 00 00 00
The second part 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.
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
- Y position
- Color (using EGA palette)
- 0000 0001 0007
- Button ID or triggered action
- Button label (null terminated, $ can be used to highlight the shortcut character)
- Shortcut (also null terminated)
There are probably other widgets but I don't understand the exact format yet.
The chunk is terminated by two NULL words (XPos = YPos = 0).
0002 Actions
These are the action buttons that can be associated to objects.
Each action has an identifier byte, and a label and shortcut (same format as the global buttons).
The section is terminated by a NULL byte.
0003 Objects
Not fully decoded yet.
Each object has two null-terminated strings: a name and a description. Following them are three NULL bytes and an action ID, identifying which action can be used on the object. I suppose there could be more than one action per object, to be checked with a more complex game.
There are some more bytes before and after that. The section is terminated by a NULL byte.
I am not sure where each object start, do they all start with a 1 byte? If so, what are the two extra bytes before the first object?
0004 Inventory descriptions
Descriptions as NULL terminated strings for the "objects" and "inventory" screens.
This section is terminated by a $ (0x24) byte.
0005 Action results
Some messages for various actions as NULL terminated strings.
This section is terminated by a $ (0x24) byte.
0006
This section is terminated by a $ (0x24) byte.
0007
Terminated by a NULL word
0008
Terminated by two NULL words
0009
Not terminated?
Rehex script
Not really usable yet, hardcoded to the game I'm testing with for the most part. I can improve it once I figure out more of how this works.
LittleEndian(); FSeek(0x52); 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]; }; struct TextAndShortcut { struct NullTerminatedString label; char shortcut[2]; }; struct GlobalButton { uint16_t xpos; uint16_t ypos; enum <uint16_t> { BLACK; BLUE; GREEN; CYAN; RED; MAGENTA; YELLOW; GRAY; } bgcolor; uint16_t unknown[3]; uint16_t buttonID; struct TextAndShortcut label; }; struct GlobalButton globalButtons[2]; uint16_t unknown[3]; struct Action { uint8_t id; struct TextAndShortcut label; }; struct Action actions[6]; FSeek(0xD3); struct Object { uint16_t unknowprefix[4]; struct NullTerminatedString objectName; struct NullTerminatedString objectDescription; uint8_t unknownsuffix[3]; uint8_t linkedAction; // ID of the matching action in the actions list uint8_t unknown2[2]; }; struct Object objects[9];