@@ -113,7 +113,14 @@ def snap_dir(dx, dy):
113113# ---------------------------------------------------------------- helpers
114114
115115def boot (garbage = False ):
116- pb = PyBoy (ROM , window = "null" )
116+ # Boot a save-free copy of the ROM: PyBoy loads <rom>.ram next to the
117+ # ROM path and writes it back on stop(), so reusing one path makes every
118+ # later boot inherit an earlier boot's save. A fresh copy per boot keeps
119+ # every test on the deterministic default-seed boot, like CI.
120+ import shutil , tempfile
121+ path = str (Path (tempfile .mkdtemp ()) / "pf.gb" )
122+ shutil .copy (ROM , path )
123+ pb = PyBoy (path , window = "null" )
117124 pb .set_emulation_speed (0 )
118125 if garbage :
119126 # simulate power-on WRAM garbage BEFORE the first tick (PyBoy zeroes
@@ -155,6 +162,38 @@ def new_game(pb, seed=None):
155162 for _ in range (30 ):
156163 pb .tick ()
157164
165+ def wait_state (pb , state , frames = 180 ):
166+ """Tick until wState == state (screen rebuilds take several frames)."""
167+ mem = pb .memory
168+ for _ in range (frames ):
169+ pb .tick ()
170+ if mem [syms ["wState" ]] == state :
171+ return True
172+ return False
173+
174+
175+ def press3 (pb , btn ):
176+ """Hold a button for 3 frames. A 1-tick press can land between the
177+ game's joypad reads (PyBoy writes hit mid-frame) and vanish."""
178+ pb .button_press (btn )
179+ for _ in range (3 ):
180+ pb .tick ()
181+ pb .button_release (btn )
182+
183+
184+ def teleport (pb , tx , ty , tries = 8 ):
185+ """Teleport to a tile, surviving torn mid-frame position writes (the
186+ collision check can revert a torn write). Returns success."""
187+ mem = pb .memory
188+ for _ in range (tries ):
189+ set16 (mem , "wPosX" , (tx * 8 ) << 4 )
190+ set16 (mem , "wPosY" , (ty * 8 ) << 4 )
191+ pb .tick ()
192+ if (w16 (mem , "wShipX" ) >> 3 , w16 (mem , "wShipY" ) >> 3 ) == (tx , ty ):
193+ return True
194+ return False
195+
196+
158197def w16 (mem , n ):
159198 return mem [syms [n ]] | mem [syms [n ] + 1 ] << 8
160199
@@ -415,6 +454,35 @@ def r8_final_wave_returned():
415454
416455# ------------------------------------------------------ R9/R10: tavern rumors
417456
457+ def find_dockable_port (s16 ):
458+ """A port district with a beach the game will actually accept: some
459+ water tile whose FIRST land neighbor in TryDock's N,S,W,E order is a
460+ beach inside this district. Seed-independent (unlike the old
461+ hardcoded (10,34), which is not a port district under DEADBEEF)."""
462+ for dy in range (72 ):
463+ for dx in range (80 ):
464+ if not has_port (dx , dy , s16 ):
465+ continue
466+ for ty in range (dy * 4 , dy * 4 + 4 ):
467+ for tx in range (dx * 4 , dx * 4 + 4 ):
468+ if tile (tx , ty , s16 ) < 3 :
469+ continue
470+ for ddx , ddy in ((0 , 1 ), (0 , - 1 ), (1 , 0 ), (- 1 , 0 )):
471+ nx , ny = tx + ddx , ty + ddy
472+ if not (0 <= nx < 320 and 0 <= ny < 288 ):
473+ continue
474+ if tile (nx , ny , s16 ) >= 3 :
475+ continue
476+ for pdx , pdy in ((0 , - 1 ), (0 , 1 ), (- 1 , 0 ), (1 , 0 )):
477+ if not (0 <= nx + pdx < 320 and 0 <= ny + pdy < 288 ):
478+ continue
479+ if tile (nx + pdx , ny + pdy , s16 ) >= 3 :
480+ if (nx + pdx , ny + pdy ) == (tx , ty ):
481+ return (dx , dy )
482+ break
483+ raise AssertionError ("no dockable port district in this sea" )
484+
485+
418486def dock_at_district (pb , s16 , dx , dy ):
419487 """Teleport next to a beach in port district (dx,dy) and dock."""
420488 mem = pb .memory
@@ -424,13 +492,21 @@ def dock_at_district(pb, s16, dx, dy):
424492 continue
425493 for ddx , ddy in ((0 , 1 ), (0 , - 1 ), (1 , 0 ), (- 1 , 0 )):
426494 nx , ny = tx + ddx , ty + ddy
495+ if not (0 <= nx < 320 and 0 <= ny < 288 ):
496+ continue
427497 if tile (nx , ny , s16 ) >= 3 :
428498 continue
429499 set16 (mem , "wPosX" , (nx * 8 ) << 4 )
430500 set16 (mem , "wPosY" , (ny * 8 ) << 4 )
431501 for _ in range (5 ):
432502 pb .tick ()
433- press (pb , "a" , 30 )
503+ # a 1-tick press can land between joypad reads: hold for 3
504+ pb .button_press ("a" )
505+ for _ in range (3 ):
506+ pb .tick ()
507+ pb .button_release ("a" )
508+ for _ in range (30 ):
509+ pb .tick ()
434510 if mem [syms ["wState" ]] == 4 :
435511 return
436512 raise AssertionError (f"no dockable beach in district ({ dx } ,{ dy } )" )
@@ -440,7 +516,7 @@ def r9_r10_tavern():
440516 mem = pb .memory
441517 new_game (pb )
442518 s16 = seed16 (mem )
443- dock_at_district (pb , s16 , 10 , 34 ) # the port test_m3 uses
519+ dock_at_district (pb , s16 , * find_dockable_port ( s16 ))
444520 press (pb , "down" , 2 )
445521 press (pb , "down" , 2 )
446522 press (pb , "a" , 30 ) # TAVERN
@@ -506,7 +582,7 @@ def r11_r12_menu_and_gold():
506582 mem = pb .memory
507583 new_game (pb )
508584 s16 = seed16 (mem )
509- dock_at_district (pb , s16 , 10 , 34 )
585+ dock_at_district (pb , s16 , * find_dockable_port ( s16 ) )
510586 press (pb , "up" , 5 )
511587 assert mem [syms ["wPortMenu" ]] == 5 , \
512588 f"UP from top item -> { mem [syms ['wPortMenu' ]]} , want 5"
@@ -558,13 +634,14 @@ def r14_dig_no_cannon():
558634 if spot :
559635 break
560636 assert spot , "no beach-adjacent water in isle 0's cell"
561- set16 (mem , "wPosX" , (spot [0 ] * 8 ) << 4 )
562- set16 (mem , "wPosY" , (spot [1 ] * 8 ) << 4 )
563- for _ in range (5 ):
637+ assert teleport (pb , * spot ), "teleport never stuck"
638+ for _ in range (4 ):
564639 pb .tick ()
565640 assert (mem [syms ["wShipCX" ]], mem [syms ["wShipCY" ]]) == (ix , iy )
566641 mem [syms ["wGuardMask" ]] = 1 # isle 0's guardian already sunk
567- press (pb , "a" , 30 ) # dig up the fragment
642+ press3 (pb , "a" ) # dig up the fragment
643+ for _ in range (30 ):
644+ pb .tick ()
568645 assert mem [syms ["wState" ]] == 5 , f"state { mem [syms ['wState' ]]} , want DIG"
569646 assert not mem [syms ["wBallPActive" ]], "digging fired a cannonball"
570647 pb .stop ()
@@ -573,24 +650,20 @@ def r14_dig_no_cannon():
573650# ---------------------- R15: SaveGame sets wHasSave (continue without reset)
574651
575652def r15_save_sets_has_save ():
576- import shutil , tempfile
577- # a ROM path with no .ram next to it: guaranteed no-save boot
578- tmp = Path (tempfile .mkdtemp ()) / "pf_nosave.gb"
579- shutil .copy (ROM , tmp )
580- pb = PyBoy (str (tmp ), window = "null" )
581- pb .set_emulation_speed (0 )
582- for _ in range (150 ):
583- pb .tick ()
653+ pb = boot () # save-free boot (see boot())
584654 mem = pb .memory
585655 assert mem [syms ["wHasSave" ]] == 0 , "fresh cart reported a save"
586656 new_game (pb )
587657 s16 = seed16 (mem )
588- dock_at_district (pb , s16 , 10 , 34 ) # autosaves on dock
658+ dock_at_district (pb , s16 , * find_dockable_port ( s16 )) # autosaves on dock
589659 assert mem [syms ["wHasSave" ]] == 1 , "wHasSave not set after autosave"
590- press (pb , "b" , 30 ) # set sail
591- press (pb , "b" , 5 ) # arm quit confirm
592- press (pb , "b" , 30 ) # confirm: quit to the editor
593- assert mem [syms ["wState" ]] == 0 , "did not return to the editor"
660+ press3 (pb , "b" ) # set sail
661+ assert wait_state (pb , 2 ), "never set sail"
662+ press3 (pb , "b" ) # arm quit confirm
663+ for _ in range (5 ): # B must be seen released...
664+ pb .tick ()
665+ press3 (pb , "b" ) # ...before the confirming edge
666+ assert wait_state (pb , 0 ), "did not return to the editor"
594667 hint = read_text (mem , 0x9800 + 5 * 32 + 5 , 11 )
595668 assert hint == "A NEW GAME" , f"editor hint { hint !r} , want 'A NEW GAME'"
596669 pb .stop ()
@@ -612,16 +685,21 @@ def f1_quit_confirm():
612685 pb = boot ()
613686 mem = pb .memory
614687 new_game (pb )
615- press (pb , "b" , 5 )
688+ press3 (pb , "b" )
689+ for _ in range (5 ):
690+ pb .tick ()
616691 assert mem [syms ["wState" ]] == 2 , "single B press quit without confirm"
617692 assert mem [syms ["wQuitCfm" ]] > 0 , "confirm window not armed"
618693 for _ in range (200 ): # let the window expire
619694 pb .tick ()
620695 assert mem [syms ["wQuitCfm" ]] == 0 , "confirm window never expired"
621696 assert mem [syms ["wState" ]] == 2 , "expired confirm still quit"
622- press (pb , "b" , 5 ) # re-arm
623- press (pb , "b" , 10 ) # confirm
624- assert mem [syms ["wState" ]] == 0 , "second B press did not quit"
697+ press3 (pb , "b" ) # re-arm
698+ for _ in range (5 ):
699+ pb .tick ()
700+ assert mem [syms ["wQuitCfm" ]] > 0 , "confirm window not re-armed"
701+ press3 (pb , "b" ) # confirm
702+ assert wait_state (pb , 0 ), "second B press did not quit"
625703 pb .stop ()
626704 print ("F1 B-at-sea quit confirm: OK" )
627705
0 commit comments