Vintage computing, old video games

This post describes how to change players’ names arbitrarily (longer or shorter) in the game NHL ’94 for Super Nintendo by changing the ROM.

Players’ names are shown as text in the menus and in-game.

In the team selection menu, it’s their first initial and last name. In other places, it’s the full first and last name.

Goal is to change how names appear in all these places. Have new names’ length be allowed to be different from the old names.

My own intended usage for this is to only change a few names, so optimize for that. And optimize for game stability because I plan to play the game, not just do a quick demo. I don’t care if the result is not elegant or wasteful in terms of memory. The result can operate on an expanded ROM.

The Very Easy, Very Limited Way

Easiest way to change names is a pure data hack. Doesn’t require any knowledge about SNES at all, only basic computer literacy. Open the ROM in a hex editor such as HxD. Scroll down until you see players’ names on the right, and type over them. Hit Ctrl+S to save. Start your game and go.

Why does this work? Because all player names are literally stored as ASCII, no need for character translation tables or whatever. Also, each name is only stored in 1 place.

But of course, the catch is whatever new name you pick has to be the same length or shorter. Ideally, the same length. Shorter only kind of works because you can pad names with spaces, but no guarantees that’ll look right when you see it laid out on the screen. And if the new name is longer it definitely won’t work. Since the goal is to let you change names without length restrictions we have to do something more.

The reasons why you can’t change the name length as a pure data hack take some context to explain. So they are explained further down. The first thing you need to know is the data you just tried to hack- what is it, really.

Player-data Format

This describes where and how players’ names are stored in the ROM, as well as the neighboring data.

There is what I’m calling a “main pointer table”. The table has 28 values. This table is stored at ROM address 0x9CA5E7.

If you dump the raw data for the table it’s

 	4F EB 9C 00 AC AB 9C 00 32 AE 9C 00 C1 B0 9C 00
 	40 B3 9C 00 4F C0 9C 00 D7 B5 9C 00 9D B8 9C 00
 	4D E9 9C 00 1D BB 9C 00 B2 BD 9C 00 DB C2 9C 00
 	7E C5 9C 00 2B C8 9C 00 9C CA 9C 00 06 CD 9C 00
 	AC CF 9C 00 37 D2 9C 00 D2 D4 9C 00 60 D7 9C 00
 	D4 D9 9C 00 4A DC 9C 00 DC DE 9C 00 7E E1 9C 00
 	AD E6 9C 00 05 E4 9C 00 57 A6 9C 00 04 A9 9C 00

Formatted nicely, the table is

int mainTable[] = { 
    9CEB4F, // Anaheim
    9CABAC, // Boston
    9CAE32, // Buffalo
    9CB0C1, // Calgary
    9CB340, // Chicago
    9CC04F, // Dallas
    9CB5D7, // Detroit
    9CB89D, // Edmonton
    9CE94D, // Florida
    9CBB1D, // Hartford
    9CBDB2, // LA Kings
    9CC2DB, // Montreal
    9CC57E, // New Jersey
    9CC82B, // NY Islanders
    9CCA9C, // NY Rangers
    9CCD06, // Ottawa
    9CCFAC, // Philly
    9CD237, // Pittsburgh
    9CD4D2, // Quebec
    9CD760, // San Jose
    9CD9D4, // St Louis
    9CDC4A, // Tampa Bay
    9CDEDC, // Toronto
    9CE17E, // Vancouver
    9CE6AD, // Washington
    9CE405, // Winnepeg
    9CA657, // All Stars East
    9CA904  // All Stars West
};

There is 1 value per hockey team.
The first element in the array belongs to Anaheim, and the value is 0x9CEB4F.
The second element in the array belongs to Boston, and the value is 0x9CABAC, and so on.

The teams are ordered alphabetically, except for the all-stars teams at the end. The values themselves aren’t in any particular order.

Although the all-stars teams are comprised of players which exist on other teams, they have their own entries in the table. That’s the game opting for perf and simplicity of code in the perf-memory tradeoff.

Looking at the values of elements in this array, you might think they also look like ROM addresses and you would be right.
As for what’s stored at each address- here’s a description

[H0] [H1] [some number of header bytes] // A two-byte low-endian length H, and a variable-length 
                                        // header stream of length H-2

For each player:
[L0] [L1] [player's name]     // A two-byte low-endian length L, and then a variable-length string 
                              // of length L-2

[PlayerNumber]                // A byte for the player number. It's in a decimal format.
                              // Leftmost half-byte is the tens place value. Rightmost half-byte is 
                              // the ones place value.

[WeightClass, Agility]        // A byte. Leftmost half-byte is the player's weight class. 
                              // Rightmost half-byte is their agility rating.
                              // Weight class is displayed as a measurement in pounds when 
                              // displayed on the team roster page.
                              // To convert from weight class to pounds, it's 
                              // 	pounds = 140 + (weightClass * 8)
                              // Weight classes range from 0 to 14 in practice. Higher numbers may 
                              // be hacked.
                              // Agility rating is from 0 to 6.
                          
[Speed, OffenseAware]         // A byte. Leftmost half-byte is player's speed. Rightmost half-byte 
                              // is their offense awareness rating.
                              // Ratings are from 0 to 6.
                          
[DefenseAware, ShotPower]     // A byte. Leftmost half-byte is player's defense awareness rating. 
                              // Rightmost half-byte is their shot power.
                              // Ratings are from 0 to 6.
                          
[Checking, Handedness]        // A byte. Leftmost half-byte is player's checking rating. Rightmost 
                              // half-byte is their handedness.
                              // Checking rating is from 0 to 6.
                              // For handedness, if the value is even (divisible by 2) they shoot 
                              // left. If it's odd they shoot right.
                          
[StickHandling, ShotAccuracy] // A byte. Leftmost half-byte is player's stick handling rating. 
                              // Rightmost half-byte is their shot accuracy.
                              // Ratings are from 0 to 6.
                          
[Endurance, Roughness]        // A byte. Leftmost half-byte is player's endurance rating. Rightmost 
                              // half-byte is their 'roughness' rating.
                              // Ratings are from 0 to 6.
                              // The 'roughnesss' stat is a hidden stat. It exists but is not 
                              // displayed in the game or the manual.
                              

[PassAccuracy, Aggression]    // A byte. Leftmost half-byte is player's pass accuracy rating. 
                              // Rightmost half-byte is their aggression rating.
                              // Ratings are from 0 to 6.

Then, at the end:

[00] [00]		      // Two zero bytes mean that's the end of the player data for this 
                              // team.

For example, if we dereference and dump what’s stored at 9CC2DB (Montreal), we get

55 00 0E 00 79 02 1D 00 0E 00 13 00 15 00 52 11
21 E8 49 C5 00 01 12 11 07 03 0C 04 00 01 17 12
07 03 0C 04 00 01 16 11 08 04 0E 03 00 01 13 14
09 05 0D 03 00 01 12 11 07 03 0C 05 00 01 13 14
08 04 05 03 00 01 11 17 07 06 03 0D 00 01 16 13
03 0D 07 06 00 0D 00 50 61 74 72 69 63 6B 20 52
6F 79 33 66 44 46 00 00 55 66 ...

Cleaned up, this is

Cleaned up, this is

headerSize = 0x0055;
byte header[] = {0E 00 79 02 1D 00 0E 00 13 00 15 00 52 11
                 21 E8 49 C5 00 01 12 11 07 03 0C 04 00 01 17 12
                 07 03 0C 04 00 01 16 11 08 04 0E 03 00 01 13 14
                 09 05 0D 03 00 01 12 11 07 03 0C 05 00 01 13 14
                 08 04 05 03 00 01 11 17 07 06 03 0D 00 01 16 13
                 03 0D 07 06 00 };

player0 = {
    // L0 L1 P  a  t  r  i  c  k  __ R  o  y
       0D 00 50 61 74 72 69 63 6B 20 52 6F 79 // "Patrick Roy" 
                                              // 11 character length + 
                                              // 2 byte string size = 13 = 0xD, converted to 
                                              // two-byte low endian that's 0D 00    
       33 // Number 33 
       66 // Weight class 6, agility 6                                    
       44 // speed 4, OffAware 4
       46 // DefAware 4, shot power 6
       00 // checking 0, handedness L
       00 // stick handling 0, shot accuracy 0
       55 // endurance 5, roughness 5
       66 // pass accuracy 6, aggression 6
}

Etc, for the rest of the players. Then there are some team-related strings at the end.

Heads up that viable (e.g., a subsequent team’s) game data can follow immediately after the data for each team. So no, you’re not free to grow things off the end.

Side thing: stats stored affect the stats in-game although you’ll notice they don’t correlate to those stats exactly. That’s because each game applies RNG. Try it yourself if you want. Boot the game, pick two teams and look at a player’s stats. Reset the game, pick the exact same matchup and player to look at. The stats will be slightly different. If you play a lot of sports games maybe this is not surprising. There is this real-world idea that any team could potentially beat any other team.

Strings are stored with a length rather than being delimited, so that’s nice. However, there’s a bunch of player data parsing code that needs to skip over player’s names to get to their stats. So if you make a player’s name shorter, you’d need to either pad with spaces (could cause things to display weirdly) or update the length value and move that person’s stats plus everyone else on the team’s data backwards. I’ve listed all the stats out because you need to know how much to move if you do it that way, and it’s also helpful to know what it is you’re moving.

And of course for making player’s names longer, there isn’t any extra space off the end, so you need something else entirely. This was the case I was more interested in.

Why not move the whole team data, and update the “main pointer table” entry?

This seemed like a swell idea at first. To get names as long as you want, just make a totally new copy of the player data someplace else with your new names, then adjust the “main pointer table” value to point to it.

Unfortunately that won’t work. It is true the “main table” above is a table of long (four-byte) pointers. But, the upper two bytes of each entry are either

  • dead data, sometimes
  • dead data, always

By “dead data” I mean the data isn’t read and doesn’t do anything.

Instead of reading data from the upper two bytes, the game will use a hardcoded number which is $9C.

Here’s an example of what I mean. This is from the code to figure out a player’s first initial and last name for the team selection screen.

			                      ; Precondition: 9F1CDC and $9f1CDE have been 
                                              ; initialized with 
			                      ; main table elements for home and away teams 
                                              ; respectively.
			                      ; We're in 16-bit mode.
			                      ;
$9F/C732 A9 9C 00    LDA #$009C               ; Hardcode $9C in the upper part
$9F/C735 85 8B       STA $8B
$9F/C737 A4 91       LDY $91
$9F/C739 B9 DC 1C    LDA $1CDC,y[$9F:1CDC]    ; Load the short pointer from the main table
$9F/C73C 85 89       STA $89                  ; Store the short pointer in the lower part
                                              ;
                                              ; ... do stuff that uses direct addressing on 
                                              ; 24-bit pointer. For example, ADC [$89].    

There are multiple instances of this pattern. I found several without trying super hard and I believe there are more. If you want to change a “main pointer table” entry, you don’t just change the entry- you need to change some undetermined number of places in game code.

Why store long pointers in the table if they were gonna do it this way? They could have just stored short pointers. Seems like a waste of space. It might have been, they started out planning for long pointers but wanted to optimize reading/dereferencing of the table values, then they never tried to go back and shrink the table.

Anyway, it’s hard to safely detect all the places that pull values out of this table so it’s difficult to “fix” the table and reclaim the space. I didn’t try. If you want to try it to be absolutely safe you might want to set a breakpoint in the debugger and play the game extensively. We don’t have source code and you can’t statically disassemble this platform.

So suppose you begrugingly accept the fact that all table entries need to live in bank $9C. Can you change lower bytes only and make that work? Unfortunately that’s not such a good idea either. You can adjust these table values to point someplace else in bank $9C, but there isn’t all kinds of free space in that bank to put anything.

If we want to move player data (including player names’ string data) to a different location in memory, a different bank- I prefer a safer option that is more targeted. Completely bypass the use of the “main pointer table” entry JUST when we are loading player names. This lets us be really confident in things working. We make a code change in a specific, testable place that we understand really well.

Detouring the loading of player names

NHL ’94 has a function I’m calling LookupPlayerName().

The code for LookupPlayerName() is

                                             ; Function: LookupPlayerName()
                                             ;     Gets the address of a player's name string, 
                                             ; based on the player's index on the team and some 
                                             ; previously-set table data.
                                             ;
                                             ; Preconditions: 
                                             ;     $91 contains HomeOrAway. 0 == home, 2 == away
                                             ;     $9F1CDC and $9F1CDE have been initialized with 
                                             ;     main table elements for home and away teams 
                                             ; respectively.
                                             ;     $A5 contains PlayerIndexOnTeam
                                             ;
                                             ; Postconditions:
                                             ;     $89-$8B = Address of the player's name string
                                             ; data. (Includes the length field that comes first)
                                             ;
                                             ; $A5 gets scrambled.

$9F/C732 A9 9C 00    LDA #$009C              ; Hardcode 9C in the upper bytes. Easy enough.                                              
$9F/C735 85 8B       STA $8B    [$00:008B]   ; 
                                             ;
$9F/C737 A4 91       LDY $91    [$00:0091]   ; Load the choice of HomeOrAway. 0 == home, 2 == away
$9F/C739 B9 DC 1C    LDA $1CDC,y[$9F:1CDC]   ; Load PlayerNamesStartAddress for the corresponding 
                                             ; team.
                                             ; As mentioned in the preconditions this has been set 
                                             ; up for us. As it happens, it's somewhere far away in 
                                             ; the chain of function calls.                                             
$9F/C73C 85 89       STA $89    [$00:0089]   ; And then store the lower bytes.																				

LookupPlayerName_GetOffsetOfPerPlayerData:
$9F/C73E A0 00 00    LDY #$0000              
$9F/C741 18          CLC                     
$9F/C742 67 89       ADC [$89]  [$9C:C2DB]   ; Use the fact that the first two bytes of the 
                                             ; PlayerNamesStartAddress
                                             ; data will give us the offset to the per-player data. 

$9F/C744 85 89       STA $89    [$00:0089]   ; For example, for Montreal we add 0x55 to get to the 
                                             ; start of the per-player data.

$9F/C746 80 0A       BRA $0A    [$C752]      ; Goto LookupPlayerName_CheckDone

LookupPlayerName_ForEachPlayerIndexOnTeam:
$9F/C748 A5 89       LDA $89    [$00:0089]   ; Load the length of the player's name.
$9F/C74A 18          CLC                     
$9F/C74B 67 89       ADC [$89]  [$9C:C330]   ; Increment the current $89-$8B pointer by the length
$9F/C74D 69 08 00    ADC #$0008              ; Plus 8, it's padding (Not really but let's pretend 
                                             ; it is)
$9F/C750 85 89       STA $89    [$00:0089]   ; Update the current $89-$8B pointer

LookupPlayerName_CheckDone:
$9F/C752 C6 A5       DEC $A5    [$00:00A5]   
$9F/C754 10 F2       BPL $F2    [$C748]      ; branch-if-positive
                                             ;  LookupPlayerName_ForEachPlayerIndexOnTeam

$9F/C756 6B          RTL

The idea is to detour this function. Use an “alternate main table” which actually does honor the long pointer, and shim LookupPlayerName() to use the “alternate main table” instead. From testing I found that LookupPlayerName() is a centralized place and changing it is sufficient.

How the detouring goes is we chuck a payload someplace there’s space (say, $A08100, in expanded ROM space). Then replace the code for LookupPlayerName() with

$9F/C732 5C 00 81 A0 JMP $A08100                ; Jump into expanded ROM space where we put the 
                                                ; detour payload
                     NOP
                     NOP
                     ...

NOPs are not strictly needed but added for hygiene. (Could use a BRK instead when you are getting things running)

As for the payload itself, it’s

$A0/8100 DA          PHX                        ; Caller doesn't like it if X is scrambled                  
         A4 91       LDY $91                    ; Load the team index, which has been stored at 
                                                ; 9F1C98/9F1C9A for home/away.
         B9 98 1C    LDA $1C98, y[$9F:1C98]
                                                
         0A          ASL                        ; Multiply by 4 to turn index into an offset
         0A          ASL                                               
                                                
         AA          TAX                        ; Use the team index to look up into the 
                                                ; "alternate main table".
         BF 00 D0 A8 LDA 0xA8D000,x             ; Load the array element from 0xA8D000, store it in 
                                                ; $89-$8C
         85 89       STA $89
         E8          INX
         E8          INX
         BF 00 D0 A8 LDA 0xA8D000,x
         85 8B       STA $8B

                                                ; The "alternate main table" is formatted a bit 
                                                ; differently from the "main table".
                                                ; Each element is itself an array, one four-byte 
                                                ; element per player.
                                                ; Use $A5 as a counter to get to the right player.

PlayerIndexIncrement:
        A5 A5        LDA $A5                    ; Sets Z
        F0 0C        BEQ $0C                    ; goto DonePlayerIndex

        E6 89        INC $89
        E6 89        INC $89
        E6 89        INC $89
        E6 89        INC $89
        C6 A5        DEC A5
        80 F0        BRA $F0                    ; goto PlayerIndexIncrement

DonePlayerIndex:
                                                ; We have the element for the right player stored 
                                                ; at $89-$8C.
                                                ; The element is a pointer. 
                                                ; It'll be either $9Cxxxx if we're keeping the 
                                                ; original names, or $A8xxxx/whatever if 
                                                ; we're using new names.
                                                ; Dereference it, and store the dereferenced result 
                                                ; at $89-$8C.
        A7 89        LDA [$89]
        48           PHA
        E6 89        INC $89
        E6 89        INC $89
        A7 89        LDA [$89]
        85 8B        STA $8B
        68           PLA
        85 89        STA $89

        FA           PLX                        ; Restore X and return.
        6B           RTL

One thing that allows the detour to work is there’s nothing in the detour that requires execution out of bank $9F (bank $9F is where the original function is). It’s okay if the code executes in bank $A8. And by a stroke of good fortune, it happens that the original code is also okay running from other banks. No absolute short addressing (local bank). This is hugely helpful when getting things up and running.

The “alternate main table” lives at 0xA8D000, chosen arbitrarily.

If this code were a bit smaller it could actually be copied overtop the implementation of LookupPlayerName() with no jumping out. Original LookupPlayerName is 37 bytes, this routine is 55 bytes. Alas, it won’t fit, so I put it at $A08100. Not a big deal since we are putting stuff in expanded ROM space anyway.

Putting it all together, the full list of things to patch are

  • the JMP at the beginning of LookupPlayerName
  • code snippet above it’s supposed to JMP to
  • the “alternate main table” at 0xA8D000 and set of tables each of its entries points to
  • the strings that the “alternate main table” points to

Not too bad.

Could do all this manually. I suggest making a program to do it so that you don’t make mistakes. And plus you can easily configure whatever new player names you want. I made an editor that does the above.

Example in the editor:

Patched game result

Enjoy

Download the editor here:

https://github.com/clandrew/nhl94e/releases/tag/v1.0

Or, find the editor source code here

https://github.com/clandrew/nhl94e

Find this post, in text form here:

https://raw.githubusercontent.com/clandrew/nhl94e/main/docs/PlayerNames.txt

November 23rd, 2020 at 6:07 am | Comments & Trackbacks (1) | Permalink

This post explains Lagoon_hitbox.ips, a proof-of-concept patch created to enlarge the hitboxes in the game Lagoon for SNES. The patch was really quick+and+dirty. Nonetheless it’s posted here:

http://secretplace.cml-a.com/edits.php

What follows is a description of the patch and how it works.

First, here are some useful memory locations

$01:0502-0503 - NASIR's X position in the map
$01:0504-0505 - NASIR's Y position in the map
$01:050A- The direction NASIR is facing.
	- 00 means right
	- 01 means down
	- 02 means left
	- 03 means up
	
$01:B710-B717 - The offsets of NASIR's hit box from his position, if he is facing right
$01:B718-B71F - The offsets of NASIR's hit box from his position, if he is facing down
$01:B720-B727 - The offsets of NASIR's hit box from his position, if he is facing left
$01:B728-B730 - The offsets of NASIR's hit box from his position, if he is facing up

In Lagoon, the following code is invoked whenever an action button is pressed, even if you’re not near anything.

$01/9BBD AD 0A 05    LDA $050A               ; A = the direction NASIR is facing.
					     ; 
$01/9BC0 0A          ASL A                   ; A *= 8
$01/9BC1 0A          ASL A                   ;
$01/9BC2 0A          ASL A                   ;
$01/9BC3 18          CLC                     ;
$01/9BC4 69 20       ADC #$20                ; A += 0x20
					     ; 
					     ; Now A effectively stores an array index with 
					     ; which to load hitbox offsets.
					     ; If facing right: A = 0x20
					     ; If facing down: A = 0x28
					     ; If facing left: A = 0x30
					     ; If facing up: A = 0x38
					     ; 
$01/9BC6 20 C3 B6    JSR $B6C3	             ; Call CalculatePlayerHitboxDimensions()
$01/9BC9 60          RTS                                         

The function CalculatePlayerHitboxDimensions() looks like the following

CalculatePlayerHitboxDimensions:
					     ; Preconditions: A is set to one of 
					     ;     {0x20, 0x28, 0x30, 0x38} depending on
					     ;     the direction NASIR is facing, as described 
					     ;     above.
					     ; Postconditions: $40, $42, $44, and $46 contain 
					     ;     the dimensions of NASIR's hit box; left, 
					     ;     right, top and bottom respectively.
$01/B6C3 C2 20       REP #$20                
$01/B6C5 A8          TAY                     
$01/B6C6 AD 02 05    LDA $0502               ; Load NASIR's X position
$01/B6C9 18          CLC                    
$01/B6CA 79 F0 B6    ADC $B6F0,y             ; Add left edge hitbox offset to NASIR's X position
$01/B6CD 85 40       STA $40                 ; Store it as an output
					     ; 
$01/B6CF AD 02 05    LDA $0502 		     ; Load NASIR's X position
$01/B6D2 18          CLC                     
$01/B6D3 79 F2 B6    ADC $B6F2,y             ; Add right edge hitbox offset to NASIR's X position
$01/B6D6 85 42       STA $42                 ; Store it as an output
					     ; 
$01/B6D8 AD 04 05    LDA $0504               ; Load NASIR's Y position
$01/B6DB 18          CLC   
$01/B6DC 79 F4 B6    ADC $B6F4,y             ; Add top edge hitbox offset to NASIR's Y position
$01/B6DF 85 44       STA $44                 ; Store it as an output
					     ; 
$01/B6E1 AD 04 05    LDA $0504               ; Load NASIR's Y position
$01/B6E4 18          CLC                     
$01/B6E5 79 F6 B6    ADC $B6F6,y             ; Add bottom edge hitbox offset to NASIR's Y position
$01/B6E8 85 46       STA $46                 ; Store it as an output
					     ; 
$01/B6EA 29 FF 00    AND #$00FF              ; Clean up and return
$01/B6ED E2 20       SEP #$20                
$01/B6EF 60          RTS          

If you were to skim the code quickly you’d see it loads hitbox dimensions from memory. From that, you might get the impression they are something dynamic. But, they come from a table hard-coded in ROM data. (you can see this based on the particular address they’re loaded from).

Just so you know, the hitbox table data contains this (Semantics are left, right, top, bottom)

Facing  	Raw data			Plain hex offsets		Signed dec offsets	
------		--------			-----------------		------------------
Right		00 00 19 00 F8 FF 08 00		0h, 19h, FFF8h, 8h		0, 25, -8, 8		
Down		F0 FF 10 00 00 00 0F 00		FFF0h, 10h, 0h, 0Fh		-16, 16, 0, 15	
Left		E7 FF 00 00 F8 FF 08 00		FFE7h, 0h, FFF8h, 8h		-25, 0, -8, 8
Up		F0 FF 10 00 F1 FF 00 00		FFF0h, 10h, FFF1h, 0h		-16, 16, -15, 0	

Yes, the game uses slightly differently-sized hitboxes depending on the direction you’re facing.

Now, the patch. What this patch does is instead of offsetting NASIR’s position by values from this table, it hacks it to offset the position simply by a hardcoded number. The hardcoded numbers yield bigger hitboxes than the offsets from the table.

It always applies the hitbox of offsets {-0x30, 0x32, -0x38, 0x30 } = {-48, 50, -56, 48 }. The hitbox size is 98×104 which is about 5 times bigger than the default.

The patch modifies just four operations in CalculatePlayerHitboxDimensions:

CalculatePlayerHitboxDimensions:
					     ; Preconditions: A is set to one of {0x20, 0x28, 0x30, 0x38} depending on
					     ;     the direction NASIR is facing, as described above.
					     ; Postconditions: $40, $42, $44, and $46 contain the dimensions of 
					     ;     NASIR's hit box; left, right, top and bottom respectively.
$01/B6C3 C2 20       REP #$20                
$01/B6C5 A8          TAY                     
$01/B6C6 AD 02 05    LDA $0502               ; Load NASIR's X position
$01/B6C9 18          CLC                    
$01/B6CA E9 30 00    SBC #$0030              ; Apply left edge hitbox offset -30h
$01/B6CD 85 40       STA $40                 ; Store it as an output
					     ; 
$01/B6CF AD 02 05    LDA $0502 		     ; Load NASIR's X position
$01/B6D2 18          CLC                     
$01/B6D3 69 32 00    ADC #$0032              ; Apply left edge hitbox offset 32h
$01/B6D6 85 42       STA $42                 ; Store it as an output
					     ; 
$01/B6D8 AD 04 05    LDA $0504               ; Load NASIR's Y position
$01/B6DB 18          CLC   
$01/B6DC E9 38 00    SBC #$0038              ; Apply left edge hitbox offset -38h
$01/B6DF 85 44       STA $44                 ; Store it as an output
					     ; 
$01/B6E1 AD 04 05    LDA $0504               ; Load NASIR's Y position
$01/B6E4 18          CLC                     
$01/B6E5 69 30 00    ADC #$0030              ; Apply left edge hitbox offset 30h
$01/B6E8 85 46       STA $46                 ; Store it as an output
					     ; 
$01/B6EA 29 FF 00    AND #$00FF              ; Clean up and return
$01/B6ED E2 20       SEP #$20                
$01/B6EF 60          RTS     

And there you have it, the code for the proof-of-concept posted at the link above.


Here is a small improvement that can be made to the above hack. First, it’d be cleaner to modify the hitbox region offsets in the ROM directly. So let’s do that instead.

To re-iterate, the default values are (with semantics left, right, top, bottom)-

Facing  	Raw data			Plain hex offsets		Signed dec offsets	
------		--------			-----------------		------------------
Right		00 00 19 00 F8 FF 08 00		0h, 19h, FFF8h, 8h		0, 25, -8, 8	
Down		F0 FF 10 00 00 00 0F 00		FFF0h, 10h, 0h, 0Fh		-16, 16, 0, 15	
Left		E7 FF 00 00 F8 FF 08 00		FFE7h, 0h, FFF8h, 8h		-25, 0, -8, 8	
Up		F0 FF 10 00 F1 FF 00 00		FFF0h, 10h, FFF1h, 0h		-16, 16, -15, 0	

The ROM file offsets for each direction are

Facing		Headerless ROM file offset
------		--------------------------
Right		B710
Down		B718
Left		B720
Up		B728

While we can patch the table manually, it makes for easier testing of changes if you use a patching program.

Here’s some C++ code for one:

enum Direction 
{ 
    FacingRight = 0, 
    FacingDown = 1,
    FacingLeft = 2, 
    FacingUp = 3 
};

struct HitboxDir
{
    int Left;
    int Right;
    int Top;
    int Bottom;
};

void PushValue(int b, std::vector<unsigned char>* out)
{
    if (b >= 0)
    {
        assert(b < 256);
        out->push_back(b); // little endian
        out->push_back(0);
    }
    else
    {
        int u = 0x10000 + b;
        int low = u & 0xFF;
        out->push_back(low);
        u >>= 8;
        assert(u < 256);
        int high = u & 0xFF;
        out->push_back(high);
    }
}

int main()
{
    FILE* pB = nullptr;

    fopen_s(&pB, "Lagoon.hitbox.v2.smc", "rb");

    // Check size
    fseek(pB, 0, SEEK_END);
    long sizeB = ftell(pB);
    fseek(pB, 0, SEEK_SET);

    std::vector<unsigned char> dataB;
    dataB.resize(sizeB);

    fread(dataB.data(), 1, sizeB, pB);

    fclose(pB);

    HitboxDir allDirs[] =
    {
        {0, 25, -8, 8},
        {-16, 16, 0, 15},
        {-25, 0, -8, 8},
        {-16, 16, -15, 0}
    };

    // Enlarge hitboxes
    int ff = 3;
    int hf = 2;

    allDirs[FacingRight].Right *= ff;
    allDirs[FacingRight].Top *= hf;
    allDirs[FacingRight].Bottom *= hf;

    allDirs[FacingDown].Bottom *= ff;
    allDirs[FacingDown].Left *= hf;
    allDirs[FacingDown].Right *= hf;

    allDirs[FacingLeft].Left *= ff;
    allDirs[FacingLeft].Top *= hf;
    allDirs[FacingLeft].Bottom *= hf;

    allDirs[FacingUp].Top *= ff;
    allDirs[FacingUp].Left *= hf;
    allDirs[FacingUp].Right *= hf;

    // Transfer hitbox info into byte data
    std::vector<unsigned char> hitboxBytes;

    for (int i = 0; i < 4; ++i)
    {
        PushValue(allDirs[i].Left, &hitboxBytes);
        PushValue(allDirs[i].Right, &hitboxBytes);
        PushValue(allDirs[i].Top, &hitboxBytes);
        PushValue(allDirs[i].Bottom, &hitboxBytes);
    }

    // Patch the new tables in
    int destOffset = 0xB710;
    for (size_t i = 0; i < hitboxBytes.size(); ++i)
    {
        dataB[destOffset + i] = hitboxBytes[i];
    }

    fopen_s(&pB, "Lagoon.hitbox.v3.smc", "wb");
    fwrite(dataB.data(), 1, dataB.size(), pB);
    fclose(pB);

}

Running this patching program yields the table

Facing  	Raw data			Plain hex offsets		Signed dec offsets	
------		--------			-----------------		------------------
Right		00 00 4B 00 F0 FF 10 00		0h, 4Bh, FFF0h, 10h		0, 75, -16, 16			
Down		E0 FF 20 00 00 00 2D 00		FFE0h, 20h, 0, 2Dh		-32, 32, 0, 45	
Left		B5 FF 00 00 F0 FF 10 00		FFB5h, 0h, FFF0h, 10h		-75, 0, -16, 16			
Up		E0 FF 20 00 D3 FF 00 00		FFE0h, 20h, FFD3, 0h		-32, 32, -45, 0		

Find a convenient, buildable version of patcher here: https://github.com/clandrew/lagoonhitbox/

You can use the patcher to change the hitboxes as you want. If this concept seems useful then it’d be a good idea to fuss with the values until they yield something desirable.

Note:

  • All ROM file offsets are on headerless ROMs.
  • The hitboxes calculated from the routine described here is used for both talking to NPCs, and combat. While there might be a motive to affect only combat, there’ve also been complaints that the hitboxes when talking to NPCs are too fussy, so YMMV.
  • If you make the hitboxes obscenely large it can make the game hard to play. For example, if NPCs A and B are standing close to each other, attempting to talk to A might acidentally cause conversation with B.

This post is also available in text form here:

https://raw.githubusercontent.com/clandrew/lagoonhitbox/master/lagoon_patch_info.txt

August 27th, 2020 at 4:57 am | Comments & Trackbacks (0) | Permalink

See a video of it here: https://www.youtube.com/watch?v=lmg9sN9FO-w

Also on Twitter: https://twitter.com/clandrew__/status/1288941082202390533

As a fun thing to do for a few days I made this demo, for background I have programmed on Apple II in the past but in higher-level ways, through BASIC and Logo. The idea here was to program 6502 directly so that I could learn more about the platform. Prior to this I had some background debugging and doing small code patchings in SNES’s 65816 (similar to 6502) and how to use an Apple II computer but no experience at all writing 6502 for it.

I got a book “Assembly Lines” by Richard Wagner which was a lot of help plus “Inside the Apple IIe” by Gary Little. And also a bigger book of opcodes lent to me by a friend and ex-coworker.

Things that were easy

– Trancoding from a .PNG image file on Windows to indexed image data compatible with LORES memory layout. I wrote this command line tool which inputs an image file on Windows and outputs a bunch of data directives for the LORES compatible image data. I referred to the “Inside the Apple IIe” book to source out what the format is. The tool uses WIC to open the image and shoves a bunch of things around. This took like 20min. The code is here
https://github.com/clandrew/demoII/blob/master/ImageTranscoder/ImageTranscoder.cpp

– Testing on real actual hardware. What I mean is, I thought this would shake loose a bunch of problems. Especially around sound. There was conflicting information as to how you’re supposed to access $C030 to sound the speaker. If an opcode technically corresponds to *two* accesses of the I/O port instead of one it could cancel out so no sound. This is an area where emulator would likely be more forgiving. And before you think “just copy from some sample code”, various sample code and documentation contradicted each other. In the end I picked absolute LDA and it worked.

– Modern IDE Now, the bulk of the code was written using Merlin’s interface on the actual Apple II. It’s actually pretty good and I thought it was fine. Once in a while, whenever there was a big messy refactor though I copied the code out of the Apple II data disk and into a Windows filesystem text file because of course that’s a thing. And quickly do the reverse, too. What a time to be alive.

– Debugging. The debugger built into the emulator is the most luxurious type of pampering. This is the reason I don’t know what it is to work or to suffer. You can set breakpoints, conditional breakpoints, view memory and disassembly of what’s executing. From talking to people, “Back in the day” you would debug by breaking into the Monitor or not at all. There are hobbyists writing Apple II code today though and I am pretty sure they all use tools like this. I feel okay with this hybrid way of doing things.

Things that were not easy

– Correct sound. Sound on most Apple II models is emitted in a weird way. You touch a memory-mapped I/O port- and how *frequently* you access the port (read or write) changes the pitch of the sound. A touch followed by 50 NOPs is a different pitch from a touch followed by 100 NOPs. And of course, you should put all of that in a loop or else the sound will be so quick it’s hardly observable. You need very careful counting of clock cycles to play different tones of uniform duration. Yes, calculation involving clock cycles to figure out how to emit notes of *different tones* but *equal length*

– “Holes” and nonlinearity of graphics memory-map. Say you have some image data. It is stored with the top left byte first, and the bottom right byte last. To display it you might think, “just do a memcopy!”. No no no. In graphics memory rows of pixels are stored in all jumbled up order. Even if you store your image data in that same jumbled-up order, there are “holes” in the range which you are not supposed to write to. Because I wanted to keep image positionings flexible for future use I had my source image data stored in straightforward order and copied each pair of rows based on a table of offsets. This wasn’t the absolute worst just fussy.

– Branch distance limit. Because only one signed byte specifies the offset you can’t branch anywhere too far away. To get around this, you branch to an intermediate place which jumps to the final place but also need to take care that the jump isn’t accidentally taken by something else.

– Asymmetry of how you’re allowed to use registers X and Y. For example, when you use indirect-indexed-load with X, it’s pre-indexed but if you do it with Y it’s post-indexed. Merlin’s semantics between these two is ever so slightly different it’s easy to miss and this was the root cause of an annoying bug I fixed Tuesday.

I scoped this project to LORES only so that it would be feasible and also a way to get comfortable with environment, instruction set and functions specific to this computer. The things I’ve heard about HIRES is that it’s fewer colors, higher res, and more gnarly to work with, could also be good to check out at some point.

Some highlights

<– Graphics bug turned a bit a e s t h e t i c. 
The root cause of this was the image loading code would scramble the input image offset passed to it. I was testing out scrolling of images (as a possible future thing to add), so the corruption happened when you hit the “down” key. Scrolling would call the image loading code again with the scrambled registers, loading an image from an incorrect place and changing which incorrect place every time.

Debugger built into the emulator. does you a favor and gives memory-mapped registers helpful human readable names (e.g., SPKR, called CHIRP in my code) rather than raw offsets. This was how I sometimes found out that I was actually using some areas of memory which seem general purpose but are not really.

For the music, I sketched it out a single-channel MIDI in Noteworthy Composer and then started testing different tones to find what ratio of I/O port setting + busy waits would produce which notes. I added the frequencies as text in the staff at the bottom.

You have this problem of “how to emit tones of different pitch but the same duration”.

This problem will be obvious to you if you’ve programmed Apple II like this before, but I’ll say it in case: to emit a tone on Apple II you touch a memory-mapped IO port. How often you touch it determines what tone it is. “Touch” here means read or write.

You’re going to want to touch the port in a loop padded with a bunch of sleeps. To get a lower note, add more sleeps. To get a higher note, use less sleeps*. Easy enough.

*For sleeps it doesn’t literally have to be a sleep, it could be a NOP or anything that takes up time. In my sound code the busy wait is “decrement a counter, branch conditional”. You could expand the gamut of what sounds you can emit by adding an actual NOP or two.

What if you want to emit two different tones which both have the same duration? If you change the number of sleeps, you change the duration. How fussy. Fortunately, we own the code for touching the port. So I fix this by counting the number of clock cycles it takes to emit a baseline tone, and solving for the number of sleeps needed to emit a different tone with the same duration.

My code to emit sound looks like


    *                                       ;CLOCK
    A6 07          TONE   LDX $07           ;3
    A4 06          DUR    LDY $06           ;3
    AD 30 C0              LDA CHIRP         ;4
    88             PCH    DEY               ;2
    D0 FD                 BNE PCH           ;Branch taken: 3. Not taken: 2
    CA                    DEX               ;2
    D0 F5                 BNE DUR           ;Branch taken: 3. Not taken: 2
    60                    RTS               ;6

See there is a routine called TONE, and two loops labeled DUR and PCH.
PCH is an inner loop.
DUR is an outer loop.

I added comments describing how many clock cycles each line will take. For branches the number of clock cycles depends on whether the branch is taken.

Example of usage of TONE- (this part doesn’t vary between pitch
or duration so clocks are not taken into account)

    A9 BC           LDA #188                
    85 06           STA $06                 
    A9 6A           LDA #106                
    85 07           STA $07                 
    20 26 66        JSR TONE                
    60              RTS                     

TONE is subroutine with two arguments: pitch and duration, nicknamed pch and dur below.

TONE takes some number of clock cycles to execute, nicknamed clk below.

We can calculate clk as a function of pch and dur.

Looking at the implementation of TONE, the initial load and return don’t vary so they weren’t taken into consideration. But we can go line by line and calculate how many clock cycles the interesting parts will be

    clk = 0;
    clk += dur * 3;                     // DUR    LDY $06
    clk += dur * 4;                     //        LDA CHIRP
    clk += pch * dur * 2;               // PCH    DEY
    clk += (pch * dur * 3) - (dur * 3); //        BNE PCH (branch taken)
    clk += dur * 2;                     //        BNE PCH (branch not taken)
    clk += dur * 2;                     //        DEX
    clk += (dur * 3) - 3;               //        BNE DUR (branch taken)
    clk += 2;                           //        BNE DUR (branch not taken)

which simplifies down to

clk = dur * (11 + 5 * pch) - 1;

rearranged to solve for dur, you get

clk + 1 = dur * (11 + 5 * pch)

dur = (clk + 1) / (11 + 5 * pch)

This way I got what value of ‘dur’ you need for each pitch to use to get about the same target cycle count as the middle pitch, 138 with duration 144 (picked arbitrarily).

If you are pro you might factor non-sound code (e.g., the loading of graphics images) into your calculations.. fortunately here the amount of time to load graphics and on other control flow ended up being negligible so it didn’t end up mattering

And of course, a good thumbnail is about setting the right expectation.

Source code posted here: https://github.com/clandrew/demoII/blob/master/T.GR.asm

July 31st, 2020 at 5:54 am | Comments & Trackbacks (0) | Permalink

Finished Arcana (SNES).

Play as Rooks, an orphaned magic card user who needs to stop an evil empress vying to take over the kingdom. Rooks also wants to live up to his late father’s legacy.

Turn-based JRPG, Wizardry-like, with “cards” being a prominent visual motif and somewhat gameplay motif. Unique qualities: no backtracking, death of anyone in your party == game over

The use of cards in the gameplay would lead you to think the game has a combat system way more evolved than the old “Fight Magic Item Flee”. It does not.

There is a rock-paper-scissors-style elemental system. The game is balanced such that you can ignore it. There are also four pokemon (“Spirits”) which act like party members except disposable and only their magic is any good.

Pop quiz: magic spell called “Attribute 6”. What does it do, take a guess? Bonus: how it is different from Attribute 5.

I think people might not play this game any more because of the enemy system. What enemy system? Random encounters. How many? A lot. It has one of the worst grinds. Find enclosed: random encounters every two steps in the map, or on simply a 90 degree turn. If not for the in-game map it would have been a big problem. Although there’s items and spells to hightail it out of a dungeon, you always enter a dungeon from the very beginning.

Lack of checkpointing is a problem for one of the largest areas called “Stavery Tower”, a twelve-floor maze. You can’t save while in a dungeon, not even a save-to-be-deleted-on-resume (those are not popular on SNES platform anyway). So you will need to book one to three hours per game session. Alternatively, you can leave your SNES on and hope there isn’t a power outage, or use a piece of technology which rhymes with asdflemulator.

Still, the first 5 minutes and the last 30 minutes were Awesome. This game has a great soundtrack and visual style with a lot of character. The final boss concept is extremely cool. This game, you can tell what they were going for.

June 2nd, 2020 at 2:20 am | Comments & Trackbacks (0) | Permalink

The portraits you make on IR-7000 are not merely for spicing up your address book. They are integrated with a “game” and that game is called Brain Drain.

In Brain Drain you choose a portrait to play as, and face off against a different portrait using psychic abilities. It is a bit thin on gameplay and soundtrack but it is kind of fun.

Eons ago I wanted to see if the outcomes of the game were pre-determined based on the character features or if there is some RNG. So I cloned one character to see. It turns out, there is some RNG.

video making of
May 29th, 2020 at 4:17 am | Comments & Trackbacks (0) | Permalink

The mid-late 90s personal organizer. The big companies making them were Sharp and Casio, although there were a lot of other ones.

I still have mine, the IR-7000, made by Sega. Looks like this:

Still works, woot.

Like the standard organizer it flipped open and had a QWERTY keyboard. It could store notes, addresses, do calculator functions, time zone calculations, set an alarm and show you a calendar. Plus, a hilarious “human portrait” maker along with a simple game you can play with the portraits.

If two people had IR-7000, you could use its infra-red communication to exchange messages, but I never came across someone who also had this organizer. The industry was really fragmented toward lots of different organizers and everyone seemed to have a different one.

The modern equivalent today would be something between a cellphone or tablet. Cellphone and tablet subsume all of the functionality that these organizers had, but in much more general-purpose ways with fuller software stacks. I can understand why these fuller software stacks are desirable yet in my heart I’m always keeping a space for the long battery life and reliability of this specialized tool for specific things.

May 27th, 2020 at 5:38 am | Comments & Trackbacks (3) | Permalink

A response to this: “What were the five most important video games to you throughout your youth/teen years? No curating to look cool/interesting please”

1. Lagoon (SNES)
Comment: The first RPG I played. The environments and sense of world-building were a lot deeper than the other games I was familiar with, so it was easy to get really invested. The ultra-small hitboxes and the lack of user-friendliness I wrote off as “the game is hard” not “the game is flawed”, so I spent effort getting better at the action mechanic. Nowadays the time I spend on Lagoon is part nostalgia, part meme-ery. It affected the ways I perceive other games then and today and led into ARPGs like Souls.

I’ve been maintaining a fan site for Lagoon for 13 years.

2. Speedway Classic (Apple II)
Comment: The game had this really cool looking 3D effect and a memorable intro. It was my go-to game on this platform narrowly beating out Lemonade Stand. Since I coded on this platform it was helpful to see the connection from code –> possible program.

3. Final Fantasy 3 or 6 (SNES)
Comment: This is a very normie pick. You know I’m taking the no-curating seriously.

This game for me is tied up with a) interest in video game characters, since before that I didn’t play many heavily character-based games) first experiences in PC usage when trying to get more content for it.

I first played it around 1995 and wanted more content about it (besides a magazine article) but it was hard to find any. I didn’t have a lot of experience using PCs or the internet but it was worth a try.

The best time was when my parents took me to a computer exhibit at the <city name> Science Center, they had a bunch of computers hooked up with internet access. It was a bit rough at the start because it was not like the Apple IIc (at home) or the IIe (at school) and the GUI/mouse/internet browser was a lot different, from what I recall the demo machines were Windows 95 with Netscape Navigator. But I got to use it to find a lot of cool things- secrets, strategies and so on.

4. Dynasty Warriors 4 (PS2)
Comment: This was when I was a teenager. Since I was under a lot of stress in high school, overbooked with way too many clubs + part time job, it was a good de-stresser.

5. Tekken 2 (Arcade)
Comment: Noise, dirty floors, cigarette smoke, frantically pushing the button + kicking the machine to un-jam your quarter. There were a couple dive-y arcades I used to go to. I used to always play as Angel on 2, Yoshimitsu on others, and got decent at those but didn’t know any other moveset. Nowadays I can do some combos with Paul.

Honorable mention: Fatty Bear’s Birthday Surprise, a point-and-click game for kids. The game is just so weird yet full of cool things. First seen at my daycare. I later got the CD from somewhere when I was in high school because I was nostalgic then too.

Scan of my strategy guide that I wrote in, there was something missing. My printing has changed.
The first image (non text) I printed from a computer. It’s been re-pinned-up many times.
May 22nd, 2020 at 12:24 am | Comments & Trackbacks (0) | Permalink

They are so uncommon let’s take a second to appreciate SNES games with functional loading screens.


Game: Civilization 1
Loading time: ~40 seconds
Purpose: When you create a new game. It is procedurally generating the terrain of the map, placing the other civilizations you’re playing with. Rather than a fixed map, it gets randomly generated each time plus the algorithm is customizeable to include different kinds of climates and features. The longest load time I have seen on this platform.

A video: https://youtu.be/oWtVe2qm7_w?t=129 (not mine, random search off youtube)


Game: Romance of the Three Kingdoms IV: Wall of Fire
Loading time: 2 or 3 seconds
Purpose: When you create a new game. Procedurally generating what commander has what resource allocations, which officers are where. Although the game comes with a fixed set of “scenarios”, you can create your own commander and/or officers and choose who to control. I believe this, plus the difficulty level affects where the game places things and there are too many combinations to pre-compute them.


Game: Another World (also called, Out of this World)
Loading time: ~10 seconds
Purpose: Transitioning out of a simple menu, into the game

This game is, in a word, ambitious. Big, lush backgrounds with lots of things animating. Nothing looks like ‘sprites’; they look like 2D vector graphics rasterized to low resolution to be honest.

This game was originally for Amiga and ported to SNES. The graphics have a unique style which unfortunately doesn’t fit well to SNES technical constraints, which tend to involve either conventional 2D graphics modes with heavily re-used sprites, or just Mode 7– neither of which fit this game well. Now that’s not to say it can’t work. Take, for example, the backgrounds of Super Mario RPG or Wonder Project J. You can make smart judgments about when to re-use sprites and try to hide them among the other elements. Of course, those games were designed from the ground-up for SNES. For this game, conversion to sprites would be an after-thought with the port. The sheer amount of graphics this game has is very large and scenes are organized in ways that are hard to break down into patterned elements.

While I don’t tend to like this type of game- the latency of controls is so slow and loose, for one– I respect its commitment to the unique art style. Given everything this game has going on, the loading screen is not frivolous.


Game: Sim City
Loading time: ~12 seconds
Purpose: When previewing the terrain on which to build your city- there are 1000 terrains (e.g., random seeds). Note that the load time is NOT just for creating a game with the level- it’s to let you view a small 120×100 image. This, plus the instantaneous “OK” button tells us two things. First, there was not enough space on the cart to store 1000 of these images. Second, unpacking the preview image is about the same as unpacking the full map. While I think all of this is okay, they could have done with fewer better-optimized seeds. Fortunately the instruction manual has a couple pages of previews of maps which you can flip through quickly.


Game: Batman Forever
Loading time: ~5 seconds
Purpose: Transitioning out of cutscene into gameplay. Likely to be graphics-related. There are big, detailed sprites with lots of frames of animation.

Although there’s an explanation for a load screen, it may not have been completely necessary. At 24Mbit, the cart is not that small; it’s very likely the graphics could fit without super aggressive compression schemes. Some contributing factors to the need for load time may have been 1) the fact that this game is a port, and there wasn’t time to optimize for any particular platform, and 2) these flashy 3D wireframe-map montage scenes, which would require different types of data and loader code.

Although this game gets a bad rap I respect its live-action-to-low-res-low-color Mortal Kombat aesthetic.


Maybe others I haven’t encountered yet.

See, a couple big things affecting our modern conception of loading screens are optical media and network latency’s failure to keep up with increasing size of game payloads. Computationally, modern computers have advanced a lot to the point where it is rare to see games spinning on procedural content like this, but it is common to spend a lot of time copying game assets from an optical medium to faster local solid-state storage, or downloading game assets from the internet.

There have been some modern efforts to curb load times. For example the Nintendo Switch had a return to a faster-than-optical-disk game media. You know, a cartridge. However, many Nintendo Switch games- non-procedural, fixed-level action games do have loading screens- screens which would have been unacceptable in 1995 but are acceptable now since we are used to them.

February 9th, 2020 at 10:51 pm | Comments & Trackbacks (0) | Permalink

Earth, who wore it better?

left: E. V. O. (SNES) right: Civilization (SNES)

February 8th, 2020 at 6:40 am | Comments & Trackbacks (0) | Permalink

Finished Donkey Kong Country 2: Diddy’s Kong Quest. This was a co-operative gameplay I finished with a couple friends as a follow up to our completion of Donkey Kong Country 1.

Completion time: about a year, since we played in small increments once in a while.

Game is longer and a lot spicier than 1. Some levels gave us a very hard time. Yeah I am a big fan of the minecart autoscroller.

Time and time again I am really impressed by how much graphics can be fit on a SNES cart. The levels are all full of lush, irregularly-shaped (non-tiled-looking) elements without a lot of repetition; sprites are big with lots of animations, lots of frames in each one; backgrounds have a lot of variety. At the same time, it also doesn’t appear they got too fancy with storage of graphics. I looked at the ROM and could spot at least some 4bpp graphics data stored plain, uncompressed. They just used a big cartridge– 32mb, big for this console.

The technical high points for this game are offline rather than online of course. These are pre-rendered 3D models rendered with Silicon Graphics software, likely on CPU, baked into a bunch of 2D sprites. Everyone likes this. I can’t think of a single fourth-console-generation game which does this and looks bad. More games would have surely done it if it wasn’t so expensive back then, in terms of money.

Only criticism is I was disappointed with the final boss fight. The fight from DKC1 established a pretty high bar, to be fair- it had that cool “fake credits then surprise there’s more”. Not saying DKC2 had to replicate that, but maybe the final boss could have had something else cool. Nope. You fight King K Rool on a pirate ship. He throws projectiles. Pretty standard stuff. Maybe they thought no one would get far enough to see that, due to Windy H̶e̶l̶l̶ Well.

February 8th, 2020 at 6:23 am | Comments & Trackbacks (0) | Permalink