This document describes a 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. After all the players, there is [L0] [L1] [Location string] // A two-byte low-endian length L, then a variable length string of length L-2 for the location the team // represents. E.g., "Edmonton" // This string appears in the GAME SETUP string beside labels like "Home" and "Visitor". // Note that it's not the label that appears right below players' names, though. [L0] [L1] [Location acronym] // A two-byte low-endian length L, then a variable length string of length L-2 for acronym for the above. // E.g., "EDM" for Edmonton. [L0] [L1] [Team name] // A two-byte low-endian length L, then a variable length string of length L-2 for the name of the team. // E.g., "Oilers". [L0] [L1] [Team venue] // A two-byte low-endian length L, then a variable length string of length L-2 for the venue the team // plays at for home games. E.g., "Northlands Coliseum". And then subsequent team data can follow immediately after with no delimiter. 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 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 and take care to update the length, you'd need to either pad with spaces (could cause things to display weirdly) or 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 byes, 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 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. The program, which does the above, is here https://github.com/clandrew/nhl94e Enjoy