gRngValue: reading it, predicting it, and aiming it

FireRed’s gRngValue can be read, predicted from a Pokemon, and aimed by a field stub so the next scripted encounter is shiny with a chosen nature and IVs. Addresses are French FireRed (BPRF, 0x0A).

The generator

u16 Random(void) { gRngValue = 1103515245 * gRngValue + 24691; return gRngValue >> 16; }
void SeedRng(u16 seed) { gRngValue = seed; }

[src/random.c, include/random.h:18]

symbol address
gRngValue 0x03004220
Random 0x080486B0
SeedRng 0x080486D0
gSpecialVar_0x8000 0x020370B4

pokeldn/frlg/rom/lcg.py is the arithmetic; distance(a, b) is exact by baby-step/giant-step. The map permutes all 232 states, so a distance is evidence only when small (odds N / 232). gRngValue and gSpecialVar_0x8000 never move; save blocks move on every battle and load [load_save.c:75].

The rate: exactly 2 turns per frame

The state advances exactly 2 turns per frame: --gift rng-rate-probe (read, delay N frames [scrcmd.c:651], read) measures 2N + 2 turns, the +2 being one frame around the delay. The state n frames after a reading is advance(S, 2n). One turn is about 8 ms.

Where the seed comes from, and why it cannot be carried

SeedRngAndSetTrainerId [main.c:264] seeds from timer 1 (REG_TM1CNT_L) at the title screen’s START press [title_screen.c:735], 65536 values; the read has sub-frame jitter, so timing START cannot choose a seed. Backing out of Mystery Gift returns to the title screen [mystery_gift_menu.c:463], so a seed set during a link never reaches the overworld.

SeedRng call site when
SeedRngAndSetTrainerId [title_screen.c:735] the title screen
LinkTestScreen [link.c:318] unused debug screen
Debug_RfuIdle [link_rfu_2.c:2670] unused debug screen
RfuMain1 [link_rfu_2.c:2116] Switch-only, gated on svc_4b() & SVC4B_RESEED_RNG, seeds from the host’s advertised playerTrainerId

No measured state descends from the RfuMain1 reseed; when its bit is set is unknown.

Reading a Pokemon back into the state that made it

A wild Pokemon is four draws: personality low, personality high, HP/ATK/DEF, SPE/SPATK/SPDEF [wild_encounter.c:233]. The IVs add 30 bits of check, so lcg.recover_wild_state finds exactly one state. A stray draw can sit before or between the IV draws; its source is unknown, so both gaps are searched:

method gap before IVs gap between IVs
1 0 0
2 1 0
4 0 1

Check a mon’s PID and IVs against its six summary stats before any RNG claim.

The scripted battle

setptr b0..b3 -> 0x03004220      gRngValue = seed          (opcode 0x11)
setwildbattle <species> <level>  CreateMon rolls PID, PID, IV, IV   (0xB6)
dowildbattle                     the battle starts          (0xB7)

setwildbattle calls CreateMon(..., 32, 0, 0, OT_ID_PLAYER_ID, 0) [script_pokemon_util.c:128]: plain four-draw Method 1, no grass or encounter roll. Both commands return FALSE, so the writes and the generation run in one frame; nothing that yields may sit between them (a test asserts it). The RAM script is bound by initramscript and ends in end (0x02), not endram, so it re-triggers. A predicted shiny Lv50 Ditto matched PID, nature and IVs exactly.

Reading the live state and generating a mon, the four draws start at the state read; the mon costs 4 turns (Random32() 2, IVs 2 [pokemon.c:1796,1836,1845]) and one overworld frame adds 2.

The seed-reading NPC

--gift rng-seed-reader, flag id 1015, a bound RAM script:

copybyte gSpecialVar_0x8000+0, 0x03004220      (opcode 0x15)
copybyte gSpecialVar_0x8000+1, 0x03004221
copybyte gSpecialVar_0x8001+0, 0x03004222
copybyte gSpecialVar_0x8001+1, 0x03004223
buffernumberstring 0, VAR_0x8000               (0x83)
msgbox                                          the NPC prints the value

rng_script.seed_from_printed(low, high) reassembles the word, and rng_script.check_two_readings scores two readings.

  • All six commands return FALSE and run in one frame, so the read cannot tear.
  • The text pointer is relative: setvaddress (0xB8) and vmessage (0xBD) [scrcmd.c:171] make the operand an offset into the script body, which moves with the save block.
  • buffernumberstring prints a u16 [scrcmd.c:1678]: two vars, two lines.

Bind to the player’s mother (group 4, map 0, object 1, MOVEMENT_TYPE_FACE_LEFT, never hidden). The Pallet Town object events wander off.

How precisely a human can press A

A player aiming 30 s ahead lands within a standard deviation of 4.5 frames after a constant offset, so a press hits one chosen frame about 9% of the time; a shiny frame comes every ~8192 frames. pokeldn/frlg/rom/rng_countdown.py is the countdown; --aimed-at STATE measures a miss in frames. This is the route when the RAM script slot holds a Wonder Card.

At a script’s button wait the hook can press instead, on the exact frame and without writing gRngValue: shiny-press, the app’s Shiny legendary.

--gift rng-shiny-hunt, pokeldn/frlg/rom/native_script.py, asm/field/. All four stubs work on retail:

stub gift
shiny-seek.s rng-shiny-hunt
mon-seek.s rng-mon-hunt
mon-seek-far.s rng-mon-hunt-far
mon-seek-both.s rng-mon-hunt-both
bool8 ScrCmd_setptr(struct ScriptContext * ctx)          // 0x11
{ u8 value = ScriptReadByte(ctx); *(u8 *)ScriptReadWord(ctx) = value; }
bool8 ScrCmd_callnative(struct ScriptContext * ctx)      // 0x23
{ void (*func)(void) = ((void (*)(void))ScriptReadWord(ctx)); func(); return FALSE; }

[scrcmd.c:300, :120]. setptr stages code into EWRAM and callnative runs it in the overworld, which CLI_RUN_BUFFER_SCRIPT cannot reach (notblisy/RUBYSAPPHIREDLC does the same on Ruby/Sapphire). CreateScriptedWildMon has no fixed personality and OT_ID_PLAYER_ID draws nothing, so shininess is the first two draws after the state the stub leaves.

The stub reads playerTrainerId at gSaveBlock2Ptr + 0x0A and writes only gRngValue. The search is bounded; on exhaustion the encounter is ordinary. Every stub runs under unicorn before staging (tests/test_native_script.py).

A RAM script may not come back from a battle

CB2_InitBattle and InitOverworldBgs call MoveSaveBlocks_ResetHeap [battle_main.c:614, overworld.c:1337], moving gSaveBlock1 by a multiple of 4 in 0..124 [load_save.c:75], while the engine keeps its pointer into ramScript.data.script [script.c:514]. Nothing after dowildbattle is reachable: the resume lands on a stray battle, zero fill, or a command that freezes the overworld.

The battle starts from outside the save block [rng_script.battle_and_exit]:

setvar 0x8000, 0x02B7      ->  0x020370B4: B7 02  =  dowildbattle ; end
goto   0x020370B4

Nothing writes gSpecialVar_0x8000 in battle, and end returns control [script.c:335]. No RAM script may rely on a save-block address across a battle or map load.

Choosing the nature and the IVs

--gift rng-mon-hunt, asm/field/mon-seek.s, flag id 1019, tests shininess, nature (personality % 25 [pokemon.c:5020]) and IV floors (draws 3 and 4 [pokemon.c:1836]):

--hunt-nature adamant,jolly   --hunt-iv speed=31 --hunt-iv attack=20   --hunt-cap N
asked for 1 state in typical freeze worst at the cap
shiny 8,192 0.02 s 0.10 s
shiny + one nature 204,800 0.55 s 2.5 s
shiny + nature + one IV >= 20 546,133 1.5 s 6.7 s
shiny + two IVs = 31 8,388,608 22 s refused

native_script.search_cost computes this; the host refuses a worst case above --hunt-freeze-frames (default 900). The player sees a still frame with music. mon-seek.s is 160 bytes of a 163-byte staging budget.

The payload in the script body

setptr costs six script bytes per code byte. The engine runs the 995-byte RAM script body in place and never reads past the last command [script.c:514], so bytes after it are storage at one byte each. &gSaveBlock1Ptr (0x03004228) gives the body’s address for the frame; asm/field/ram-jump.s (36 bytes) is the only part staged:

setptr x36    the trampoline, into gDecompressionBuffer        216 bytes
callnative    -> trampoline -> payload -> back                   5
setwildbattle / setvar / goto                                   16
pad to a multiple of four                                        3
payload                                                        755

The trampoline tail-branches (bx r0), so the payload’s pop {r4-r7, pc} returns to ScrCmd_callnative’s caller. It returns early unless ramScript.data.magic is 51 [script.c:12].

Alignment to four bytes

THUMB ldr rN, [pc, #imm] uses Align(PC, 4). The body is word-aligned: the save-block offset is & 0x7C [load_save.c:75] and RAMSCRIPT_BODY_OFFSET is 0x3624. native_script.emulate_body_script walks the real script bytes to catch a misalignment.

Proving the size rather than the jump

asm/field/mon-seek-far.s is followed by non-zero filler to byte 995; the stub searches only if the filler sums correctly. InitRamScript zero-fills the rest [script.c:495], so a short delivery leaves gRngValue alone. --gift rng-mon-hunt-far carries 196 bytes of stub and 559 of filler.

Searching so the stray draw cannot move the answer

asm/field/mon-seek-both.s (232 bytes, --gift rng-mon-hunt-both, flag id 1001) tests the floors at two placements. With d3, d4, d5 the draws after the personality:

method first triple (HP/ATK/DEF) second triple (SPE/SPATK/SPDEF)
1 (clean) d3 d4
2 d4 d5
4 d3 d5

Requiring both words d3 | d4<<15 and d4 | d5<<15 covers all three methods. Shiny + Jolly + SPEED >= 20 becomes 1 in 1,456,000, about 4 s typical; the cap is 95%.

Where a hunt writes its report

asm/field/mon-seek-log.s (288 bytes, --gift rng-mon-hunt-log, flag id 1002) writes {marker, start, found, iterations, cap} to gSaveBlock1Ptr + 0x348C, u8 unused_348C[400] [include/global.h], which no code writes. It survives the battle and reaches flash on save; an exhausted search writes found 0.

--buffer-script save-dump --dump-block sav1 --dump-offset 0x348C --dump-size 32

native_script.decode_hunt_log reads it. A logged search of 603,745 iterations matched lcg.distance(start, found) exactly and took 2-3 s, 3.7-5.6 cycles per instruction against the model’s CYCLES_PER_INSTRUCTION_FROM_EWRAM of 3.

LeafGreen

The stubs run unported: every literal is a link-time IWRAM word or a constant, and TID ^ SID is read at run time. See LeafGreen.