Reverse Engineering the International Superstar Soccer Password System Part 2
2026-02-22Previously
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?