├── map.bmp ├── DAVE.EXE ├── tool.png ├── dave_bug.png ├── dave_hax.png ├── debugger.png ├── README.md └── dave_parse.py /map.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yo-yo-yo-jbo/dangerous_dave/HEAD/map.bmp -------------------------------------------------------------------------------- /DAVE.EXE: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yo-yo-yo-jbo/dangerous_dave/HEAD/DAVE.EXE -------------------------------------------------------------------------------- /tool.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yo-yo-yo-jbo/dangerous_dave/HEAD/tool.png -------------------------------------------------------------------------------- /dave_bug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yo-yo-yo-jbo/dangerous_dave/HEAD/dave_bug.png -------------------------------------------------------------------------------- /dave_hax.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yo-yo-yo-jbo/dangerous_dave/HEAD/dave_hax.png -------------------------------------------------------------------------------- /debugger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yo-yo-yo-jbo/dangerous_dave/HEAD/debugger.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A very Dangerous Dave 2 | This is an unusual blogpost about a bug (vulnerability?) that has been bothering me for years. 3 | When I was a kid I used to play tons of old DOS games. [Commander Keen](https://en.wikipedia.org/wiki/Commander_Keen)? With pleasure! [SkyRoads](https://en.wikipedia.org/wiki/SkyRoads_(video_game))? I remember most stages by heart. [AlleyCat](https://en.wikipedia.org/wiki/Alley_Cat_(video_game))? Oh yes! 4 | Well, one of the most remembered games from that era is [Dangerous Dave](https://en.wikipedia.org/wiki/Dangerous_Dave). 5 | You see, Dangerous Dave was a platformer - it had 10 levels, each one had a goal of getting to a trophy and passing through a door. 6 | Some of these levels had `Warp Zones` which are "secret levels", and you get to them by going out-of-bounds. 7 | Being an inquisitive young boy, I tried to find all warp zones, and I accidently found an unexpected one in level 6, which caused an unexpected behavior. 8 | You can click the link to watch a video recording of that: 9 | 10 | [![Dangerous Dave Out-Of-Bounds Read](http://img.youtube.com/vi/95tPM7GGAeI/0.jpg)](http://www.youtube.com/watch?v=95tPM7GGAeI "Dave OOBR") 11 | 12 | The end result looks like a complete mess: 13 | ![BUG](dave_bug.png) 14 | 15 | The idea of level 6 was to go right, take the trophy, head back and touch the door, however, *the door is treated as empty space if you still do not have the trophy*. 16 | I believe this was an obvious [out-of-bounds-read](https://cwe.mitre.org/data/definitions/125.html), but as a kid I never investigated it further. 17 | MANY years went by, until I finally found some spare time to try and understand what happens there. 18 | That led me to an interesting rabbit hole that I'd like to share today! 19 | 20 | ## LZEXE 21 | The game comes packed in one file only - `DAVE.EXE` (sha1 = `b0e70846c31d651b53c2f5490f516d8fd4844ed7`), its size is merely 76597 bytes! 22 | I was hoping to see many strings, but didn't see any. Opening it in IDA revealed it might be packed... I noticed it resolves some interrupt handlers from the interrupt handler table and even hooks the "divide by zero" handler (I talked about the subject [in the past](https://github.com/yo-yo-yo-jbo/mbr_analysis/)). 23 | It was later clear to me the code was compressed, and while I could have worked my way through it, I decided to search online. 24 | That led me to a thriving modding community - [shikadi.net](https://moddingwiki.shikadi.net/wiki/Dangerous_Dave) (if you don't know what a "Shikadi" is, you should play more [Commander Keen](https://en.wikipedia.org/wiki/Commander_Keen)). They had very complete records of the file format(s) and even a link to one Ghidra project file with some important notes. I can honestly say it saved me tons of work, and that's an important lesson too - it's cheaper to search online rather than embark on heavy reverse-engineering on your own. 25 | One of the first things I discovered was the file is [LZW-compressed](https://moddingwiki.shikadi.net/wiki/UNLZEXE), and luckily the community has a tool to decompress the executable: 26 | 27 | ```shell 28 | UNLZEXE.EXE DAVE.EXE 29 | ``` 30 | This makes the file 172848 bytes long - more than double in size! 31 | 32 | ## File format and tilemap 33 | The next part was to examine the file format and the level information. 34 | As before, the modding community left very [complete notes](https://moddingwiki.shikadi.net/wiki/Dangerous_Dave) with offsets in the file of where certain things are, including tilesets, level information and so on. 35 | While I was mostly interested in the level information, I wanted to examine the tile data - level data references tile numbers. 36 | Luckily, a modder called [MaiZure](https://www.maizure.org/projects/index.html) had a project that was kind of easy to compile and run to extract the tile information - thank you so much for that! 37 | While the tools MaiZure built are great, I ended up building my own parser in Python, which adds a few missing pieces. 38 | 39 | ![Tilemap](map.bmp) 40 | 41 | With greater confidence, I approached the format of the levels, which was the most interesting to me. 42 | 43 | ## Level formats 44 | The level format is described well in the modding wiki. There are `10` levels - each one is `100x10` tiles. Additionally, there is one special level with only tile information, which represents the tiles that appear in the opening screen (it's `10x7` tiles). 45 | The normal levels appear in an array of `1280 bytes` per element, each containing: 46 | 1. Path data (`256 bytes`) - data about paths that monsters can take. While interesting, not relevant to our scope. 47 | 2. Tiles (`1000 bytes`) - the `100x10` level information, each byte references a tile. 48 | 3. Padding (`24 bytes`) - unused. 49 | 50 | There is another place where the initial state for each level is saved (again in a `10`-element array), which includes the `starting X position`, `starting Y position` and the `initial motion` (which can be `stationary` or `falling`). 51 | 52 | It's interesting to see that levels contain the `warp zones` tiles in them, they are not special levels at all! 53 | The metadata for `warp zones` is saved in a different offset in the file, alongside two constants that are shared for all warp zones: 54 | 1. `The starting Y position for warp zones` is constant. That makes sense since all warp zones display an animation of Dave falling in a some sort of "tube". 55 | 2. `The motion flags for warp zones` is constant. That motion flag has realistically two options: `falling` and `stationary`. Since Dave is `falling` in all warp zones, it's a global variable. 56 | 3. `Starting X coordinate` for each warp level is saved in an array of `10` elements. 57 | 4. `Horizontal shift` for each level is saved in an array of `10` elements. 58 | 59 | The `horizontal shift` works with the `starting X coordinate` to position Dave at the right position in the `warp zone.` 60 | It's interesting to note that there are only `4 warp zones` - in levels `5`, `8`, `9` and `10`. All other levels have all those other values as `0`. 61 | At that point I wanted to take a better look and coded my own parser, which I uploaded to this repository. 62 | It expects the unpacked `DAVE.EXE` and parses it. I believe I found some slight errors in the modding community descriptions (for example, the `warp zone` X coordinate is in tiles and the horizontal shift is in pixels), but I was able to work around them. 63 | 64 | ``` 65 | ./dave_parse.py 66 | 67 | ... 68 | 69 | Level 8 (start at (2, 8) stationary): 70 | 71 | X-------------|-----------------|# ################################################################ 72 | X | * J | $ $ W W * T ## 73 | X Q| -- | OOO ### 74 | X # #------|G -- - - ######### ### W W = = OOO #### 75 | X | - ####oo####XXXXXXXXXX\W | $## 76 | X # # | * ####XXX/ \XXXX\W ====== ======= | ## 77 | X _ |* XXX - - XXX* MX&JXW * | ## 78 | X # X X XX/ XX * * X XXXX # # $ # | ## 79 | X>___ ____ ___ _XX XXXXX* * * W JXXX | ### 80 | XXXXXoXXXXoooXXXoXXXWWWWWWWWWWWWWW WXXXXXXXXXXXXXXXXXXXXXXXXX####WWWWW#####WWWWW######WWWWW######## 81 | 82 | 83 | Loaded 11 levels. 84 | Choose a level to view (zero-based) or 'Q' to quit: q 85 | Quitting. 86 | ``` 87 | (yes, that thing on the right is a tree) 88 | 89 | ## Transitioning to warp levels 90 | Figuring out the variable that maintains the current level number was pretty easy. I was looking at the strings and found the following: 91 | 92 | ``` 93 | congratulations! 94 | you made it through all the peril- 95 | ous areas in clyde's hideout! 96 | very good work! did you find 97 | the 4 warp zones? they are located 98 | on levels 5,8,9 and 10. just jump 99 | off the top of the screen at the 100 | extreme left or right edge of the 101 | world and voila! you're there! 102 | ``` 103 | 104 | Looking at references for those strings in IDA was easy too after some minor prettifying: 105 | ```c 106 | PresentFreeText(" congratulations!"); 107 | PresentFreeText("you made it through all the peril-"); 108 | PresentFreeText("ous areas in clyde's hideout!"); 109 | PresentFreeText("very good work! did you find"); 110 | PresentFreeText("the 4 warp zones? they are located"); 111 | PresentFreeText("on levels 5,8,9 and 10. just jump"); 112 | PresentFreeText("off the top of the screen at the"); 113 | PresentFreeText("extreme left or right edge of the"); 114 | PresentFreeText("world and voila! you're there!"); 115 | ``` 116 | 117 | Of course, this is a further testament that level 6 should not have a `warp zone`... 118 | But most importantly, it gave me the function that runs when a level is completed. Quickly I found this: 119 | 120 | ```c 121 | sub_1749B(); 122 | word_56F4 = word_56F4 + 1; 123 | sub_10BFB(); 124 | if (word_573C == 1) { 125 | word_573C = 0; 126 | word_56F4 = word_6152; 127 | } 128 | if ((int)word_56F4 < 10) { 129 | sub_10BFB(); 130 | sub_14C69(); 131 | sub_17631(); 132 | sub_18CDA(); 133 | sub_14B9C(); 134 | sub_13235(); 135 | } 136 | ``` 137 | 138 | - The comparison to `10` and its increament clearly indicated that `word_56F4` is the current level number. 139 | - Following the code flow a bit, `word_573C` seems to maintain whether we're in a warp zone or not! 140 | - Note that if we just finished a warp zone (`word_573C == 1`) then the current level is restored - `word_56F4 = word_6152`. This means `word_6152` saves the level Dave should warp back into. That makes sense because after level 5, for instance, Dave warps to the warp zone at level 2, so level 5 is going to be saved in that memory address. 141 | 142 | At this point I decided to validate all these learnings. While it was my first time of using [the DosBox debugger](https://www.dosbox.com), I must say it was very intuitive and surprisigly efficient! 143 | ![DosBox debugger](debugger.png) 144 | 145 | From that point reverse-engineering became easier - I had to look for *assignments* to `word_6152` (the level backup). That should happen when moving to warp zones, and luckily there was only one such writing reference: 146 | 147 | ```c 148 | g_curr_warp_zone_mapping = g_current_level; 149 | var2 = *(word *)(g_current_level * 2 + 0x192); 150 | var3 = *(word *)(g_current_level * 2 + 0x1a6); 151 | g_current_level = *(int *)(g_current_level * 2 + 0x16a) - 1; 152 | sub_14C69(); 153 | g_start_y = 0x10; 154 | ``` 155 | 156 | - Here we can see how the current level is backed-up for coming back from the warp zone. 157 | - The current level number is mapped from `0x16A + (2*current_level)-1`. 158 | - Perhaps not surprisingly, `g_start_y = 0x10` is the constant they mention at the modding community for the warp zone starting Y position. When I originally read the description, I assumed this was in a data segment, but it's just a constant baked into the code (probably originally in a `#define`). 159 | 160 | The calculation for loading the current level is loaded from address `0x2583a` (plus twice the current level, minus one). Indeed we can see this easily: 161 | 162 | ```python 163 | import struct 164 | struct.unpack('<10H', open('DAVE.EXE', 'rb').read()[0x2583a:0x2583a+20]) 165 | ``` 166 | 167 | This yields `(0, 0, 0, 0, 2, 0, 0, 6, 7, 1)` - we can clearly see the warp zones at 1-based indexes 5, 8, 9 and 10. Success! 168 | 169 | ## Level 6 170 | When level `5`, for example, is loaded, `2` is returned, and the tiles from that level are processed, which are really element `1` in the 0-based levels array. 171 | Thanks to the modding community, we already know the tiles start at offset `0x26E0A` (with each element being `0x500` bytes long). 172 | Well, let's imagine what happens to level `6`... The returned number is `0`, which will be index `-1`, which is really `0xFFFF`... 173 | Interestingly, that `sub_14C69` seems to be exactly the level loader: 174 | 175 | ```c 176 | Starty = *(word *)(g_current_level * 2 + 0x136); 177 | ... 178 | src = word3687C + g_current_level * 0x500; 179 | ``` 180 | 181 | Well, that multipication by `0x500` is a clear indication that the base address (loaded from `word3687C`) should be the base of the levels. 182 | Indeed by debugging I see it maps back to the file offset `0x26E0A`. So, marking `0x26E0A` as `g_levels` and with each element taking `0x500` bytes, we simply get: 183 | 184 | ```c 185 | src = g_levels[g_current_level]; 186 | ``` 187 | 188 | Well, if `g_current_level` is `0xFFFF` then multiplying it by `0x500` gives `0xFB00` because of the 16-bit wrap-around: 189 | 190 | ```python 191 | hex((0xFFFF * 0x500) & 0xFFFF) 192 | ``` 193 | 194 | Therefore, the warp-level tiles for level 6 are loaded from address `0x2932B`, and indeed: 195 | 196 | ```python 197 | import binascii 198 | print(binascii.hexlify(open('DAVE.EXE', 'rb').read()[0x2932B:0x2932B+0x20], ' ')) 199 | ``` 200 | 201 | This yields `00 00 00 2b 23 2c 00 00 02 05 05 01 00 05 00 00 00 00 05 1e 1e 1e 1f 1e 1e 1d 14 00 00 1f 1e 00` which corresponds to the buggy tiles we see. 202 | 203 | By the way, I've added the buggy level representation to the parser I coded, here's how it looks like: 204 | 205 | ``` 206 | Buggy warp level: 207 | 208 | ##XXX/ \XXXX\W ====== ======= | ##X _ |* XXX - - XXX* 209 | MX&JXW * | ##X # X X XX/ XX * * 210 | X XXXX # # $ # | ##X>___ ____ ___ _XX XXXXX* * 211 | * W JXXX | ###XXXXXoXXXXoooXXXoXXXWWWWWWWWWWWWWW WXXXXXXXX 212 | XXXXXXXXXXXXXXXXX####WWWWW#####WWWWW######WWWWW########?M_ ? ?T? ??? Xg]??M| W WXW?W#W?W#W?W#W????=? 213 | ??#???#??? ?? 3???????? ????? ???? ?????????? ????????????????????????????????????????????????????? 214 | ?????????????? ?????????????????????????????????????????????????????????? ????????????? L?O ? ?_!? 215 | WWW|||----g ??????????????LT|-----V------------------V--------------------- 216 | $ | |* * M Q $$ $ - 217 | | | * * $ -- 218 | ``` 219 | 220 | See all the `?` symbols? Those are out-of-bounds tiles (which explains the weird buggy-looking tiles at the bottom of that level). 221 | 222 | ## Further mysteries 223 | After the blogpost was published, I got some follow-up questions: 224 | - Apparently, if you go out-of-bounds on the *right* side of the screen in level 6, you get to the `warp zone` from level 8. Why does that happen? 225 | - If you get a score greater than `99,999`, pressing `ESC` to go to the menu increases your life every time you pick something up. Neat! 226 | 227 | Well, the first one is easy - since the `warp zone` from level 8 is "baked" into level 6, the out-of-bounds simply loads the right side of the level, so you never truly stepped into a `warp zone`. The game does not even consider it out-of-bounds! 228 | 229 | The 2nd question requires further explanation. Armed with all the knowledge I had, I found an initialization function at offset `0x535a` from the file. Some parts of it made it quite clear (with some simple variable renaming): 230 | 231 | ```c 232 | g_lives = 3; 233 | g_score_lo = 0; 234 | g_score_hi = 0; 235 | g_next_goal_lo = 0; 236 | g_next_goal_hi = 0; 237 | g_is_game_over = 0; 238 | g_current_level = 0; 239 | g_some_level_reference = 0; 240 | g_maybe_levels_left = 10; 241 | g_is_warp_zone = 0; 242 | ``` 243 | 244 | Debugging with the the DosBox debugger helped clarify everything and helped me a lot with the renaming. 245 | The score is composed of an entire `32`-bit value (implemented as two halves - each `16` bit). 246 | Now that I know where the number of lives is saved, reverse-engineering is pretty quick - I suspected there is no memory corruption issue this time but just a logic bug, perhaps. 247 | The decompiler shows something that looks quite interesting: 248 | 249 | ```c 250 | if ((g_score_hi - g_next_goal_hi != (uint)(g_score_lo < g_next_goal_lo)) || (0x4e20 < g_score_lo - g_next_goal_lo)) { 251 | // ... 252 | g_next_goal_lo = g_score_lo; 253 | if ((int)g_lives < 3) { 254 | UpdateSprite(g_lives * 0x10 + 0x100,0,*(int *)(g_some_base * g_some_offset * 2 + 0x216)); 255 | g_lives = g_lives + 1; 256 | PlaySound(0xc); 257 | } 258 | } 259 | } 260 | 261 | // ... 262 | if ((g_score_hi != 0) && ((1 < g_score_hi || (0x869f < g_score_lo)))) { 263 | g_score_lo = 0x869f; 264 | g_score_hi = 1; 265 | } 266 | ``` 267 | 268 | Some insights: 269 | 1. The score is split to two `16`-bit values as I mentioned - `g_score_hi` and `g_score_lo`. Same thing for the next goal for more lives. 270 | 2. The condition to try to increase the number of lives is that either the difference between low parts of the next goal and the current score are bigger than `0x4e20` (which is `20000` decimal) *or this condition*: `g_score_hi - g_next_goal_hi != (uint)(g_score_lo < g_next_goal_lo`. 271 | 3. The maximum number of lives is `3` as can be seen by the check that increases the number of lives. 272 | 4. The end shows how the total score cannot exceed `0x1869f`, which is `99999` decimal. 273 | 274 | Well, from debugging I can tell that after a score of `99999`, the next goal is `100000`, which means: 275 | - `g_next_goal_hi` is `1`. 276 | - `g_next_goal_lo` is `0x86a0`. 277 | - `g_score_lo` is `0x869f`. 278 | - `g_score_hi` is `1`. 279 | 280 | This means the condition I highlighted is fulfilled, always: 281 | 1. `g_score_hi - g_next_goal_hi` is evaluated to `0`. 282 | 2. `(uint)(g_score_lo < g_next_goal_lo)` is evaluated to `1`. 283 | 284 | Why does that happen when coming back from the menu? Well, it appears there are several conditions for triggering that function. 285 | Most of the time that function is triggered by a collision with an object (which makes sense - think of a collision with a diamond) but also by returning from the menu, apparently, probably to redraw the score. 286 | 287 | ## Tooling 288 | After a long time, I've added some better tooling and nice colors for easy usage! 289 | Run [dave_parse.py](dave_parse.py) as the level editor: 290 | ![Tool](tool.png) 291 | 292 | ## Summary 293 | Of course that after all of that I had to add some funny changes, my parser is also capable of editing levels and text: 294 | ![Modified](dave_hax.png) 295 | 296 | This has been an unusual weekend project for me, I am very grateful for the [shikadi.net](https://moddingwiki.shikadi.net/wiki/Dangerous_Dave) modding community. 297 | Even though this kind of reverse-engineering is less and less common in real world scenario, I still think it's interesting. 298 | I appreciate old DOS games and how they cleverly implemented games in so little disk storage space and memory space - I see that as a lost art! 299 | 300 | Thank you, 301 | 302 | Jonathan Bar Or 303 | -------------------------------------------------------------------------------- /dave_parse.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import struct 3 | import os 4 | import colorama 5 | import base64 6 | 7 | # Fine-tunables 8 | NORMAL_LEVELS_OFFSET = 0x26E0A 9 | NORMAL_LEVELS_SIZE = 0x500 10 | NORMAL_LEVELS_NUM = 10 11 | NORMAL_LEVELS_INIT_STATE_OFFSET = 0x257E8 12 | SPECIAL_LEVEL_OFFSET = 0x25EA4 13 | SPECIAL_LEVEL_SIZE = 70 14 | BUGGY_LEVEL_OFFSET = 0x2932B 15 | BUGGY_LEVEL_SIZE = 0x500 16 | BUGGY_LEVEL_ID = 6 17 | WARP_ZONE_LEVELS_DATA_OFFSET = 0x25862 18 | WARP_ZONE_START_Y_OFFSET = 0x1710 19 | WARP_ZONE_MOTION_FLAGS_OFFSET = 0x1716 20 | WARP_ZONE_LEVEL_MAPPING_OFFSET = 0x2583A 21 | MOTION_FLAG_MAPPINGS = { 0x24: 'stationary', 0x28: 'falling' } 22 | PIXELS_PER_TILE = 16 23 | FILENAME = 'DAVE.EXE' 24 | TITLE_OFFSET = 0x2643F 25 | TITLE_SIZE = 14 26 | SUBTITLE_OFFSET = 0x26451 27 | SUBTITLE_SIZE = 23 28 | 29 | # Initialize colors 30 | colorama.init() 31 | RESET_COLORS = colorama.Style.RESET_ALL 32 | DIM = colorama.Style.DIM 33 | BRIGHT = colorama.Style.BRIGHT 34 | BLUE_FORE = colorama.Fore.BLUE 35 | BLUE_BACK = colorama.Back.BLUE 36 | RED_BACK = colorama.Back.RED 37 | RED_FORE = colorama.Fore.RED 38 | CYAN_FORE = colorama.Fore.CYAN 39 | CYAN_BACK = colorama.Back.CYAN 40 | GREEN_FORE = colorama.Fore.GREEN 41 | GREEN_BACK = colorama.Back.GREEN 42 | YELLOW_FORE = colorama.Fore.YELLOW 43 | YELLOW_BACK = colorama.Back.YELLOW 44 | MAGENTA_FORE = colorama.Fore.MAGENTA 45 | MAGENTA_BACK = colorama.Back.MAGENTA 46 | WHITE_FORE = colorama.Fore.WHITE 47 | WHITE_BACK = colorama.Back.WHITE 48 | BLACK_FORE = colorama.Fore.BLACK 49 | BLACK_BACK = colorama.Back.BLACK 50 | 51 | # Logo 52 | LOGO = base64.b64decode(b'ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAbWzMxbV9fLC0tfi0tLl8bWzBtCiAgICAbWzM3bRtbMW3ilojilojilojilojilojilojilZcgIOKWiOKWiOKWiOKWiOKWiOKVlyDilojilojilZcgICDilojilojilZfilojilojilojilojilojilojilojilZcgICAg4paI4paI4paI4paI4paI4paI4paI4pWX4paI4paI4paI4paI4paI4paI4pWXIOKWiOKWiOKVl+KWiOKWiOKWiOKWiOKWiOKWiOKWiOKWiOKVlyDilojilojilojilojilojilojilZcg4paI4paI4paI4paI4paI4paI4pWXG1swbSAgICAgICAgICAgICAbWzMxbSwtOzs7OyAgIGA7OztgXBtbMG0KICAgIBtbMzdtG1sxbeKWiOKWiOKVlOKVkOKVkOKWiOKWiOKVl+KWiOKWiOKVlOKVkOKVkOKWiOKWiOKVl+KWiOKWiOKVkSAgIOKWiOKWiOKVkeKWiOKWiOKVlOKVkOKVkOKVkOKVkOKVnSAgICDilojilojilZTilZDilZDilZDilZDilZ3ilojilojilZTilZDilZDilojilojilZfilojilojilZHilZrilZDilZDilojilojilZTilZDilZDilZ3ilojilojilZTilZDilZDilZDilojilojilZfilojilojilZTilZDilZDilojilojilZcbWzBtICAgICAgICAgICAbWzMxbS87Ozs7OyAgICAgIDs7OztgXBtbMG0KICAgIBtbMzdtG1sxbeKWiOKWiOKVkSAg4paI4paI4pWR4paI4paI4paI4paI4paI4paI4paI4pWR4paI4paI4pWRICAg4paI4paI4pWR4paI4paI4paI4paI4paI4pWXICAgICAg4paI4paI4paI4paI4paI4pWXICDilojilojilZEgIOKWiOKWiOKVkeKWiOKWiOKVkSAgIOKWiOKWiOKVkSAgIOKWiOKWiOKVkSAgIOKWiOKWiOKVkeKWiOKWiOKWiOKWiOKWiOKWiOKVlOKVnRtbMG0gICAgICAgICAbWzMxbS87Ozs7OzsgICAgIF9fXzs7XzsgXBtbMG0KICAgIBtbMzdtG1sxbeKWiOKWiOKVkSAg4paI4paI4pWR4paI4paI4pWU4pWQ4pWQ4paI4paI4pWR4pWa4paI4paI4pWXIOKWiOKWiOKVlOKVneKWiOKWiOKVlOKVkOKVkOKVnSAgICAgIOKWiOKWiOKVlOKVkOKVkOKVnSAg4paI4paI4pWRICDilojilojilZHilojilojilZEgICDilojilojilZEgICDilojilojilZEgICDilojilojilZHilojilojilZTilZDilZDilojilojilZcbWzBtICAgICAgICAbWzMxbS87Ozs7OzsgICAgICBcXyB8O3w7OyB8G1swbQogICAgG1szN20bWzFt4paI4paI4paI4paI4paI4paI4pWU4pWd4paI4paI4pWRICDilojilojilZEg4pWa4paI4paI4paI4paI4pWU4pWdIOKWiOKWiOKWiOKWiOKWiOKWiOKWiOKVlyAgICDilojilojilojilojilojilojilojilZfilojilojilojilojilojilojilZTilZ3ilojilojilZEgICDilojilojilZEgICDilZrilojilojilojilojilojilojilZTilZ3ilojilojilZEgIOKWiOKWiOKVkRtbMG0gICAgICAgG1szMW0bWzFtKDs7Ozs7OyAgICAgICBfX3x8X3w7Ozt8G1swbSAgIAogICAgG1szN20bWzFt4pWa4pWQ4pWQ4pWQ4pWQ4pWQ4pWdIOKVmuKVkOKVnSAg4pWa4pWQ4pWdICDilZrilZDilZDilZDilZ0gIOKVmuKVkOKVkOKVkOKVkOKVkOKVkOKVnSAgICDilZrilZDilZDilZDilZDilZDilZDilZ3ilZrilZDilZDilZDilZDilZDilZ0g4pWa4pWQ4pWdICAg4pWa4pWQ4pWdICAgIOKVmuKVkOKVkOKVkOKVkOKVkOKVnSDilZrilZDilZ0gIOKVmuKVkOKVnRtbMG0gICAgICAgIBtbMzFtG1sxbWBcXyw7OyAgICAgICAgICAgICA7XyxgXBtbMG0KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIBtbMzFtG1sxbWBcX19fLC0tLS0tJ35+fn5+ICAgIGBcG1swbQogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgG1szMW0bWzFtXCAgICAgICAgICAgICAgICAgICApG1swbQogICAgICAgICAgICAbWzMzbURhbmdlcm91cyBEYXZlICgxOTkwKSBlZGl0b3IgYnkgSm9uYXRoYW4gQmFyIE9yICgiSkJPIikbWzBtICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIBtbMzFtG1sxbWBcLl9fICAgICAgICAgICAgIC8nG1swbQogICAgICAgICAgICAgICAgICAgICAgICAbWzMzbWh0dHBzOi8veW8teW8teW8tamJvLmdpdGh1Yi5pbxtbMG0gICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgG1szMW0bWzFtYH5+fn4tLS0tLS1+fhtbMG0KCg==').decode() 53 | 54 | # Tile mappings 55 | TILES = [('empty', ' '), 56 | ('crack1', f'{RED_FORE}{DIM}▚{RESET_COLORS}'), 57 | ('door', f'{RED_FORE}#{RESET_COLORS}'), 58 | ('girder_block', '▆'), 59 | ('jetpack', f'{GREEN_FORE}#{RESET_COLORS}'), 60 | ('bluewall', f'{BLUE_BACK} {RESET_COLORS}'), 61 | ('fire1', f'{RED_FORE}W{RESET_COLORS}'), 62 | ('fire2', f'{RED_FORE}W{RESET_COLORS}'), 63 | ('fire3', f'{RED_FORE}W{RESET_COLORS}'), 64 | ('fire4', f'{RED_FORE}W{RESET_COLORS}'), 65 | ('trophy1', f'{YELLOW_FORE}{BRIGHT}T{RESET_COLORS}'), 66 | ('trophy2', f'{YELLOW_FORE}{BRIGHT}T{RESET_COLORS}'), 67 | ('trophy3', f'{YELLOW_FORE}{BRIGHT}T{RESET_COLORS}'), 68 | ('trophy4', f'{YELLOW_FORE}{BRIGHT}T{RESET_COLORS}'), 69 | ('trophy5', f'{YELLOW_FORE}{BRIGHT}T{RESET_COLORS}'), 70 | ('pipe_horiz', f'{WHITE_FORE}{BRIGHT}{DIM}╣{RESET_COLORS}'), 71 | ('pipe_vert', f'{WHITE_FORE}{BRIGHT}{DIM}╩{RESET_COLORS}'), 72 | ('redwall', f'{RED_BACK} {RESET_COLORS}'), 73 | ('crack2', f'{RED_FORE}{DIM}▚{RESET_COLORS}'), 74 | ('bluetile', f'{BLUE_BACK}{CYAN_FORE}▚{RESET_COLORS}'), 75 | ('gun', '╤'), 76 | ('diag1', f'{RED_FORE}{DIM}▙{RESET_COLORS}'), 77 | ('diag2', f'{RED_FORE}{DIM}▟{RESET_COLORS}'), 78 | ('diag3', f'{RED_FORE}{DIM}▜{RESET_COLORS}'), 79 | ('diag4', f'{RED_FORE}{DIM}▛{RESET_COLORS}'), 80 | ('tent1', f'{MAGENTA_FORE}|{RESET_COLORS}'), 81 | ('tent2', f'{MAGENTA_FORE}|{RESET_COLORS}'), 82 | ('tent3', f'{MAGENTA_FORE}|{RESET_COLORS}'), 83 | ('tent4', f'{MAGENTA_FORE}|{RESET_COLORS}'), 84 | ('girder_vert', f'{MAGENTA_FORE}{DIM}▐{RESET_COLORS}'), 85 | ('girder_horiz1', f'{MAGENTA_FORE}{DIM}▄{RESET_COLORS}'), 86 | ('girder_horiz2', f'{MAGENTA_FORE}{DIM}▄{RESET_COLORS}'), 87 | ('low_grass', f'{GREEN_FORE}_{RESET_COLORS}'), 88 | ('trunk', f'{RED_FORE}|{RESET_COLORS}'), 89 | ('branch1', f'{GREEN_FORE}O{RESET_COLORS}'), 90 | ('branch2', f'{GREEN_FORE}O{RESET_COLORS}'), 91 | ('water1', f'{CYAN_BACK}{BLUE_FORE}▓{RESET_COLORS}'), 92 | ('water2', f'{CYAN_BACK}{BLUE_FORE}▓{RESET_COLORS}'), 93 | ('water3', f'{CYAN_BACK}{BLUE_FORE}▓{RESET_COLORS}'), 94 | ('water4', f'{CYAN_BACK}{BLUE_FORE}▓{RESET_COLORS}'), 95 | ('water5', f'{CYAN_BACK}{BLUE_FORE}▓{RESET_COLORS}'), 96 | ('stars', f'{WHITE_FORE}{BRIGHT}.{RESET_COLORS}'), 97 | ('moon', f'{WHITE_FORE}{BRIGHT}<{RESET_COLORS}'), 98 | ('branch3', f'{GREEN_FORE}O{RESET_COLORS}'), 99 | ('branch4', f'{GREEN_FORE}O{RESET_COLORS}'), 100 | ('branch5', f'{GREEN_FORE}O{RESET_COLORS}'), 101 | ('branch6', f'{GREEN_FORE}O{RESET_COLORS}'), 102 | ('diamond_blue', f'{CYAN_FORE}{BRIGHT}*{RESET_COLORS}'), 103 | ('purple_dot', f'{MAGENTA_FORE}*{RESET_COLORS}'), 104 | ('diamond_red', f'{RED_FORE}{BRIGHT}*{RESET_COLORS}'), 105 | ('crown', f'{YELLOW_FORE}{BRIGHT}M{RESET_COLORS}'), 106 | ('ring', f'{YELLOW_FORE}{BRIGHT}O{RESET_COLORS}'), 107 | ('septer', f'{GREEN_FORE}/{RESET_COLORS}'), 108 | ('dave1', f'{RED_FORE}{DIM}D{RESET_COLORS}'), 109 | ('dave2', f'{RED_FORE}{DIM}D{RESET_COLORS}'), 110 | ('dave3', f'{RED_FORE}{DIM}D{RESET_COLORS}'), 111 | ('dave4', f'{RED_FORE}{DIM}D{RESET_COLORS}'), 112 | ('dave5', f'{RED_FORE}{DIM}D{RESET_COLORS}'), 113 | ('dave6', f'{RED_FORE}{DIM}D{RESET_COLORS}'), 114 | ('dave7', f'{RED_FORE}{DIM}D{RESET_COLORS}'), 115 | ('shadow1', ' '), 116 | ('shadow2', ' '), 117 | ('shadow3', ' '), 118 | ('shadow4', ' '), 119 | ('shadow5', ' '), 120 | ('shadow6', ' '), 121 | ('shadow7', ' '), 122 | ('jump_right', f'{RED_FORE}{DIM}D{RESET_COLORS}'), 123 | ('jump_left', f'{RED_FORE}{DIM}D{RESET_COLORS}'), 124 | ('shadow_right', ' '), 125 | ('shadow_left', ' '), 126 | ('climb1', f'{RED_FORE}{DIM}D{RESET_COLORS}'), 127 | ('climb2', f'{RED_FORE}{DIM}D{RESET_COLORS}'), 128 | ('climb3', f'{RED_FORE}{DIM}D{RESET_COLORS}'), 129 | ('shadow_climb1', ' '), 130 | ('shadow_climb2', ' '), 131 | ('shadow_climb3', ' '), 132 | ('jetpack_right1', f'{RED_FORE}{DIM}{GREEN_BACK}D{RESET_COLORS}'), 133 | ('jetpack_right2', f'{RED_FORE}{DIM}{GREEN_BACK}D{RESET_COLORS}'), 134 | ('jetpack_right3', f'{RED_FORE}{DIM}{GREEN_BACK}D{RESET_COLORS}'), 135 | ('jetpack_left1', f'{RED_FORE}{DIM}{GREEN_BACK}D{RESET_COLORS}'), 136 | ('jetpack_left2', f'{RED_FORE}{DIM}{GREEN_BACK}D{RESET_COLORS}'), 137 | ('jetpack_left3', f'{RED_FORE}{DIM}{GREEN_BACK}D{RESET_COLORS}'), 138 | ('jetpack_shadow_right1', ' '), 139 | ('jetpack_shadow_right2', ' '), 140 | ('jetpack_shadow_right3', ' '), 141 | ('jetpack_shadow_left1', ' '), 142 | ('jetpack_shadow_left2', ' '), 143 | ('jetpack_shadow_left3', ' '), 144 | ('spider1', 'S'), 145 | ('spider2', 'S'), 146 | ('spider3', 'S'), 147 | ('spider4', 'S'), 148 | ('shuriken1', '@'), 149 | ('shuriken2', '@'), 150 | ('shuriken3', '@'), 151 | ('shuriken4', '@'), 152 | ('lili1', 'L'), 153 | ('lili2', 'L'), 154 | ('lili3', 'L'), 155 | ('lili4', 'L'), 156 | ('stick1', 'F'), 157 | ('stick2', 'F'), 158 | ('stick3', 'F'), 159 | ('stick4', 'F'), 160 | ('ufo1', 'v'), 161 | ('ufo2', 'v'), 162 | ('ufo3', 'v'), 163 | ('ufo4' ,'v'), 164 | ('burger1', 'b'), 165 | ('burget2', 'b'), 166 | ('burget3', 'b'), 167 | ('burget4', 'b'), 168 | ('green_ball1', f'{GREEN_FORE}{DIM}O{RESET_COLORS}'), 169 | ('green_ball2', f'{GREEN_FORE}{DIM}O{RESET_COLORS}'), 170 | ('green_ball3', f'{GREEN_FORE}{DIM}O{RESET_COLORS}'), 171 | ('green_ball4', f'{GREEN_FORE}{DIM}O{RESET_COLORS}'), 172 | ('saucer1', 's'), 173 | ('saucer2', 's'), 174 | ('saucer3', 's'), 175 | ('saucer4', 's'), 176 | ('shot_right1', ']'), 177 | ('shot_right2', ']'), 178 | ('shot_right3', ']'), 179 | ('shot_left1', '['), 180 | ('shot_left2', '['), 181 | ('shot_left3', '['), 182 | ('bullet_right', '>'), 183 | ('bullet_left', '<'), 184 | ('explode1', '!'), 185 | ('explode2', '!'), 186 | ('explode3', '!'), 187 | ('explode4', '!'), 188 | ('label_jetpack', ' '), 189 | ('label_gun', ' '), 190 | ('label_lives', ' '), 191 | ('label_level', ' '), 192 | ('label_score', ' '), 193 | ('label_go', ' '), 194 | ('label_warp', ' '), 195 | ('label_zone', ' '), 196 | ('frame', ' '), 197 | ('red_right', ' '), 198 | ('lives_face', 'L'), 199 | ('intro1', ' '), 200 | ('intro2', ' '), 201 | ('intro3', ' '), 202 | ('intro4', ' '), 203 | ('num0', '0'), 204 | ('num1', '1'), 205 | ('num2', '2'), 206 | ('num3', '3'), 207 | ('num4', '4'), 208 | ('num5', '5'), 209 | ('num6', '6'), 210 | ('num7', '7'), 211 | ('num8', '8'), 212 | ('num9', '9') ] 213 | 214 | def clear_screen(): 215 | """ 216 | Clears the screen. 217 | """ 218 | 219 | # Act based on OS 220 | if os.name == 'nt': 221 | os.system('cls') 222 | else: 223 | os.system('clear') 224 | 225 | # Print logo 226 | print(f'{WHITE_FORE}{BRIGHT}{LOGO}{RESET_COLORS}') 227 | 228 | def pixel_to_tile_coord_x(p): 229 | """ 230 | Translates pixel numbers to a tile X cooredinate. 231 | """ 232 | 233 | # Return the result 234 | return p // PIXELS_PER_TILE 235 | 236 | def pixel_to_tile_coord_y(p): 237 | """ 238 | Translates pixel numbers to a tile Y cooredinate. 239 | """ 240 | 241 | # Return the result 242 | return (p // PIXELS_PER_TILE) - 1 243 | 244 | class WarpZoneInfo(object): 245 | """ 246 | Represents Warp Zone information. 247 | """ 248 | 249 | @staticmethod 250 | def parse(bin_bytes): 251 | """ 252 | Parses all the warp zones from the binary bytes. 253 | Returns None for levels that do not have warp-zones. 254 | """ 255 | 256 | # Extract per-level warp zone data 257 | warp_zone_level_data_fmt = '<%dH%dH' % (NORMAL_LEVELS_NUM, NORMAL_LEVELS_NUM) 258 | warp_zone_level_data = struct.unpack(warp_zone_level_data_fmt, bin_bytes[WARP_ZONE_LEVELS_DATA_OFFSET:WARP_ZONE_LEVELS_DATA_OFFSET + struct.calcsize(warp_zone_level_data_fmt)]) 259 | 260 | # Extract the global data for warp zones 261 | warp_zone_start_y = pixel_to_tile_coord_y(struct.unpack('= 0 and int(num_level) < num_levels: 390 | return int(num_level) 391 | else: 392 | raise Exception('Invalid level number: %s.' % (num_level,)) 393 | 394 | def get_coord(coord_type, max_num): 395 | """ 396 | Gets a coordinate. 397 | """ 398 | 399 | # Get input 400 | coord = input('Enter %s coordinate (0-%d): ' % (coord_type, max_num - 1)) 401 | if coord.isdigit() and int(coord) >= 0 and int(coord) < max_num: 402 | return int(coord) 403 | else: 404 | raise Exception('Invalid %s coordinate: %s.' % (coord_type, coord)) 405 | 406 | def main(): 407 | """ 408 | Main functionality. 409 | """ 410 | 411 | # Handle critical errors 412 | try: 413 | 414 | # Parse DAVE.EXE 415 | with open(FILENAME, 'rb') as f: 416 | bin_bytes = f.read() 417 | 418 | # Parse levels 419 | levels = Level.parse(bin_bytes) 420 | if len(levels) == 0: 421 | raise Exception('Error parsing levels.') 422 | 423 | # Extract title and subtitle 424 | titles = [ bin_bytes[TITLE_OFFSET:TITLE_OFFSET + TITLE_SIZE].decode(), bin_bytes[SUBTITLE_OFFSET:SUBTITLE_OFFSET + SUBTITLE_SIZE].decode() ] 425 | 426 | except Exception as ex: 427 | print('Fatal error: %s' % (ex,)) 428 | return 429 | 430 | # Handle menu 431 | clear_screen() 432 | saved = True 433 | while True: 434 | 435 | # Handle all exceptions 436 | try: 437 | 438 | # Present header 439 | print(f'Loaded {BLUE_FORE}{BRIGHT}{len(levels)}{RESET_COLORS} levels.') 440 | print(f'Current intro title: {BLUE_FORE}{BRIGHT}"{titles[0]}"{RESET_COLORS}.') 441 | print(f'Current intro subtitle: {BLUE_FORE}{BRIGHT}"{titles[1]}"{RESET_COLORS}.') 442 | if not saved: 443 | print(f'You have {RED_FORE}UNSAVED{RESET_COLORS} edits.\n') 444 | print(f'\n{YELLOW_FORE}== MENU =={RESET_COLORS}\n\t[{YELLOW_FORE}V{RESET_COLORS}]iew a level.\n\t[{YELLOW_FORE}E{RESET_COLORS}]dit a level.\n\tEdit intro [{YELLOW_FORE}T{RESET_COLORS}]itle.\n\tEdit intro su[{YELLOW_FORE}B{RESET_COLORS}]title.\n\t[{YELLOW_FORE}S{RESET_COLORS}]ave pending changes.\n\t[{YELLOW_FORE}Q{RESET_COLORS}]uit without saving.') 445 | choice = input('> ').upper() 446 | 447 | # Handle title or subtitle changes 448 | if choice == 'T' or choice == 'B': 449 | title_index = 0 if choice == 'T' else 1 450 | print('Current intro %s: "%s".' % ('title' if choice == 'T' else 'subtitle', titles[title_index])) 451 | new_text = input('New %s (AT MOST %d characters): ' % ('title' if choice == 'T' else 'subtitle', len(titles[title_index]))) 452 | if len(new_text) > len(titles[title_index]): 453 | raise Exception('Length of new %s is too big (max=%d).' % ('title' if choice == 'T' else 'subtitle', len(titles[title_index]))) 454 | spaces = len(titles[title_index]) - len(new_text) 455 | for i in range(spaces // 2): 456 | new_text = ' ' + new_text + ' ' 457 | if spaces % 2 == 1: 458 | new_text += ' ' 459 | titles[title_index] = new_text 460 | saved = False 461 | clear_screen() 462 | print('Changed %s successfully.' % ('title' if choice == 'T' else 'subtitle',)) 463 | continue 464 | 465 | # Handle edit\view 466 | if choice == 'V' or choice == 'E': 467 | level_num = choose_level(len(levels)) 468 | clear_screen() 469 | print(levels[int(level_num)]) 470 | print('\n\n') 471 | if choice == 'E': 472 | x_coord = get_coord('X', levels[level_num].width) 473 | y_coord = get_coord('Y', levels[level_num].height) 474 | tile_index = levels[level_num].tiles[y_coord * levels[level_num].height+ x_coord] 475 | if tile_index >= len(TILES): 476 | raise Exception('Cannot edit unknown tile for level %d at position (%d, %d).' % (level_num, x_coord, y_coord)) 477 | print('Current tile is %s.' % (TILES[tile_index][0],)) 478 | print('Available tile types: %s' % (', '.join( [tile[0] for tile in TILES ]))) 479 | new_tile = input('New tile type: ') 480 | matched_tiles = [ tile_index for tile_index in range(len(TILES)) if TILES[tile_index][0] == new_tile ] 481 | if len(matched_tiles) != 1: 482 | raise Exception('Invalid tile type: %s.' % (new_tile,)) 483 | if TILES[matched_tiles[0]] != TILES[tile_index][0]: 484 | levels[level_num].tiles[y_coord * levels[level_num].width+ x_coord] = matched_tiles[0] 485 | saved = False 486 | clear_screen() 487 | print('Level %d patched successfully.' % (level_num,)) 488 | continue 489 | 490 | # Handle saving 491 | if choice == 'S': 492 | if saved: 493 | raise Exception('Nothing to save.') 494 | choice = input(f'This will completely override file %s! Choose \'{YELLOW_FORE}Y{RESET_COLORS}\' to do it or any key to cancel: ' % (FILENAME,)).upper() 495 | if choice != 'Y': 496 | continue 497 | new_bytes = bin_bytes[:] 498 | for level in levels: 499 | new_bytes = new_bytes[:level.tiles_offset] + bytes(level.tiles) + new_bytes[level.tiles_offset + len(level.tiles):] 500 | new_bytes = new_bytes[:TITLE_OFFSET] + titles[0].encode() + new_bytes[TITLE_OFFSET + TITLE_SIZE:] 501 | new_bytes = new_bytes[:SUBTITLE_OFFSET] + titles[1].encode() + new_bytes[SUBTITLE_OFFSET + SUBTITLE_SIZE:] 502 | with open(FILENAME, 'wb') as f: 503 | f.write(new_bytes) 504 | clear_screen() 505 | print('Written %d bytes to file %s successfully.' % (len(bin_bytes), FILENAME)) 506 | saved = True 507 | continue 508 | 509 | # Handle quitting 510 | if choice == 'Q': 511 | if not saved: 512 | choice = input(f'All your changed will be lost! Choose \'{YELLOW_FORE}Y{RESET_COLORS}\' to quit or any key to cancel: ').upper() 513 | if choice != 'Y': 514 | continue 515 | print('Quitting.\n') 516 | break 517 | 518 | # Default handling 519 | raise Exception('Invalid option: %s\n' % (choice,)) 520 | 521 | # Handle exceptions 522 | except Exception as ex: 523 | clear_screen() 524 | print(ex) 525 | 526 | 527 | if __name__ == '__main__': 528 | main() 529 | 530 | --------------------------------------------------------------------------------