Reverse Engineering the International Superstar Soccer Password System Part 2

Previously

Last time, a year ago, I finally got my vacations and decided to enjoy my time by totally disconecting from the grid and...Just kidding, I decided to disassemble a game to answer a question I had for years.

The game was International super star soccer and the question was, why the password for that game was so big?

The answer then was that the password was basically a savestate with some encoding and a checksum.

I got so far to create actually valid passwords, without any control on where they lead you.

I promissed then I would go back to it and find out what the values on the password actually mean.

Well my dear blog reader/AI scrapper, it has been a year and I am on vacations again, so the time finally came! Let's do it!

Note: If you just want the results, check the generator here or jump directly to the code

Before starting

Using AI

I know everyone talks about it all the time, so feel free to skip this section. I think it is worth mentioning as this is not the typical AI usage.

I used a lot of AI this time (chatgpt and copilot).

It was really hit or miss.

AI really shined when I was close to the answer, I knew what part of the problem I was strugling with and I was able to recognize the answer was right. On those cases, the best results came when I didn't suggest any solution. The AI would suggest some things and I would check if they worked.

But then there were two other situations where AI was very bad (as in making me go on a totally wrong direction or being completetely useless). First was when the next step was not clear. The AI was always repeating the obvious and not coming up with any idea which I did not thougt about already. Second was when I was pursuing a wrong idea. The AI would jump hoops to keep on track, even if the idea was not working.

At some point it even added a bunch of magic constants just to make the logic work.

Anyway, in the end I think I would not be able to do this without AI. It is a really great tool for back and fort and the insights, although not entirely correct, helped a lot to get unstuck.

What we know so far

The password is a byte array delimited by 0xff with a maximum of 60 bytes.

The password is decoded using a counter that is incremented in counts of 6. The counter is divided by 8. The division result rounded down is the position on the decoded password to write the value using OR. The remainder is used to rotate the value.

I haven't found out the role for the value on the first position of the decoded password. It is used as an OR mask for other bytes.

The second value is a checksum.

All other values depend on the password type, that is calculated from bytes 2 and 3. Each type has a fixed password size.

Password numeric value is at 0x7ee2d0 address on memory on password screen.

The basic game types we get from the passwords so far are:

  • World series
  • International
  • Short league
  • Short tournament
  • Scenario

With all this information, it is possible to create a generator that rewrite passwords to make them valid passwords.

Cool, but pretty useless....

Maybe we can do better?

Start from the encoding

While previously we went with reverse engineering the decoding, to figure out what the values actually mean it is best to try reverse engineering the encoding part.

First step is to choose a game

I chose International cup with Brazil.

Got this group:

Brazil - Columbia - Argentina

First game, Columbia

On Brazil Stadium

Got a red card

Lost 4x0

Columbia 1 win 3 points

Argentina 0

Brazil 1 loss 0 points

Next game: Argentina

Password:

=YN♪< ↑T0π M★

But saving the gamestate before seeing the password and then re-opening the password screen again gave a different password!!!

nV★Kh -~3qZ ↓J

Interesting.

I will start with an assumption. Resources are scarce, memory is fixed, so I will assume the same place used to decode the passord is used for encoding. If this is true, the moment the password is encrypted, it will show up on the same memory address: 0x7ee2d0.

And then

Bingo!

Onto the next step!

Time to follow the stack

First I added a write breakpoint to the address 0x7ee2d0. First code that touches it is a clean up.

86cb0e	mvn	$7e, $7e

MVN is Move Memory Negative. This will fill the password area with 0xff.

And after a lot of clicking on step over, I got this at 7ee2d0

25 22 1f 28 27 12 1b 0d 22 2e 1d 29 ff ff ff ff
ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff
ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff
ff ff ff ff ff ff ff ff ff ff ff ff

I need to answer two questions:

Where is the value bein encrypted and how?

Why is it random, and can I stabilize it to make it easier to debug?

Let's focus on the first one. I will check where the first value comes from. Maybe it is encrypted somewhere else and copied?

This is the instruction that writes the first value:

86cb87	sta	$7ee2d0,x	[7ee2d0]

This stores the value from a on our memory address, so we just need to find where the value comes from.

This is our code:

86cb64	sty	$06	[000006]
86cb66	jsl	$86cafe	[86cafe]
86cb6a	stz	$00	[000000]
86cb6c	stz	$02	[000002]
86cb6e	lda	$02	[000002]
86cb70	ldy	#$0008
86cb73	jsl	$808b4c	[808b4c]
86cb77	tax
86cb78	lda	$1700,x	[811700]
86cb7b	clc
86cb7c	rol
86cb7d	ror
86cb7e	dey
86cb7f	bpl	$cb7d	[86cb7d]
86cb81	ldx	$00	[000000]
86cb83	sep	#$20
86cb85	and	#$3f
86cb87	sta	$7ee2d0,x	[7ee2d0]
86cb8b	rep	#$20
86cb8d	lda	$02	[000002]
86cb8f	clc
86cb90	adc	#$0006
86cb93	sta	$02	[000002]
86cb95	inc	$00	[000000]
86cb97	lda	$00	[000000]
86cb99	cmp	$06	[000006]
86cb9b	bcc	$cb6e	[86cb6e]
86cb9d	rtl

And the command that loads a value to a (lda) seems to get it from address 0x811700.

We put a write breakpoint there and I ended up on the command that clears that memory region.

8089fd	rep	#$30
8089ff	phb
808a00	lda	#$0000
808a03	sta	$001700	[001700]
808a07	lda	#$0039
808a0a	ldx	#$1701
808a0d	txy
808a0e	iny
808a0f	mvn	$00, $00
808a12	plb
808a13	rtl

And stepping further:

83f471	lda	#$0008
83f474	sta	$00	[000000]
83f476	lda	$78	[000078] ; <<<<<<----- here
83f478	sta	$1700	[811700]
83f47b	ldx	#$1702

Aha, value from address 78 is the first value written. That low address sounds like something special.

If I look at the memory at address 78, it looks like a value going up all the time.

This could be a clock or maybe something else, but it really looks like a source of random values.

I guess we should not mess with address 78, but what happens when we freeze 0x811700?

We can find out by creating a cheat.

All those cheats you can use on emulators are, in fact, freezing an address in memory with a certain value.

So for example, setting infinite lives is just finding the live counter in memory and freezing it.

On our case, we want to set address 0x811700 to 00, so we create the cheat 81170000

And... success!

Password now is always B~DCD GC5K* KB

And we found out that the password is intentionally made to not repeat (or at least has 256 variations). Why?

I have no idea.

Anyway, now we can try figuring out the rest of the values. I know from my previous vacation that the second byte is a checksum.

So, is it calculated after? Where is the encoding? What are the original values?

Let's follow the code and see where the next values come from.

I will use this time chatgpt to help and comment on what each command is doing (and hopefully it will not hallucinate)

83f46d  jsl  $8089fd [8089fd]   # JSL = Jump to Subroutine Long:
                               # pushes return address (and sets it up for RTL),
                               # then loads the 24-bit PC (bank:addr) = $80:89FD.

83f471  lda  #$0008             # LDA immediate: load the constant $0008 into A.

83f474  sta  $00 [000000]       # STA direct page: store A into RAM at DP+$00
                               # (here it resolves to absolute address $000000).

83f476  lda  $78 [000078]       # LDA direct page: load A from RAM at DP+$78
                               # (here it resolves to $000078).

83f478  sta  $1700 [811700]     # STA absolute: store A into address $1700
                               # (shown effective address is $81:1700 in this trace).

83f47b  ldx  #$1702             # LDX immediate: load the constant $1702 into X.

83f47e  lda  $1648 [811648]     # LDA absolute: load A from address $1648
                               # (effective address shown as $81:1648).

83f481  bit  #$0020             # BIT immediate: AND A with $0020 to set Z flag.
                               # Z=1 if (A & $0020)==0, Z=0 otherwise.
                               # (Immediate BIT does not modify A.)

83f484  beq  $f489 [83f489]     # BEQ: branch if Equal (i.e., Z=1) to $83:F489.

83f486  jmp  $f54d [83f54d]     # JMP absolute: unconditional jump to $83:F54D.

83f489  bit  #$0004             # BIT immediate: test bit $0004 in A via AND,
                               # updates Z accordingly.

83f48c  bne  $f4ab [83f4ab]     # BNE: branch if Not Equal (i.e., Z=0) to $83:F4AB.

83f48e  bit  #$0002             # BIT immediate: test bit $0002 in A, updates Z.

83f491  beq  $f496 [83f496]     # BEQ: branch if Z=1 to $83:F496.

83f493  jmp  $f581 [83f581]     # JMP: unconditional jump to $83:F581.

83f496  bit  #$0400             # BIT immediate: test bit $0400 in A, updates Z.

83f499  beq  $f49e [83f49e]     # BEQ: branch if Z=1 to $83:F49E.

83f49b  jmp  $f5c0 [83f5c0]     # JMP: unconditional jump to $83:F5C0.

83f49e  ldy  #$b4db             # LDY immediate: load the constant $B4DB into Y.

83f4a1  jsr  $f5df [83f5df]     # JSR absolute: jump to subroutine at $83:F5DF,
                               # pushing 16-bit return address (for RTS).

83f4a4  lda  #$0014             # LDA immediate: load constant $0014 into A.

83f4a7  jsr  $f618 [83f618]     # JSR: call subroutine at $83:F618 (return via RTS).

83f4aa  rtl                      # RTL = Return from Long subroutine:
                               # pulls 24-bit return address set up by JSL and returns.

The constants 0x0020 0x0400 and others are really familiar (from part 1). They look like the bits that indicate what is the type of the password.

I would guess address 0x811648 holds the type of the current game, and then each type has a different logic to encode the values.

So, I reloaded the game and selected different game types. Turns out this value changes as soon as I select a game type:

- x04 x02 for international

- x21 x00 for world series

- x00 x10 for scenario

- x03 x00 for short league

- x01 x44 for short tournament

I will also decode the password with my previous password generator to check what are the values:

B~DCD GC5K* KB

[0x00, 0x2c, 0x04, 0x02, 0x11, 0x90, 0x07, 0x7e, 0x00, 0x00]

Which from what we know so far is

0x00 - Randomizer (always zero due our cheat)

0x2c - Checksum

0x04, 0x02 - Game mode

Which leaves for game state:

0x11, 0x90, 0x07, 0x7e, 0x00, 0x00 (where maybe 0x00 is a terminator?)

Before trying to keep reverse engineering the code, maybe we can do a trial and error and change some values, then reencode it.

I also played another game with same conditions where I won. The password with fixed random was:

B~NCD GC5K* K1

Incredbly close to the one where I lost. I think maybe that's the reason for the randomizer.

The decoded password is:

[0x00, 0xac, 0x04, 0x02, 0x11, 0x90, 0x07, 0x7e, 0x80, 0x00]

Which means the only difference is on byte index 8, or address 811708

We can put a write breakpoint there.

And we end up on ror $00, x (comments partially from chatgpt):

; Where
; LDA – Load Accumulator (loads a value into register A)
; STA – Store Accumulator (stores A into memory)
; LDY – Load Index Y (loads a value into register Y)
; STY – Store Index Y (stores Y into memory)
; INY – Increment Y (adds 1 to register Y)
; INX – Increment X (adds 1 to register X)
; AND – Logical AND (bitwise AND between A and value, result in A)
; LSR – Logical Shift Right (shifts bits right, bit 0 goes to Carry)
; ROR – Rotate Right (rotates bits right through Carry)
; DEC – Decrement (subtracts 1 from a memory value)
; BEQ – Branch if Equal (branches if Zero flag is set)
; BNE – Branch if Not Equal (branches if Zero flag is clear)
; BRA – Branch Always (unconditional branch)
; SEP – Set Processor Status bits (sets flags in the status register)
; REP – Reset Processor Status bits (clears flags in the status register)
; RTS – Return from Subroutine (returns from a JSR call)
; Low values are variables on the stack
;  

83f5df	lda	#$007e ; Load constant $007E into A (16-bit here), Looks like a bank/value used later by the routine (often "work RAM bank" context).
83f5e2	sta	$12	[000012] ; Store A into direct page $12 (so $12/$13 now hold $007E). Likely a parameter / bank selector / base value for later pointer math.
83f5e4	sty	$08	[000008] ; Save incoming Y into DP $08/$09. Y is being used as an iterator over a table; this preserves the start.
  83f5e6	ldy	$08	[000008] ; Loop start. Restore Y from DP $08/$09. Start/continue outer loop using Y as "current table entry pointer".
  83f5e8	lda	$0000,y	[81b3c4] ; Load a 16-bit value from (base+$0000+Y). This is probably the first field of a table entry (often a pointer/offset).
  83f5eb	beq	$f617	[83f617] ; If that 16-bit value is zero, table terminator reached -> exit (RTS).
  83f5ed	sta	$10	[000010] ; Save that 16-bit value into DP $10/$11. Later used as an *indirect pointer* via LDA [$10].
  83f5ef	lda	$0002,y	[81b3c6] ; Load the next 16-bit field of the table entry (at Y+2).
  83f5f2	and	#$00ff ; Mask to low byte only. So the table's "count/length" is stored in the low byte of this word.
  83f5f5	sta	$0c	[00000c] ; Store that count into DP $0C/$0D. Inner-loop counter: how many bits/steps to process for this entry.
  83f5f7	iny ; Y += 1
  83f5f8	iny ; Y += 1
  83f5f9	iny ; Y += 1  (total Y += 3)
                ; Advance Y to the *next* table entry.
                ; (This implies entries are packed oddly: 3 bytes step, or you're iterating a byte stream
                ;  while reading words from it. Debugger still shows word reads at $0000,y and $0002,y.)
  83f5fa	sty	$08	[000008] ; Save updated Y back into DP $08/$09. So the outer loop can resume from the next entry after finishing this one.
  83f5fc	lda	[$10]	[7edc02] ; Load A from the *far indirect* address in DP $10/$11 (24-bit pointer via DBR). This is the *source byte/word* being encoded into the password buffer via rotates.
  83f5fe	sep	#$20 ; Set M flag => A becomes 8-bit (accumulator is now 8-bit wide). From here, STA/LSR operate on a single byte value.
  83f600	sta	$0e	[00000e] ; Store the 8-bit A into DP $0E. Working register: current source byte being shifted out bit-by-bit.
    83f602	lsr	$0e	[00000e] ; Loop start. Logical Shift Right DP $0E. Bit0 of $0E goes into Carry (C), and $0E shifts right (fills with 0).
                             ; This is producing one bit at a time in C for the next rotate.
    83f604	ror	$00,x	[001707] ; Value is written here
                                 ; Rotate Right the byte at (DP $00 + X) through Carry.
                                 ; This is the key write:
                                 ; - old Carry becomes bit7 of the destination byte
                                 ; - destination bit0 becomes new Carry
                                 ; Net effect: you are *packing bits* from the source stream into a byte array at $00+X,
                                 ; one bit per iteration, MSB-first injection (because Carry goes into bit7).
    83f606	dec	$00	[000000]     ; Decrement DP $00. DP $00 is acting like "bits remaining in current output byte" (a countdown).
    83f608	bne	$f60f	[83f60f] ; If DP $00 != 0, keep using the same output byte ($00+X). When it hits 0, you move to the next output byte.
    83f60a	inx ; X++ (move to next output byte in the destination buffer).
    83f60b	lda	#$08 ; Load 8 into A (still 8-bit due to SEP #$20).
    83f60d	sta	$00	[000000] ; Reset DP $00 to 8. That means: "we have 8 bits to fill in this new output byte".
    83f60f	dec	$0c	[00000c] ; Decrement the per-entry bit/step counter.
    83f611	bne	$f602	[83f602] ; Loops to 83f602
                                 ; If more bits/steps remain for this entry, loop back:
                                 ; - shift next bit out of $0E (LSR $0E)
                                 ; - rotate it into destination (ROR $00,X)
                                 ; Repeat until $0C reaches zero.
  83f613	rep	#$20 ; Clear M flag => A becomes 16-bit again
83f615	bra	$f5e6	[83f5e6] ; Loops to 82f5e6
                             ; Unconditional branch back to outer loop:
                             ; process next table entry (Y restored from $08).
83f617	rts ; Return from subroutine (JSR caller).

So basically what is happening (simplified):

Parameter b3a7 (comes from y register)
Parameter 1702 (comes from x register)

var passwordAddressOnMemory = 1702 // this is x register during the entire logic
var address = 007e // we call $12 address for commodity
var addressCursor = b3a7 // we call $08 addressCursor
var bitStep = 8 // It starts with 8, this is $00, this is the counter for the bit output
/// loop
while(true) { // we jump out when valueFromCursor is zero
  var valueFromCursor = loadValueFrom(addressCursor)
  if (valueFromCursor == 0) return
  var currentCursorAddress = valueFromCursor // we call $10 currentCursorAddress 
  var bitCount = loadValueFrom(addressCursor + 2) & 00ff // get the bit count (we get only the low byte), we call $0c bitCount
  addressCursor = addressCursor + 3 // advance 3 bytes on the cursor 
  var valueToUnpack = loadValueFrom(currentCursorAddress) // load actual value from cursor start, for example on first pass value comes from 811648 04 02 (0204) 
  //  we call $0e valueToUnpack
  // loop start
  while(bitCount > 0){
    shiftLeftRotateRight(valueToUnpack, passwordAddressOnMemory) // this is a very clever trick
    // There is a register, the Carry flag that in this case serves like an extra bit when shifting left
    // So, valueToUnpack loses a bit to the carry flag
    // Then, when rotating Right the value already on the password the carry bit is used as the last bit
    // So it enters the value on the current password byte (811702...811708) 
    bitStep -- // this counts from 8 to 0 and counts when it is time to go to the next pass output
    if(bitStep == 0) {
      passwordAddressOnMemory++ // actualy the x register, command inx
      bitStep = 8 // on $00, commands lda #$08 writes8 on register a, sta $00 to put the value on the stack at $00
    }
    bitCount --
  }
}

Summarizing, it is a table of memory addresses wth bit counts. For each address, the number of bits is read and concatenated on the destination.

This is a tecnique known as bitpacking.

It makes a lot of sense here because the password must be small. It also explains why changing the characteres on the password have random results. That's because the values do not align with the characters.

So, knowing how many entries are on the table and their sizes we can create a function that generates the password and figure out what each entry represents by trial and error. Hopefully all other password types work the same...

Hey, Let's look at some raw memory :)

First the address/bit count table. It is a 3 byte array terminated by null at address b3a7.

48 16 08 
49 16 08 
40 16 04 
52 16 07 
9c 1f 03 
00 00 00

Or writing it down and using little endian:

How many bits to copy Address where to copy them from
8 1648
8 1649
4 1640
7 1652
3 1f9c

And from those addresses we get:

Right from the start we see some familiar values. 0x04 0x02 is the game type.

Then some other values that we don't know yet. And then that is it. But... It does not fit the whole password.

What?

After more time I care to admit, I realized it was called twice!! Let's see from where by following where it returns to.

83f4ab	ldy	#$b3a7
83f4ae	jsr	$f5df	[83f5df]
...
83f4d2	ldy	#$b3bb
83f4d5	jsr	$f5df	[83f5df]
...
83f4f2	rtl

So the password is compose by two address tables. $b3a7 and $b3bb.

Let's see b3bb:

00 dc 06
01 dc 06
02 dc 06
f3 dd 02
00 00 00  

And together with our previous entry:

How many bits to copy Address where to copy them from
8 1648
8 1649
4 1640
7 1652
3 1f9c
6 dc00
6 dc01
6 dc02
2 ddf3

Total 44 bits

We can check the values on the register. We need to log the command after the change so we see the values:

lda	$0002,y
and	#$00ff 
sta	$0c ; Log here, register a has the bit count 
...
lda	[$10] 
sep	#$20 ; Log here, register a has the value to be written
...
 
See that it starts on 1702 because 1700 is the randomizer and 1701 is reserved for the checksum.
83f5f5 sta $0c        [00000c] A:0008 X:1702 Y:b3a7 S:0190 D:0000 DB:81 .....I.. V: 48 H:296 F:33
83f5fe sep #$20                A:0204 X:1702 Y:b3aa S:0190 D:0000 DB:81 .....I.. V: 48 H:331 F:33

write 8 bits 0x04

83f5f5 sta $0c        [00000c] A:0008 X:1703 Y:b3aa S:0190 D:0000 DB:81 .....I.. V: 50 H: 82 F:33
83f5fe sep #$20                A:0002 X:1703 Y:b3ad S:0190 D:0000 DB:81 .....I.. V: 50 H:118 F:33

write 8 bits 0x02

83f5f5 sta $0c        [00000c] A:0004 X:1704 Y:b3ad S:0190 D:0000 DB:81 .....I.. V: 51 H:219 F:33
83f5fe sep #$20                A:0001 X:1704 Y:b3b0 S:0190 D:0000 DB:81 .....I.. V: 51 H:255 F:33

write 4 bits 0x01

83f5f5 sta $0c        [00000c] A:0007 X:1704 Y:b3b0 S:0190 D:0000 DB:81 .....I.. V: 52 H:158 F:33
83f5fe sep #$20                A:0001 X:1704 Y:b3b3 S:0190 D:0000 DB:81 .....I.. V: 52 H:194 F:33

write 7 bits 0x01

83f5f5 sta $0c        [00000c] A:0003 X:1705 Y:b3b3 S:0190 D:0000 DB:81 .....I.. V: 53 H:241 F:33
83f5fe sep #$20                A:0002 X:1705 Y:b3b6 S:0190 D:0000 DB:81 .....I.. V: 53 H:277 F:33

write 3 bits 0x02

83f5f5 sta $0c        [00000c] A:0006 X:1705 Y:b3bb S:0190 D:0000 DB:81 .....I.. V: 54 H:295 F:33
83f5fe sep #$20                A:201e X:1705 Y:b3be S:0190 D:0000 DB:81 .....I.. V: 54 H:330 F:33

write 6 bits 0x1e

83f5f5 sta $0c        [00000c] A:0006 X:1706 Y:b3be S:0190 D:0000 DB:81 .....I.. V: 55 H:332 F:33
83f5fe sep #$20                A:1f20 X:1706 Y:b3c1 S:0190 D:0000 DB:81 .....I.. V: 56 H: 28 F:33

write 6 bits 0x20

83f5f5 sta $0c        [00000c] A:0006 X:1707 Y:b3c1 S:0190 D:0000 DB:81 .....I.. V: 57 H: 30 F:33
83f5fe sep #$20                A:ff1f X:1707 Y:b3c4 S:0190 D:0000 DB:81 N....I.. V: 57 H: 66 F:33

write 6 bits 0x1f

83f5f5 sta $0c        [00000c] A:0002 X:1708 Y:b3c4 S:0190 D:0000 DB:81 .....I.. V: 58 H: 68 F:33
83f5fe sep #$20                A:0000 X:1708 Y:b3c7 S:0190 D:0000 DB:81 .....IZ. V: 58 H:104 F:33

write 2 bits 0x00

And we know the decoded result is:

[0x00, 0x2c, 0x04, 0x02, 0x11, 0x90, 0x07, 0x7e, 0x00, 0x00]  

So, does it match? Let's test:

I will use Javascript because it is so easy to just run it on dev tools and also I will be reusing the code for the password generator.

function bitPackValues(values) {
  // result[0]=randomizer, result[1]=checksum placeholder
  // bytes written start at result[2] (1702).
  const result = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; // will change bytes as needed

  let bitsLeftInCurrentByte = 8; // corresponds to bits remaining.
  let currentIndex = 2;          // starting at 0x1702
  let carry = 0;                 // CPU carry flag

  for (const [bitCount, value] of values) {
    let src = value;

    for (let i = 0; i < bitCount; i++) {
      // Carry gets bit at position 0, src shifts right, bit at position 7 fills is now 0
      // Just like on the actual game
      carry = src & 1;
      src = (src >>> 1) & 0xff;

      let dest = result[currentIndex] & 0xff; // we keep it byte sized
      // We move one to the right and write the carry on the 8 bit
      // In other words, the bit enters on the left
      dest = ((dest >>> 1) | (carry << 7)) & 0xff;
      result[currentIndex] = dest;
      bitsLeftInCurrentByte--;
      if (bitsLeftInCurrentByte === 0) { // no more space, advance one byte
        currentIndex++;
        bitsLeftInCurrentByte = 8;
      }
    }
  }

  return result;
}

function hex(n) { return "0x" + n.toString(16).padStart(2, "0"); }

// bits and value
const valuesForInternationalEliminationPhaseBrazilLost = [
  [8,  0x04],
  [8,  0x02],
  [4,  0x01],
  [7,  0x01],
  [3,  0x02],
  [6,  0x1e],
  [6,  0x20],
  [6,  0x1f],
  [2,  0x00],
];
const passwordDecoded = bitPackValues(valuesForInternationalEliminationPhaseBrazilLost);

// Expected decoded payload bytes (starting at 1702 == decoded[2])
// This is Brazil lost B~DCD GC5K* KB
const expected = [0x00, 0x2c, 0x04, 0x02, 0x11, 0x90, 0x07, 0x7e, 0x00, 0x00];

console.log("Computed:", passwordDecoded.map(hex));
console.log("Expected:", expected.map(hex));

And the output (ignore first two bytes):

Computed: (10) ['0x00', '0x00', '0x04', '0x02', '0x11', '0x90', '0x07', '0x7e', '0x00', '0x00']
Expected: (10) ['0x00', '0x2c', '0x04', '0x02', '0x11', '0x90', '0x07', '0x7e', '0x00', '0x00']

So, even though we don't know what these values are, we can already in theory generate a password!!!

All we need is to use the encoding logic.

Another interesting thing seems to be that the data part os the password has size 64 bits, but our data table only uses 50 bits.

And, we have enough information to extract the values from existing passwords so we can compare them and try to find what each parameter does.

We just need the code to do it.

function extractParameters(decoded) {
  // fixed, known by the format
  const bitCounts = [8, 8, 4, 7, 3, 6, 6, 6, 2];

  // Step 1: figure out how many bits are used in each payload byte (starting at decoded[2])
  const bitsUsedInByte = [];
  let bitsLeftInCurrentByte = 8;
  let byteNumber = 0; // 0 means decoded[2]

  for (const bitCount of bitCounts) {
    for (let i = 0; i < bitCount; i++) {
      bitsUsedInByte[byteNumber] = (bitsUsedInByte[byteNumber] || 0) + 1;

      bitsLeftInCurrentByte--;
      if (bitsLeftInCurrentByte === 0) {
        byteNumber++;
        bitsLeftInCurrentByte = 8;
      }
    }
  }

  // Step 2: traverse the bitstream in the final stored order and rebuild params
  const params = [];

  let currentIndex = 2; // decoded[2] is first packed byte
  let bitOffsetInUsedRegion = 0;

  for (const bitCount of bitCounts) {
    let value = 0;

    for (let i = 0; i < bitCount; i++) {
      const used = bitsUsedInByte[currentIndex - 2] || 0;
      const startBit = 8 - used; // where the "used region" begins (e.g. 4-bit => start at bit 4)

      const bitPosition = startBit + bitOffsetInUsedRegion; // left -> right inside used region
      const bit = (decoded[currentIndex] >>> bitPosition) & 1;

      // original encoder consumed value LSB-first, so rebuild LSB-first
      value |= (bit << i);

      bitOffsetInUsedRegion++;
      if (bitOffsetInUsedRegion === used) {
        currentIndex++;
        bitOffsetInUsedRegion = 0;
      }
    }

    params.push(value);
  }

  return params;
}

// demo
const payload = [0x00, 0x2c, 0x04, 0x02, 0x11, 0x90, 0x07, 0x7e, 0x00, 0x00];
const params = extractParameters(payload);

function hex(n) { return "0x" + n.toString(16).padStart(2, "0"); }
console.log(params.map(hex));
// -> [ '0x04', '0x02', '0x01', '0x01', '0x02', '0x1e', '0x20', '0x1f', '0x00' ]

// Or better, non hex  
console.log(params);
// -> [4, 2, 1, 1, 2, 30, 32, 31, 0]

I need a play with a different team.

So far I have:

Columbia - Argentina - Brazil
International Brazil lost:
B~DCD GC5K* KB  
decoded: [0x00, 0x2c, 0x04, 0x02, 0x11, 0x90, 0x07, 0x7e, 0x00, 0x00]  
params extracted: [4, 2, 1, 1, 2, 30, 32, 31, 0] (decimal instead of hex)

Columbia - Argentina - Brazil
International Brazil win:
B~NCD GC5K* K1  
decoded: [0x00, 0xac, 0x04, 0x02, 0x11, 0x90, 0x07, 0x7e, 0x80, 0x00]  
params extracted: [4, 2, 1, 1, 2, 30, 32, 31, 2]  

Columbia - Argentina - Brazil
International Argentina lost:
B(spades)GCD GC?8K LB
decoded: [0x00, 0x4f, 0x04, 0x02, 0x11, 0xd0, 0xe7, 0x81, 0x00, 0x00]
params extracted: [4, 2, 1, 1, 2, 31, 30, 32, 0]  

Japan - Turkey - S. Korea  
Japan lost:  
BLFCD GCG7d JB  
decoded: [0x00, 0x32, 0x04, 0x02, 0x11, 0x10, 0xa6, 0x65, 0x00, 0x00]  
params extracted: [4, 2, 1, 1, 2, 24, 26, 25, 0]  

I think I see a pattern :)

What happens when I try 32, 31, 30.

// bits and value
bitPackValues([
  [8,  0x04],
  [8,  0x02],
  [4,  0x01],
  [7,  0x01],
  [3,  0x02],
  [6,  32],
  [6,  31],
  [6,  30],
  [2,  0x00],
]).map(hex);
 // ['0x00', '0x00', '0x04', '0x02', '0x11', '0x10', '0xf8', '0x79', '0x00', '0x00']

And encode and calculate checksum with the previous tool, we get:

B1MCD GCG*8 KB

OH YEAH!!

Can we get Japan here?

// bits and value
bitPackValues([
  [8,  0x04],
  [8,  0x02],
  [4,  0x01],
  [7,  0x01],
  [3,  0x02],
  [6,  24],
  [6,  31],
  [6,  30],
  [2,  0x00]
]).map(hex);

 // [0x00,0x00,0x04,0x02,0x11,0x10,0xf6,0x79,0x00,0x00]
BgMCD GCG↓8 KB

Are there any enforced restrictions on teams? Let`s check!

[
  [8,  0x04],
  [8,  0x02],
  [4,  0x01],
  [7,  0x01],
  [3,  0x02],
  [6,  24],
  [6,  24],
  [6,  24],
  [2,  0x00]
]
B*BCD GCGJJ JB

That would have been an interesting cup :D

What about the other values? After trial and error I get some of them:

[4, 2, 1, 1, 2, 24, 26, 25, 0]  
[game type, game type, param1, param2, param3, player team, team, team, game result]

Where:

param1: 0 lets you chose a team, with the result, abive 0 it has no apparent effect

param2: No effect?

param3: No effect?

teams:

parameter Team hex
0 Italy 0x00
1 Holland 0x01
2 England 0x02
3 Norway 0x03
4 Spain 0x04
5 Ireland 0x05
6 Portugal 0x06
7 Denmark 0x07
8 Germany 0x08
9 France 0x09
10 Belgium 0x0A
11 Sweden 0x0B
12 Romania 0x0C
13 Bulgaria 0x0D
14 Russia 0x0E
15 Swiss 0x0F
16 Greece 0x10
17 Croatia 0x11
18 Austria 0x12
19 Wales 0x13
20 Scotland 0x14
21 N. Ireland 0x15
22 The Czech Rep. 0x16
23 Poland 0x17
24 Japan 0x18
25 S. Korea 0x19
26 Turkey 0x1A
27 Nigeria 0x1B
28 Cameroon 0x1C
29 Morocco 0x1D
30 Brazil 0x1E
31 Argentina 0x1F
32 Columbia 0x20
33 Mexico 0x21
34 USA 0x22
35 Uruguay 0x23
36 All Star 0x24
37 Eurostar.a 0x25
38 Eurostar.b 0x26
39 Asian star 0x27
40 AfricanStar 0x28
41 AllAmericanStar 0x29

Greater than 41: Glitchy

game result: 0-Lost, 1-Draw, 2-Win, Above 2 always win

Now on to the other password types. The hope is that they all use the same logic, so all it is needed is to print the logs for the bit counts.

I advanced to the preliminary with Brazil (lost), and luckly the same logic is used. The password is longer.

B=gCB LB?(spades)q g(note)LBB  

Teams:

Brazil

Russia

Romania

Morocco

And the logs:

83f5f5 sta $0c        [00000c] A:0008 X:1702 Y:b3a7 S:0190 D:0000 DB:81 .....I.. V: 48 H:296 F:17
83f5fe sep #$20                A:0005 X:1702 Y:b3aa S:0190 D:0000 DB:81 .....I.. V: 48 H:331 F:17

8 bits 0x05

83f5f5 sta $0c        [00000c] A:0008 X:1703 Y:b3aa S:0190 D:0000 DB:81 .....I.. V: 50 H: 82 F:17
83f5fe sep #$20                A:0000 X:1703 Y:b3ad S:0190 D:0000 DB:81 .....IZ. V: 50 H:118 F:17

8 bits 0x00 (those two bits are the game type)

83f5f5 sta $0c        [00000c] A:0004 X:1704 Y:b3ad S:0190 D:0000 DB:81 .....I.. V: 51 H:219 F:17
83f5fe sep #$20                A:0002 X:1704 Y:b3b0 S:0190 D:0000 DB:81 .....I.. V: 51 H:255 F:17

4 bis 0x02

83f5f5 sta $0c        [00000c] A:0007 X:1704 Y:b3b0 S:0190 D:0000 DB:81 .....I.. V: 52 H:158 F:17
83f5fe sep #$20                A:0000 X:1704 Y:b3b3 S:0190 D:0000 DB:81 .....IZ. V: 52 H:194 F:17

7 bits 0x00

83f5f5 sta $0c        [00000c] A:0003 X:1705 Y:b3b3 S:0190 D:0000 DB:81 .....I.. V: 53 H:241 F:17
83f5fe sep #$20                A:0002 X:1705 Y:b3b6 S:0190 D:0000 DB:81 .....I.. V: 53 H:277 F:17

3 bits 0x02

83f5f5 sta $0c        [00000c] A:0003 X:1705 Y:b3cc S:0190 D:0000 DB:81 .....I.C V: 54 H:316 F:17
83f5fe sep #$20                A:0003 X:1705 Y:b3cf S:0190 D:0000 DB:81 .....I.C V: 55 H: 11 F:17

3 bits 0x03

83f5f5 sta $0c        [00000c] A:0006 X:1706 Y:b3cf S:0190 D:0000 DB:81 .....I.. V: 55 H:221 F:17
83f5fe sep #$20                A:0e1e X:1706 Y:b3d2 S:0190 D:0000 DB:81 .....I.. V: 55 H:257 F:17

6 bits 0x1e (6 bits was team on the previous password and 0x1e is Brazil)

83f5f5 sta $0c        [00000c] A:0006 X:1706 Y:b3d2 S:0190 D:0000 DB:81 .....I.. V: 56 H:249 F:17
83f5fe sep #$20                A:0c0e X:1706 Y:b3d5 S:0190 D:0000 DB:81 .....I.. V: 56 H:285 F:17

6 bits 0x0e

83f5f5 sta $0c        [00000c] A:0006 X:1707 Y:b3d5 S:0190 D:0000 DB:81 .....I.. V: 57 H:287 F:17
83f5fe sep #$20                A:1d0c X:1707 Y:b3d8 S:0190 D:0000 DB:81 .....I.. V: 57 H:323 F:17

6 bits 0x0c

83f5f5 sta $0c        [00000c] A:0006 X:1708 Y:b3d8 S:0190 D:0000 DB:81 .....I.. V: 58 H:325 F:17
83f5fe sep #$20                A:ff1d X:1708 Y:b3db S:0190 D:0000 DB:81 N....I.. V: 59 H: 20 F:17

6 bits 0x1d

83f5f5 sta $0c        [00000c] A:0006 X:1709 Y:b3db S:0190 D:0000 DB:81 .....I.. V: 60 H: 22 F:17
83f5fe sep #$20                A:ff44 X:1709 Y:b3de S:0190 D:0000 DB:81 N....I.. V: 60 H: 58 F:17

6 bits 0x44

83f5f5 sta $0c        [00000c] A:0002 X:1709 Y:b3de S:0190 D:0000 DB:81 .....I.. V: 61 H: 51 F:17
83f5fe sep #$20                A:0000 X:1709 Y:b3e1 S:0190 D:0000 DB:81 .....IZ. V: 61 H: 87 F:17

2 bits 0x00

83f5f5 sta $0c        [00000c] A:0002 X:170a Y:b3e1 S:0190 D:0000 DB:81 .....I.. V: 61 H:252 F:17
83f5fe sep #$20                A:0000 X:170a Y:b3e4 S:0190 D:0000 DB:81 .....IZ. V: 61 H:288 F:17

2 bits 0x00

83f5f5 sta $0c        [00000c] A:0002 X:170a Y:b3e4 S:0190 D:0000 DB:81 .....I.. V: 62 H: 92 F:17
83f5fe sep #$20                A:0000 X:170a Y:b3e7 S:0190 D:0000 DB:81 .....IZ. V: 62 H:128 F:17

2 bits 0x00

83f5f5 sta $0c        [00000c] A:0002 X:170a Y:b3e7 S:0190 D:0000 DB:81 .....I.. V: 62 H:284 F:17
83f5fe sep #$20                A:0000 X:170a Y:b3ea S:0190 D:0000 DB:81 .....IZ. V: 62 H:320 F:17

2 bits 0x00

So, we just need to generalize and reuse the logic with:

[
  [8,  0x05],
  [8,  0x00],
  [4,  0x02],
  [7,  0x00],
  [3,  0x02],
  [3,  0x03],
  [6,  0x1e],  // Brazil?
  [6,  0x0e],
  [6,  0x0c],
  [6,  0x1d],
  [6,  0x44],
  [2,  0x00],
  [2,  0x00],
  [2,  0x00],
  [2,  0x00]
]

And start playing with the values... So, can we have an all Brazil preliminaries?

[
  [8,  0x05],
  [8,  0x00],
  [4,  0x02],
  [7,  0x00],
  [3,  0x02],
  [3,  0x03],
  [6,  0x1e], // Brazil?
  [6,  0x1e],
  [6,  0x1e],
  [6,  0x1e],
  [6,  0x1e],
  [2,  0x00],
  [2,  0x00],
  [2,  0x00],
  [2,  0x00]
]

Not this time :(

But the fields with 6 bits do control which teams are on the preliminary (not sure the last one though)!

Well, the next steps are basically playing each game type, dumping the logs and creating the function for each bit size table.

The final picture

So, after all I have the answer to my original question:

Why is this password so big?

Because it contains the game state.

The first byte is XORed on all bytes wih data. It is used to randomize the password, or else all passwords looks similar.

The second byte is a checksum value.

The next bytes are data, they are used on the checksum.

The next two bytes determine the game type. This game type also determines the size of the password.

There are passwords for each type of game.

After that the password is basically a memory dump. Each type of game has a table of memory addresses and how many bits to copy from said address to the this part of the password.

Because the size in bits does not align with the caharacter size, just changing the characters to see what each one does, does not reproduce consistent results.

Also, the game has no checks for those values, so anything that goes here and has the correct checksum and size for game type is accepted.

Many passwords result in black screens or glitchyness.

By loggin the commands that write the values being written on the password I dumped all the size in bits for each value on the password. With this, by trial and error, many of the values significance could be inferred. Most important is that 6 bit values usually refers to a team.

And finally

Here is the link to the generator with parameter fields

When I started this I was expecting either to not be able to reverse engineer it at all or that it would be so easy that I would have it solved in a couple of hours.

If it wasn't for the excellent devinacker/bsnes-plus, for all the community documentation online and also usiing of AI tools, I don't think I would be able to do it. It is nice to know that future me is able to answer questions that past me wouldn't have a chance to.

I know there is still a small community that plays and mods ISSD, so maybe this will be useful to someone?

And lastly, I see this can be worked further to maybe do an arbitrary code execution similar to the famous super mario world hack?

The password basically writes direct to memory, unrestrited.

Maybe next vacations?