Bill & Ted NES map hacking guide

written by karmic, jan 16-20, 2022

Introduction

Bill & Ted's Excellent Video Game Adventure is THE worst game LJN ever published. It's based on the Bill & Ted movie series. To make it easier to go through, I thought I'd figure out the game's map format so I could make perfect pixel-level maps of the worlds, which didn't really work out for reasons I will discuss later. Anyway, so this doesn't end up a complete waste of time I'm publishing this information for anyone curious, or for anyone who wants to ROM hack the game.

The first thing we must discuss is the game's perspective. The game uses isometric graphics, and the controls are "rotated". For the sake of simplicity, we will say the game operates on the following compass, where the directions the character moves onscreen correspond to the D-pad button the player must press to move in that direction. To avoid confusion, the ingame directions will only be referred to as the compass directions- "left", "down", "top", "bottom", etc. are all physical directions onscreen.

W   N
 \ /
  .
 / \
S   E

This means that different "sectors" of the map can be presented in either two ways, a straight shot on the "vertical" axis, or the "horizontal" axis.

"Vertical" axis. The player must move north or south to proceed

"Horizontal" axis. The player must move east or west to proceed

Throughout this document, ROM addresses will be presented in the following way: $xx:$yyyy, where xx is the 16k ROM bank and yyyy is the CPU address where the data is accessed. Unlike most NES games, the game fixes $8000-$BFFF to the first bank and makes $C000-$FFFF switchable. Fixed-bank addresses will have no specified bank number, since it's always just $00 anyway.

Unless otherwise noted, all multi-byte numbers are little-endian. A "word" is a 16-bit value.

The basics

The game has 6 distinct worlds, which are split into sectors, which are then split into 256-pixel-wide screens, which are then split into 32-pixel-wide columns, which are then split into 16-pixel-tall rows.

Colonial World, sector 1. The transparent parts of the image are simply not present in the map data.

As you can see from the above image, each column is actually vertically offset (by 1 row/16 pixels) from the previous one to accomplish the isometric effect. The maps are always organized from left to right, but whether it starts from the bottom or the top depends on the primary axis the sector moves on. In this case, it is the horizontal axis, so it starts from the top.

The upper 8 rows of each column will be referred to as the "outside" portion. This is where the player cannot walk, and where buildings and doors are placed. The lower 16 rows are the "inside" portion, where all the walkable paths are located.

The basic building-block of graphics in this game is the metatile, which is a 4x2 block of tiles.

In memory, primary axis coordinates are generally words, where the upper 4 bits are the sector and the lower 12 bits are the actual coordinate within the sector. How coordinates relate to the pixel position is odd, though: if you pay close attention, you may notice that the screen scrolls twice as fast X-wise than it does Y-wise. So, the coordinate actually does directly correspond to the pixel position Y-wise, but you must multiply the coordinate by 2 to get the current X-wise pixel position. Going right always means a higher coordinate value.

Secondary axis coordinates are different. They are only 1-byte signed quantities, where 0 is the center of the "inside" portion. The coordinate value gets lower as you get closer to the outside portion.

The worlds & sectors

As mentioned earlier, there are 6 worlds. The current world ID is stored in RAM address $26. The IDs are assigned as follows:

World IDs:
  $00: Ancient World
  $01: Medieval World 1
  $02: Medieval World 2 (same map, different enemy sprites)
  $03: Colonial World
  $04: Western World
  $05: Modern World

The world pointer table is at $02:$CF56. These pointers point to a table (still in bank $02) describing each sector in the world, where each entry is $10 bytes long. The routine that copies the current sector's parameters to RAM is at $AF10. A world cannot have any more than 16 sectors.

Sector table entry:
  +$00: (word) Sector map pointer  (-> $00E4-$00E5)
  +$02: (byte) (-> $00DA)
                Bit 7: Primary axis (0: vertical / 1: horizontal)
                Bits 0-6: Length of sector in screens
  +$03: (word) Command list pointer  (-> $06E9-$06EA)
  +$05: (byte) Amount of commands  (-> $06E8)
  +$06: (word) Unknown pointer  (-> $06EB-$06EC)
  +$08: (big-endian word) Maximum coordinate on primary axis  (-> $00DE-$00DF)
  +$0a: (big-endian word) Minimum coordinate on primary axis  (-> $00E0-$00E1)
  +$0c: (word) Unknown pointer  (-> $00E2-$00E3)
  +$0e: (word) Palette pointer  (-> $00EE-$00EF)

Some things to note:

The sector maps

A sector map describes each screen in the sector, from left to right. Each entry is 3 bytes long.

Sector map entry:
  +$00: (byte) Bits 0-2: Screen set bank
               Bits 3-7: Screen set ID within the bank
  +$01: (byte) Screen "outside" map ID within the set
  +$02: (byte) Screen "inside" map ID within the set

Valid screen sets:
  $03 - Inside long building, vertical
  $0b - Inside small building, vertical
  $13 - Western World, vertical
  $1b - Western World, horizontal
  $23 - Ancient World, vertical
  $2b - Ancient World, horizontal
  $04 - Medieval World villages, vertical
  $0c - Medieval World villages, horizontal
  $14 - Medieval World castles, vertical
  $1c - Medieval World castles, horizontal
  $24 - Colonial World, vertical
  $2c - Colonial World, horizontal
  $34 - Modern World, vertical
  $3c - Modern World, horizontal

The screen sets

A screen set describes the general look of a screen. Specifically, it contains the palette, the 4k CHR bank used for the background, and all the possible metatiles and arrangements thereof that can be used by the screens in the set.

The data tables start at $C000 within the specified bank. Each entry in the tables is always two bytes long, even if only one byte is needed. There may be a maximum of 8 sets in a bank. All pointers point to data in the same bank.

Screen set data tables:
  $C00x: Background palette pointer
  $C01x: Background CHR bank
  $C02x: Metatile data pointer
  $C03x: "Outside" column maps pointer
  $C04x: "Inside" column maps pointer
  $C05x: "Outside" screen maps pointer
  $C06x: "Inside" screen maps pointer

The palettes

Palette data is just 16 bytes long. It describes the background color and the 4 palettes used by the background, exactly the same way as $3F00-$3F0F in PPU VRAM.

The metatiles

Metatile data is simply a linear array of 9-byte entries describing each metatile. The first 8 bytes are the tile IDs, arranged row by row, and the final byte describes the attributes of the 2x2 tile blocks.

Metatile attribute byte:
  Bits 0-1: Left half palette ID
  Bits 2-3: Right half palette ID
  Bits 4-7: Should be set identically to bits 0-3

The column maps

Column maps describe the arrangement of metatiles in a column. "Outside" column maps are 8 bytes long, "inside" column maps are 16 bytes long, and each byte is just a metatile ID, arranged from top to bottom.

The screen maps

Screen maps describe the arrangement of columns in a screen. Each entry is 8 bytes long, and each byte is the column map ID, arranged from left to right.

The sector commands

Sector "commands", for lack of a better term, are used to describe special locations within a sector. Note that some of them are not known yet. The code that handles all sector commands is at $B01F. The table that describes the length of each command is at $AFC1-$AFD1.

Sector commands:
  $00 - ??? (5 bytes)
  $01 - Enemy invisible wall? (5 bytes)
        +$01: (big-endian word) Primary coordinate
        +$03: (byte) ???
        +$04: (byte) ???
  $02 - Horse/canoe/canoe collectible (8 bytes)
        +$01: (big-endian word) Spawn hotspot primary coordinate
        +$03: (big-endian word) Actual primary coordinate
        +$05: (byte) Actual secondary coordinate
        +$06: (byte) Object type
                $00: Horse
                $02: Canoe
                $ff: Canoe collectible
        +$07: (byte)
                Horse/canoe: Initial height
                Collectible: Reward
                  $00: Pudding
                  $01: Firecracker
                  $02: Textbook
                  $03: Cassette tape
                  $04: Coin
                  $05: Key
  $03 - "Sticking point"? (6 bytes)
        +$01: (big-endian word) Primary coordinate
        +$03: (byte) Direction player must be moving
        +$04: (byte) ???
        +$05: (byte) ???
  $04 - Horse/canoe reward point (5 bytes)
        +$01: (big-endian word) Primary coordinate
        +$03: (byte) ???
        +$04: (byte) Reward
                $00: Pudding
                $01: Firecracker
                $02: Textbook
                $03: Cassette tape
                $04: Coin
                $05: Key
  $05 - Falling in water respawn point (there can be only one per sector) (4 bytes)
        +$01: (big-endian word) Primary coordinate
        +$03: (byte) Secondary coordinate
  $06 - Historical bait (6 bytes)
        +$01: (big-endian word) Primary axis hitbox center coordinate
        +$03: (byte) Secondary axis hitbox center coordinate
        +$04: (byte) Hitbox "diameter"
        +$05: (byte) Bait index (0-3)  (see RAM $019A-$01A5)
  $07 - Good stuff (6 bytes)
        +$01: (big-endian word) Primary axis hitbox center coordinate
        +$03: (byte) Secondary axis hitbox center coordinate
        +$04: (byte) Hitbox "diameter"
        +$05: (byte) Item code  (if it's the same as RAM $0196, don't have any item)
  $08 - Crossroad (6 bytes)
        +$01: (big-endian word) Source coordinate
        +$03: (byte) ???
        +$04: (big-endian word) Destination coordinate
  $09 - Enterable door to character room (15 bytes)
        +$01: (big-endian word) Primary axis entrance coordinate
        +$03: (byte) Secondary axis entrance coordinate
        +$04: (byte) Direction player must be moving in to enter
        +$05: (byte) Door entering animation time in frames
        +$06: (big-endian word) Destination primary coordinate
        +$08: (byte) Destination secondary coordinate
        +$09: (byte) Destination movement direction
        +$0a: (big-endian word) Character primary coordinate
        +$0c: (byte) Character secondary coordinate
        +$0d: (byte) Character sprite
                $80-$ff: world-alternating character
                    bits 1-3:
                      0: medieval guy
                      1: girl with bag
                      2: miss fifi
                      3: the jester
                      4: medieval peasant guy
                      5: yourself
                $00-$7f: generic character
                    bits 4-6: sprite set
                    bit 3: 1: flip sprite horizontally
                    bits 0-2: sprite index in set
        +$0e: (byte) Dialog tree ID (if it's the same as RAM $0195, character won't appear)
  $0a - Enterable door to historical figure room (14 bytes)
        +$01: (big-endian word) Primary axis entrance coordinate
        +$03: (byte) Secondary axis entrance coordinate
        +$04: (byte) Direction player must be moving in to enter
        +$05: (byte) Door entering animation time in frames
        +$06: (big-endian word) Destination primary coordinate
        +$08: (byte) Destination secondary coordinate
        +$09: (byte) Destination movement direction
        +$0a: (big-endian word) Historical figure primary coordinate
        +$0c: (byte) Historical figure secondary coordinate
        +$0d: (byte) Historical figure index (player must have the item, and it must be the same as RAM $03C9)
  $0b - "Blocked" door (6 bytes)
        +$01: (big-endian word) Primary axis entrance coordinate
        +$03: (byte) Secondary axis entrance coordinate
        +$04: (byte) Direction player must be moving in to enter
        +$05: (byte) Door entering animation time in frames
  $0c - Exit door from a building (6 bytes)
        +$01: (big-endian word) Primary axis entrance coordinate
        +$03: (byte) Secondary axis entrance coordinate
        +$04: (byte) Direction player must be moving in to enter
        +$05: (byte) Door entering animation time in frames
  $0d - Jail exit door (6 bytes)
        +$01: (big-endian word) Primary axis entrance coordinate
        +$03: (byte) Secondary axis entrance coordinate
        +$04: (byte) Direction player must be moving in to enter
        +$05: (byte) Door entering animation time in frames
  $0e - Building teleporter door (RAM $0198 must be $0a for it to not be blocked?) (6 bytes)
        +$01: (big-endian word) Primary axis entrance coordinate
        +$03: (byte) Secondary axis entrance coordinate
        +$04: (byte) Direction player must be moving in to enter
        +$05: (byte) Door entering animation time in frames
  $0f - Another door (15 bytes)

The problem

So, you're probably looking at all that and thinking, "well, where are the maps"? Simply put, they can't really be made right because the sector sizes are not proportional to each other. For example:

Western World map, from the manual

The same map, if the sectors were accurately sized

Nevertheless, if you want to be able to export maps one sector at a time, you can with this tool I wrote.

Lua script

I have created a Lua script for this game that you can use with Mesen. It shows all sector commands as they appear onscreen, and can also be used to more easily navigate the game.

Appendix

RAM map

$0000 - NMI up-counter
$0004 - $00: NMI allowed  $ff: NMI in progress

$0009 - (word) NMI vblank handler pointer
$000B - (word) NMI main handler pointer

$0019-$001A - Controllers

$001C - Current level - 1

$001D-$001F - Random number generator LFSR
          Feedback value is $1d872b, so the first byte can never be larger than $1f.
          The game usually reads $001F.

$0026 - Current world
          $00: Ancient World
          $01: Medieval World 1
          $02: Medieval World 2 (same map, different enemy sprites)
          $03: Colonial World
          $04: Western World
          $05: Modern World

$0031 - Words (linguistic words, not 16-bit values) left in dialog box string
$0032 - Dialog box character base (reset to $00 every word, and set to $20 when a $ or number is printed)
$0033 - Dialog box word buffer index
$0034 - Dialog box call stack index

$0038 - (word) Nametable row copy dest #1
$003A - Nametable row copy size #1
$003B - (word) Nametable row copy dest #2
$003D - Nametable row copy size #2

$0045 - (word) Nametable column copy dest #1
$0047 - Nametable column copy size #1
$0048 - (word) Nametable column copy dest #2
$004A - Nametable column copy size #2

(first entry in these tables is the player, then three "dudes", then horse/canoe collectibles, then "good stuff", then horse/canoe)
$0080-$0085 - Entity movement direction
              if primary axis is vertical:
                $00: north
                $02: east
                $04: south
                $06: west
              if primary axis is horizontal:
                $00: east
                $02: south
                $04: west
                $06: north
$0086-$008B - Entity movement speed
$008C-$0092 - Entity jump height acceleration
$0093-$0099 - Entity height fractional part
$009A-$00A0 - Entity height
$00A1-$00A7 - Entity onscreen X position
$00AF-$00B5 - Entity primary coordinate fractional part
$00B6-$00BC - Entity primary coordinate low
$00BD-$00C3 - Entity primary coordinate high
$00C4-$00CA - Entity secondary coordinate fractional part
$00CB-$00D1 - Entity secondary coordinate

$00D4 - Camera movement direction  ($00: right  $ff: left)
$00D5 - Camera primary coordinate fractional part
$00D6 - (word) Camera primary coordinate

$00DA - Current sector primary axis/length in screens

$00DB - Door entering timer
$00DC - (word) Door command pointer

$00DE - (word) Current sector primary axis max coordinate
$00E0 - (word) Current sector primary axis min coordinate
$00E2 - (word) Current sector unknown pointer 2
$00E4 - (word) Current sector map pointer

$00EE - (word) Current sector palette pointer

$00F0 - PPUCTRL for next frame
$00F1 - PPUMASK for next frame
$00F2 - PPUSCROLL X for next frame
$00F3 - PPUSCROLL Y for next frame

$00FA-$00FB - CHR banks for next frame

$00FC - Current PRG bank
$00FD - PRG bank backup

$0100-$011F - Nametable row copy buffer
$0120-$013F - Nametable column copy buffer
$0140-$014F - Nametable attribute copy buffer
$0150-$016F - Palettes mirror

$018C - (big-endian word) Falling in water respawn point primary coordinate
$018E - Falling in water respawn point secondary coordinate

$018F - Door entry primary coordinate backup hi
$0190 - Door entry primary coordinate backup lo
$0191 - Door entry secondary coordinate backup
$0192 - Door entry movement direction backup
$0193 - Door entry camera primary coordinate backup hi
$0194 - Door entry camera primary coordinate backup lo

$0195 - Last spoken to character dialog tree ID
$0196 - Last collected "good stuff" index  ($ff: none)

$019A-$01A5 - Historical bait in level
        $00: Major credit card
        $01: Fortune cookie
        $02: Holy grail
        $03: Compass
        $04: Megaphone
        $05: Uzi
        $06: Book of lawyer stuff
        $07: Headstone
        $08: Salad dressing
        $09: Bag-o-money
        $0a: Paint roller
        $0b: Stage prop (skull)
        $0c: Pair of choppers (dentures)
        $0d: Lawn chair
        $0e: Compact disc
        $0f: Rose
        $10: Some stuff
        $ff: Already got

$0200-$02FF - OAM mirror

$0300-$037C - Sound RAM

(these are all packed BCD)
$03A0 - Pudding
$03A1 - Firecrackers
$03A2 - Textbooks
$03A3 - Cassette tapes
$03A4 - Coins
$03A5 - Keys

$03A6 - Horse/canoe end reward count
$03A7 - Horse/canoe end reward
        $00: Pudding
        $01: Firecracker
        $02: Textbook
        $03: Cassette tape
        $04: Coin
        $05: Key

$03A8 - Phone number entry mode
        $00: Phone book
        $02: Phone number entry

$03A9 - Phone number entry cursor Y-pos
$03AA - Phone number entry cursor X-pos
$03AB-$03B1 - Phone number entry phone number (unpacked BCD)
$03B3 - Phone number entry phone number index

$03B4 - Valid phone number index

$03B5 - Write nonzero here to make all "dudes" onscreen crazy

$03B6 - Phone book animation phase
$03B7 - Current phone book selection
        $00: Cleopatra
        $01: Confucius
        $02: King Arthur
        $03: Christopher Columbus
        $04: Paul Revere
        $05: Jesse James
        $06: Al Capone
        $07: Elvis
        $08: Julius Caesar
        $09: Robin Hood
        $0a: Rembrandt
        $0b: William Shakespeare
        $0c: George Washington
        $0d: Sitting Bull
        $0e: Thomas Edison
        $0f: Marilyn Monroe
        $10: A stranger

$03BC-$03BE - Historical figures that need to be saved
        $00-$0f: As above
        $ff: Already saved

$03C5-$03C6 - Historical bait  (1: have)
        Byte 0:
          bit 0: Major credit card
          bit 1: Fortune cookie
          bit 2: Holy grail
          bit 3: Compass
          bit 4: Megaphone
          bit 5: Uzi
          bit 6: Book of lawyer stuff
          bit 7: Headstone
        Byte 1:
          bit 0: Salad dressing
          bit 1: Bag-o-money
          bit 2: Paint roller
          bit 3: Stage prop (skull)
          bit 4: Pair of choppers (dentures)
          bit 5: Lawn chair
          bit 6: Compact disc
          bit 7: Rose

$03C7 - Current historical figure
        See $03B7

$03C8 - Index to historical bait table at $019A

$03C9 - Correct historical figure door index

$03D2-$0511 - Dialog box nametable buffer

$06AC - Canoe collectible reward
        $00: Pudding
        $01: Firecracker
        $02: Textbook
        $03: Cassette tape
        $04: Coin
        $05: Key

$06E8 - Amount of commands in command list
$06E9 - (word) Current sector command list

$06EB - (word) Current sector unknown pointer 1

$06EE - Next dialog box string ID

$06F0 - (word) VRAM read location
$06F2 - VRAM read result

$0720-$073F - ($10 words) Dialog box string substitution pointer table

$0756-$076D - Dialog box word buffer

$076E-$0775 - Dialog box call stack $0795
$0776-$077D - Dialog box call stack words left
$077E-$0785 - Dialog box call stack pointer lo
$0786-$078D - Dialog box call stack pointer hi

$078E - Dialog box string ID
        $00-$08: Bill/Ted speaking
          $00: Whoa... Check it out! It's ______!
          $01: Excellent! _________!
          $02: Excellent! It's one of those _______!
          $03: No problemo o'mighty Rufus dude.
          $04: That is most upsetting Rufus. You see... I am currently without any change with which to make a call.
          $05: Thanks Rufus... I better get started.
          $06: Hi Rufus. I guess I just missed _____.
          $07: Sure dude... Whatever you say... Any advice before I go help my most excellent compatriot?
          $08: Thanks Rufus. My excellent friend ____ and I will do our best to make this a most triumphant adventure.
        $09-$3b: Just text
          $09: _____ for me! Thank you, _____! Let's party back at my place... and I'll pay for the call.
          $0a-$2e: character dialog trees
          $2f: historical figure dialog tree
          $30: Is this some kind of joke? I'll show you how we treat wise guys around here!
          $31: Clumsy oaf! Pay me one coin or I'll have you locked up!
          $32: No coins?! Worthless scum! You leave me no choice...
          $33: Your friend _____ asked me to give you one of these _______.
          $34: Get away from me kid, you're bothering me.
          $35: Don't you have some homework you should be doing?
          $36: I don't know you! You have obviously mistaken me for someone else.
          $37: Hey look out! Watch where you're going buddy!
          $38: historical bait clue
          $39: historical dude clue
          $3a: "good stuff" clue
          $3b: Stick 'em up!
        $3c+: Rufus speaking
          $3c: Greetings, my excellent friend! Your time travelling skills are needed once again.
          $3d: Time-space rebels have relocated important historical personalities into incorrect time periods.
          $3e: If history is not corrected, Wyld Stallyns will miss the concert that will launch your careers.
          $3f: First check the historical telephone directory to see whose phone number has been changed and travel to their world.
          $40: Unfortunately I can only leave you a pay phone.
          $41: Here are 15 coins to help you out... And if you use a shortcut through time you can also save money.
          $42: Now listen carefully, amigo. It may be difficult to find your friends and send them back to their own world.
          $43: Ask the locals for help, but remember to be excellent to everyone... don't make them mad!
          $44: It's better that you work separately in order to restore order to the temporal continuum.
          $45: You'll need a special item to help lure the historical dudes back to their correct times. Without it they won't go home.
          $46: Good luck... and be excellent to each other!
          $47: Well done, my excellent friend, but I'm afraid the rebels have not changed their vandalous ways.
          $48: Once again you are required to correct history before your next gig.
          $49: Your performance is improving amigo, but once again the future requires your help.
          $4a: You must correct history if you want to experience your next concert. And by the way, your musical skills are improving...
          $4b: Well, _____, I'm afraid it appears you've run out of keys. As the saying goes... game over.
$078F - (word) Dialog box string pointer

$0793 - Dialog box currently selected item ID
$0794 - Dialog box currently selected item "index" (incremented by 4 each time a valid item is found, loops back to 0 when item id loops)

$07F7-$07F8 - Current CHR banks

Text compression

This game uses a nybble-based text compression. With this scheme, the most common 11 characters take only 4 bits as opposed to a whole byte. The actual decompression part is at $BBEC. There is also some dictionary coding for a couple common words.

The most significant nybble in a byte is always read first.

Compressed text header:
  ???

Compressed text:
  $0: Space
  $1-$b: Character is one of "ETAONRIHSML"
  $c:
    $0-$f: Character is one of "CUDFPYBWGVJKXQZ'"
  $d:
    $0: Space
    $1-$4: Character is one of ".?!,", only if RAM $0795 AND #$04 == 0
    $5-$f: Character is one of "$0123456789". All characters from this until the next space are also forced in this range.
  $e:
    $0: '-'
    $1: "--"
    $2: "..."
    $3: "?!"
    $4: buggy?
    $5: "You"
    $6: "Dude"
    $7: "Excellent"
    $8: "Careful"
    $9: "Your"
    $a: "Don't"
    $b: "And"
    $c: "The"
    $d: "Have"
    $e: "Something"
    $f: "Way"
  $f: string substitution
    $1: Selects next item in posession, then prints name of the currently selected item
    $3: Adds 2 to RAM $0755, then proceeds as normal
    $4: Turns bit (RAM $0754) off in RAM $0797, then proceeds as normal
    otherwise: See RAM $0720-$073F

Compressed text footer:
  ???