I spent some time deciphering the PRNG for rocks, so I'm sharing here! There'll be a lot of juicy details on the disassembly.
First of all, the PRNG for this game is at $1600AA-$160510. It's a XOR PRNG with 4 words of state. Different components of the game use it, and it is only advanced when it is consumed. And what kinds of events use it and how?
IDLE ANIMATIONS
Idle animations use the value returned by this subroutine (stored at data register D0) to decide whether or not to play the 'wait' animation. For Cyclops, that's when he checks his watch. For Nightcrawler, that's when he fully stands straight and looks left then right. If PRNG is forced to always return the same value here, then the wait animation will always play after a while, and will remain that way until the player changes the state by pressing any button. Otherwise, with the PRNG working as expected, sometimes it will repeat for a bit, but then go back to the idle animation.
Something interesting you can do here also is changing the idle animation. Depending on the character, the game uses a value to bge against (that's probably to make it repeat more often for some characters), then before calling the 'set animation function' (at $1F626A), it compares the value returned by the PRNG with the value retrieved from the character array, and then loads different anim ptrs based on the result. This is the code:
1F396E: 7200 moveq #0, D1
1F3970: 3200 move.w D0, D1 ; D0 contains the value returned by the PRNG.
1F3972: 4A81 tst.l D1
1F3974: 206C movea.l ($8C,A4), A0 ; ($8C,A4) contains the character ptr.
1F3978: 3028 move.w ($26,A0), D0 ; each character has a different value to bge against.
1F397C: 48C0 ext.l D0
1F397E: B081 cmp.l D1, D0
1F3980: 6C12 bge $1F3994
1F3982: 2248 movea.l A0, A1
1F3984: D0E9 adda.w ($44,A1), A0 ; this is the key: if bge is not taken, we take offset at A1+$44.
1F3988: 2F08 move.l A0, -(A7) ; we send the arguments to the set animation subroutine.
1F398A: 2F0C move.l A4, -(A7)
1F398C: 4EAD jsr ($4E42,A5) ; set anim $44,A1
1F3990: 504F addq.w #8, A7 ; restore stack
1F3992: 6014 bra $1F39A8 ; this bra allows for an if/else: we skip the code that would have run in case bge was taken
1F3994: 206C movea.l ($8C,A4), A0
1F3998: 2248 movea.l A0, A1
1F399A: D0E9 adda.w ($42,A1), A0
1F399E: 2F08 move.l A0, -(A7) ; we send the arguments again, but this time the one at $42,A1
1F39A0: 2F0C move.l A4, -(A7)
1F39A2: 4EAD jsr ($4E42,A5) ; set anim $42,A1
1F39A6: 504F addq.w #8, A7 ; restore stack
1F39A8: (rest of shared code)
ROCKS
For rocks, this is the code while moving (more on that in a bit):
05B6DC: 7200 moveq #0, D1
05B6DE: 3200 move.w D0, D1 ; again, D0 contains result of PRNG.
05B6E0: 4A81 tst.l D1
05B6E2: 0801 btst #$0, D1 ; this is the key: rocks can go *only* one of two ways: left or right. True or False. 0 or 1.
05B6E6: 6708 beq $5B6F0
05B6E8: 397C move.w $150, ($16,A4) ; then, we will move $150 into ($16,A4)
05B6EE: 6006 bra $5B6F6 ; again, bra to have an if/else structure, one or the other.
05B6F0: 397C move.w $FEB0, ($16,A4)
05B6F6: (rest of shared code)
The difference is in what we set to ($16,A4) (which resolves to $9124).
$FEB0 = Left $0150 = Right
Another interesting point is that, because the rocks pattern depends on the PRNG, and the PRNG only moves when it is consumed, if you create a savestate in the select character screen, or even during the cutscene, no matter what you do, how much time you let pass, or how much you move the selector between characters, the PRNG will not advance, and so when you select a character and start the level, it will seem as if the starting rocks are fixed, but they're not! You can also tell by creating a savestate during the Magneto fight (which makes the PRNG advance if you remain idle for a bit... more on that later also) and then playing Stage 4, there's a high chance they'll have a different pattern.
Interestingly enough, the rocks code that runs if you're walking is not the same that runs as when you're standing, although the checks and how the PRNG is used are still the same:
05C026: 7200 moveq #0, D1
05C028: 3200 move.w D0, D1
05C02A: 4A81 tst.l D1
05C02C: 0801 btst #$0, D1
05C030: 584F addq.w #4, A7
05C032: 6708 beq $5C03C
05C034: 377C move.w $150, ($16,A3)
05C03A: 6006 bra $5C042
05C03C: 377C move.w $FEB0, ($16,A3)
05C042: (rest of code)
(of note also, the address register used is not the same, here it is A3, but the value is still the same, RAM $FF910E)
The other detail to keep in mind (and perhaps, this has to do with different code running while walking vs standing) is that the PRNG runs both when a new rock is generated and when a rock enters in contact/collides with the ground: PRNG is run to decide, once again, direction of the rock. So the bouncing of a rock can also be either left or right, depending on the value returned by the PRNG.
$910E always contains the direction of the rock: even if you change it as a rock is falling, the rock will immediately change direction defying the laws of physics lol (or maybe it's just the wind).
Sometimes it can also be $0300 or $0000, but it doesn't seem to matter very much. The logic is always the same: run PRNG, give the rock left or right direction, if it collides with the ground, roll the dice (PRNG) again, rinse and repeat.
This is also why, even if you create a savestate at the very start of the level, the rocks may be seen to follow a different path in some attempts: it's because if your path varies even the slightest, you may cause a rock to fall in one of the 'ceilings', causing an extra bounce, and completely throwing the PRNG off track.
Also, if you stay waiting idle for a bit, you will also cause that PRNG to roll, making it even more random.
But, if you make sure to always follow pretty much the exact same path, with the same exact movements, you should be fine. (this is why after I came up with a path, it seemed to have removed most of the randomness.)
INITIAL STATE FOR ROCKS
As for the initial state... can it be manipulated somehow?
First, we need to find everything that changes PRNG from the start of the game until the rocks. Here we go.
- Wall Destruction: all the white walls you destroy in Siberia trigger the PRNG. And can produce variations on the PRNG if some of the objects/particles become offscreen at specific points. Interestingly, explosives do not trigger it.
- ... wall destruction.
Huh? Yes. The game uses PRNG for wall destructions, which mean, Siberia, the glass in 3-2 Boss Fight, and the walls during Magneto fight. And pretty much that.
So, if you make sure to not run idle animation PRNG, and to always end wall destruction in the same manner, you will 100% guarantee the initial state at Stage 4. Allowing you to manipulate rock pattern exactly the way you want.
One way of doing so is by remaining crouched instead of standing while waiting for something (like in elevators for example, or while you wait for the boss at 3-2 to appear). While crouched, PRNG does not run. Same goes for jumping.