diff --git a/.gitignore b/.gitignore index 07ad212d..7910a645 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ *.pyc *.bin *.txt +*.log tetris.lst tetris.lbl tetris.map diff --git a/gentas.py b/gentas.py new file mode 100644 index 00000000..7fbb15c9 --- /dev/null +++ b/gentas.py @@ -0,0 +1,110 @@ +import argparse +import logging +import pathlib +import sys + +logger = logging.getLogger(__name__) +OFFSET=2 +INPUT_LETTERS = "RLDUTSBA" +INPUT_VALUES = dict( + right=0x80, + left=0x40, + down=0x20, + up=0x10, + start=0x08, + select=0x04, + b=0x02, + a=0x01, +) + + +# TASLINE = "|0|........|||" +def get_tas_line(input_byte: int): + result = [] + result.extend("|0|") + binary = f"{input_byte & 0xFF:08b}" + for i, char in enumerate(binary): + result.append(INPUT_LETTERS[i] if char == "1" else ".") + result.extend("|||") + return "".join(result) + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("frames", type=int, help="length of tas") + parser.add_argument("-f", "--file", type=pathlib.Path, help="output file (stdout otherwise)") + parser.add_argument( + "-R", + "--right", + type=int, + nargs="+", + default=[], + ) + parser.add_argument( + "-L", + "--left", + type=int, + nargs="+", + default=[], + ) + parser.add_argument( + "-D", + "--down", + type=int, + nargs="+", + default=[], + ) + parser.add_argument( + "-U", + "--up", + type=int, + nargs="+", + default=[], + ) + parser.add_argument( + "-T", + "--start", + type=int, + nargs="+", + default=[], + ) + parser.add_argument( + "-S", + "--select", + type=int, + nargs="+", + default=[], + ) + parser.add_argument( + "-B", + "--b", + type=int, + nargs="+", + default=[], + ) + parser.add_argument( + "-A", + "--a", + type=int, + nargs="+", + default=[], + ) + args = parser.parse_args() + + output = bytearray(args.frames) + for label, value in INPUT_VALUES.items(): + for frame in vars(args)[label]: + # if frame < OFFSET: + # raise RuntimeError(f"{frame} needs to be less than {OFFSET}") + output[frame+OFFSET] |= value + + + output = ('\n'.join(get_tas_line(f) for f in output)) + if args.file: + with open(args.file, 'w+') as file: + print(output, file=file) + else: + print(output) + + +if __name__ == "__main__": + main() diff --git a/maketas.sh b/maketas.sh new file mode 100755 index 00000000..d9e0f430 --- /dev/null +++ b/maketas.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +mkdir -p tases + +for i in {100..2000..100}; do + python gentas.py $i -f tases/no-start-$(printf '%04d' $i).fm2 +done + +python gentas.py 300 -T 265 -f tases/earliest-start.fm2 +python gentas.py 1800 -T 1794 -f tases/latest-start.fm2 +python gentas.py 300 -T 265 270 274 286 -f tases/fastest-999999-start.fm2 + +python gentas.py 500 -T 400 420 440 460 -f tases/start-pattern-a.fm2 +python gentas.py 800 -T 700 720 740 -f tases/start-pattern-b.fm2 +python gentas.py 1000 -T 900 920 940 -f tases/start-pattern-c.fm2 + + +while read -r -d '' tas; do + clean="${tas%.*}"-clean.log + if ! test -f "$clean"; then + echo $clean not found, running test + time cargo run --release --manifest-path tests/Cargo.toml -- \ + parity "$tas" -w + fi +done < <(find tases/ -name "*.fm2" -print0) diff --git a/src/boot.asm b/src/boot.asm index 707d2ac2..5f67c1b1 100644 --- a/src/boot.asm +++ b/src/boot.asm @@ -30,6 +30,10 @@ lda #$A sta paceModifier + inc hzFlag + inc inputDisplayFlag + ; inc darkModifier + lda #$10 sta dasModifier @@ -80,9 +84,19 @@ jsr disableNmi jsr drawBlackBGPalette ; instead of clearing vram like the original, blank out the palette +; align with vanilla at frame 0004 + ldx #$0 + ldy #$20 +@wait: + dex + bne @wait + dey + bne @wait + lda #$EF ldx #$04 ldy #$04 ; used to be 5, but we dont need to clear 2p playfield + jsr memset_page jsr waitForVBlankAndEnableNmi jsr updateAudioWaitForNmiAndResetOamStaging diff --git a/src/gamemode/branch.asm b/src/gamemode/branch.asm index b3111a1c..769e5ecc 100644 --- a/src/gamemode/branch.asm +++ b/src/gamemode/branch.asm @@ -1,22 +1,328 @@ ; 2nd and 3rd instances of playAndEndingHighScore_jmp used to be demo and startDemo respectively branchOnGameMode: branchTo gameMode, \ - gameMode_bootScreen, \ - gameMode_waitScreen, \ - gameMode_gameTypeMenu, \ + gameMode0, \ + gameMode1, \ + gameMode2, \ gameMode_levelMenu, \ gameMode_playAndEndingHighScore_jmp, \ - gameMode_playAndEndingHighScore_jmp, \ + gameMode2, \ gameMode_playAndEndingHighScore_jmp, \ gameMode_speedTest -.include "bootscreen.asm" -.include "waitscreen.asm" -.include "gametypemenu/menu.asm" +; .include "bootscreen.asm" +; .include "waitscreen.asm" +; .include "gametypemenu/menu.asm" .include "levelmenu.asm" +gameMode0: + lda #$00 + sta renderMode + + ; reset cursors + lda #$0 + sta practiseType + sta menuSeedCursorIndex + + ; levelMenu stuff + sta levelControlMode + lda #INITIAL_CUSTOM_LEVEL + sta customLevel + + jsr updateAudioWaitForNmiAndDisablePpuRendering + jsr checkRegion +.if KEYBOARD = 1 + ; todo test timing for keyboard + jsr detectKeyboard +.endif + jsr updateAudioWaitForNmiAndDisablePpuRendering + jsr disableNmi + + lda #NMIEnable + sta currentPpuCtrl +.if INES_MAPPER <> 0 +; NROM (and possibly FDS in the future) won't load the 2nd bankset +; and will instead use the title/menu chrset letters. This won't be noticeable +; unless a graphic is added + lda #CHRBankSet1 + jsr changeCHRBanks +.endif + jsr bulkCopyToPpu + .addr wait_palette + jsr copyRleNametableToPpu + .addr legal_nametable + + jsr bulkCopyToPpu + .addr legal_nametable_patch + + jsr waitForVBlankAndEnableNmi + + jsr stageBootSprites + jsr updateAudioWaitForNmiAndResetOamStaging + + dec sleepCounter + jsr stageBootSprites + jsr updateAudioWaitForNmiAndEnablePpuRendering + lda #$01 + sta qualFlag + + lda #$00 + sta frameCounter+1 +gameMode0Loop: + lda qualFlag + bne @wait + + lda newlyPressedButtons_player1 + and #BUTTON_START + bne @goToGameMode1 +@wait: + jsr stageBootSprites + jsr updateAudioWaitForNmiAndResetOamStaging + + lda sleepCounter + bne gameMode0Loop + + lda frameCounter+1 + cmp #$02 + beq @goToGameMode1 + + ; this is the point where start can be pressed + lda #$00 + sta qualFlag + + dec sleepCounter + bne gameMode0Loop +@goToGameMode1: + inc gameMode + rts + + +gameMode1: + lda #$00 + sta renderMode + + ; reset cursors + lda #$0 + sta practiseType + sta menuSeedCursorIndex + + ; levelMenu stuff + sta levelControlMode + lda #INITIAL_CUSTOM_LEVEL + sta customLevel + + jsr updateAudioWaitForNmiAndDisablePpuRendering + jsr updateAudioWaitForNmiAndDisablePpuRendering + jsr disableNmi + + lda #NMIEnable + sta currentPpuCtrl +.if INES_MAPPER <> 0 +; NROM (and possibly FDS in the future) won't load the 2nd bankset +; and will instead use the title/menu chrset letters. This won't be noticeable +; unless a graphic is added + lda #CHRBankSet1 + jsr changeCHRBanks +.endif + jsr bulkCopyToPpu + .addr wait_palette + jsr copyRleNametableToPpu + .addr legal_nametable + + jsr bulkCopyToPpu + .addr title_nametable_patch + + jsr waitForVBlankAndEnableNmi + jsr stageBootSprites + jsr updateAudioWaitForNmiAndResetOamStaging + jsr stageBootSprites + jsr updateAudioWaitForNmiAndEnablePpuRendering + lda #$00 + sta frameCounter+1 + +gameMode1Loop: + jsr stageBootSprites + jsr updateAudioWaitForNmiAndResetOamStaging + + lda newlyPressedButtons_player1 + and #BUTTON_START + bne @goToGameMode2 + +; this is the point vanilla enters demo mode +; go to game select menu page instead + lda frameCounter+1 + cmp #$05 + bne gameMode1Loop + + ; use this for now to signal demo start + lda #$01 + sta qualFlag + lda #$5 + sta gameMode + rts + +@goToGameMode2: + inc gameMode + rts + + +gameMode2: ; also game mode 5 + ; check to see if really demo + lda gameMode + cmp #$5 + bne @actually2 + + @DEMO_FRAMES = 4759 + lda #>@DEMO_FRAMES + sta endingSleepCounter+1 + lda #<@DEMO_FRAMES + sta endingSleepCounter + + +@actually2: + + + lda #$00 + sta renderMode + + ; reset cursors + lda #$0 + sta practiseType + sta menuSeedCursorIndex + + ; levelMenu stuff + sta levelControlMode + lda #INITIAL_CUSTOM_LEVEL + sta customLevel + + jsr updateAudioWaitForNmiAndDisablePpuRendering + jsr updateAudioWaitForNmiAndDisablePpuRendering + jsr disableNmi + + lda #NMIEnable + sta currentPpuCtrl +.if INES_MAPPER <> 0 +; NROM (and possibly FDS in the future) won't load the 2nd bankset +; and will instead use the title/menu chrset letters. This won't be noticeable +; unless a graphic is added + lda #CHRBankSet1 + jsr changeCHRBanks +.endif + jsr bulkCopyToPpu + .addr wait_palette + jsr copyRleNametableToPpu + .addr legal_nametable + + jsr bulkCopyToPpu + .addr menu_nametable_patch + + jsr waitForVBlankAndEnableNmi + jsr stageBootSprites + jsr updateAudioWaitForNmiAndResetOamStaging + jsr stageBootSprites + jsr updateAudioWaitForNmiAndEnablePpuRendering + + + lda qualFlag + beq @skipDemoShuffle + ldx #rng_seed + jsr generateNextPseudorandomNumber + lda qualFlag ; temporary use to signal demo start +@skipDemoShuffle: + +gameMode2Loop: + ; check to see if really demo + lda gameMode + cmp #$5 + bne @still2 + + dec endingSleepCounter + bne :+ + dec endingSleepCounter+1 + bne :+ + +; end demo + lda #$2 + sta gameMode + + lda #rng_seed + jsr generateNextPseudorandomNumber + + lda #rng_seed + jsr generateNextPseudorandomNumber +: + + + +@still2: + + lda newlyPressedButtons_player1 + and #BUTTON_START + bne @goToGameMode3 + + jsr stageBootSprites + jsr updateAudioWaitForNmiAndResetOamStaging + jmp gameMode2Loop + +@goToGameMode3: + inc gameMode + rts + gameMode_playAndEndingHighScore_jmp: jsr branchOnGameModeState rts .include "speedtest.asm" + +stageBootSprites: + ldx #frameCounter+1 + jsr stageZeroPage + ldx #frameCounter + jsr stageZeroPage + + ldx #rng_seed_hi + jsr stageZeroPage + ldx #rng_seed + jsr stageZeroPage + + ldx #sleepCounter + jsr stageZeroPage + ldx #generalCounter + jsr stageZeroPage + + ldx #gameMode + jsr stageZeroPage + + rts + +stageZeroPage: + lda oamStagingLength + and #$0F + asl + clc + adc #$1A + sta spriteXOffset + lda oamStagingLength + and #$F0 + clc + adc #$20 + sta spriteYOffset + stx byteSpriteAddr + lda #0 + sta byteSpriteAddr+1 + sta byteSpriteTile + lda #1 + sta byteSpriteLen + jmp byteSprite + +render_mode_0: +; rustico's run_until_vblank stops a few cycles into scanline 242 where nmi +; business is being done and messes up tests. for testing purposes while nothing +; is needed in nmi, a minimum amount of wait time is needed so that test polling +; always reads after game logic but before the nmi affects memory values, +; specifically the frame counter and rng + ldx #$40 +@wait: + dex + bne @wait + rts diff --git a/src/gamemode/gametypemenu/menu.asm b/src/gamemode/gametypemenu/menu.asm index f4fb67b1..eaeda36e 100644 --- a/src/gamemode/gametypemenu/menu.asm +++ b/src/gamemode/gametypemenu/menu.asm @@ -22,6 +22,15 @@ gameMode_gameTypeMenu: sta tmp3 jsr copyRleNametableToPpuOffset .addr game_type_menu_nametable_extra + + ; account for extra nametable loading time + inc frameCounter + bne :+ + inc frameCounter+1 +: + ldx #rng_seed + jsr generateNextPseudorandomNumber + .if INES_MAPPER <> 0 lda #CHRBankSet0 jsr changeCHRBanks @@ -29,7 +38,8 @@ gameMode_gameTypeMenu: lda #NMIEnable sta currentPpuCtrl jsr waitForVBlankAndEnableNmi - jsr updateAudioWaitForNmiAndResetOamStaging +; skip this wait, rng advanced manually above +; jsr updateAudioWaitForNmiAndResetOamStaging jsr updateAudioWaitForNmiAndEnablePpuRendering jsr updateAudioWaitForNmiAndResetOamStaging diff --git a/src/gamemode/levelmenu.asm b/src/gamemode/levelmenu.asm index 751b4810..97e6c3d2 100644 --- a/src/gamemode/levelmenu.asm +++ b/src/gamemode/levelmenu.asm @@ -28,6 +28,16 @@ gameMode_levelMenu: lda #RENDER_LINES sta renderFlags jsr resetScroll + +; skip an nmi cycle to align with vanilla + ldy #$10 + ldx #$00 +@wait: + dex + bne @wait + dey + bne @wait + jsr waitForVBlankAndEnableNmi jsr updateAudioWaitForNmiAndResetOamStaging jsr updateAudioWaitForNmiAndEnablePpuRendering diff --git a/src/gamemodestate/branch.asm b/src/gamemodestate/branch.asm index 8ae60ecd..772fb6b5 100644 --- a/src/gamemodestate/branch.asm +++ b/src/gamemodestate/branch.asm @@ -1,14 +1,3 @@ -; the return value of this routine dictates if we should wait for nmi or not right after -; initGameBackground gms: 1 acc: 0 - ne -; initGameState gms: 2 acc: 4/0 - ne -; updateCountersAndNonPlayerState gms: 3 acc: 0/1 - ne -; handleGameOver gms: 4 acc: eq (set to $9) if gameOver, $1 otherwise (ne) -; updatePlayer1 gms: 5 acc: $FF - ne -; next gms: 6 acc: $1 ne -; checkForResetKeyCombo gms: 7 acc: 0 or heldButtons - eq if holding down, left and right -; handlePause gms: 8 acc: 0/3 - ne -; vblankThenRunState2 gms: 2 acc eq (set to $2) - branchOnGameModeState: branchTo gameModeState, \ gameModeState_initGameBackground, \ @@ -23,13 +12,12 @@ branchOnGameModeState: gameModeState_next: ; used to be updatePlayer2 inc gameModeState - lda #$1 ; acc should not be equal rts gameModeState_vblankThenRunState2: lda #$02 sta gameModeState - rts + jmp updateAudioWaitForNmiAndResetOamStaging .include "initbackground.asm" .include "initstate.asm" diff --git a/src/gamemodestate/checkforabss.asm b/src/gamemodestate/checkforabss.asm index 432a4679..7d4977b7 100644 --- a/src/gamemodestate/checkforabss.asm +++ b/src/gamemodestate/checkforabss.asm @@ -4,7 +4,10 @@ gameModeState_checkForResetKeyCombo: cmp #BUTTON_A+BUTTON_B+BUTTON_START+BUTTON_SELECT beq @reset inc gameModeState - ; acc has to be heldButtons_player1 here + cmp #BUTTON_LEFT+BUTTON_DOWN+BUTTON_RIGHT + bne @continue + jsr updateAudioWaitForNmiAndResetOamStaging +@continue: rts @reset: jsr updateAudio2 diff --git a/src/gamemodestate/handlegameover.asm b/src/gamemodestate/handlegameover.asm index 2a6a4afe..cfec037c 100644 --- a/src/gamemodestate/handlegameover.asm +++ b/src/gamemodestate/handlegameover.asm @@ -46,5 +46,4 @@ gameModeState_handleGameOver: rts @ret: inc gameModeState ; 4 - lda #$1 ; acc should not be equal (always $1 in original game) rts diff --git a/src/gamemodestate/initbackground.asm b/src/gamemodestate/initbackground.asm index cc9658ac..798ff641 100644 --- a/src/gamemodestate/initbackground.asm +++ b/src/gamemodestate/initbackground.asm @@ -45,18 +45,30 @@ gameModeState_initGameBackground: sta PPUDATA @heartEnd: + lda #NMIEnable|BGPattern1|SpritePattern1 sta PPUCTRL sta currentPpuCtrl jsr resetScroll jsr waitForVBlankAndEnableNmi + + + + ; this no longer applies: + ; gym setup takes longer than vanilla, skip a frame to line up + ; jsr updateAudioWaitForNmiAndResetOamStaging + + + ; https://github.com/kirjavascript/TetrisGYM/pull/154 + ; restored wait jsr after mainloop wait inlining + ; the changes in this branch may have introduced a bug that have been fixed. + ; exact cause tbd (if ever) jsr updateAudioWaitForNmiAndResetOamStaging jsr updateAudioWaitForNmiAndEnablePpuRendering jsr updateAudioWaitForNmiAndResetOamStaging lda #$01 sta playState inc gameModeState ; 1 - lda #0 ; acc should not be equal rts scoringBackground: diff --git a/src/gamemodestate/initstate.asm b/src/gamemodestate/initstate.asm index 5bda81a6..23ca3bfd 100644 --- a/src/gamemodestate/initstate.asm +++ b/src/gamemodestate/initstate.asm @@ -153,7 +153,6 @@ gameModeState_initGameState: lda musicSelectionTable,x jsr setMusicTrack inc gameModeState ; 2 - lda #4 ; acc should not be equal initGameState_return: rts diff --git a/src/gamemodestate/pause.asm b/src/gamemodestate/pause.asm index a3b5c70f..34fcb550 100644 --- a/src/gamemodestate/pause.asm +++ b/src/gamemodestate/pause.asm @@ -17,7 +17,6 @@ gameModeState_handlePause: jsr pause @ret: inc gameModeState ; 8 - lda #$0 ; acc must not be equal rts pause: diff --git a/src/gamemodestate/updatecounters.asm b/src/gamemodestate/updatecounters.asm index b35e93c5..d3b8f65f 100644 --- a/src/gamemodestate/updatecounters.asm +++ b/src/gamemodestate/updatecounters.asm @@ -3,8 +3,6 @@ gameModeState_updateCountersAndNonPlayerState: lda #$00 sta oamStagingLength inc fallTimer - ; next code makes acc behave as normal - ; (dont edit unless you know what you're doing) lda newlyPressedButtons_player1 and #BUTTON_SELECT beq @ret diff --git a/src/gamemodestate/updateplayer1.asm b/src/gamemodestate/updateplayer1.asm index edf9da24..a00be232 100644 --- a/src/gamemodestate/updateplayer1.asm +++ b/src/gamemodestate/updateplayer1.asm @@ -15,5 +15,4 @@ gameModeState_updatePlayer1: jsr stageSpriteForNextPiece inc gameModeState ; 5 - lda #$FF ; acc from stateSpriteForNextPiece rts diff --git a/src/main.asm b/src/main.asm index 1fb4c13d..9dbdf24a 100644 --- a/src/main.asm +++ b/src/main.asm @@ -17,7 +17,12 @@ .segment "PRG_chunk1": absolute +; consumes exactly 1 page +.assert <* = 0, error, "mult_orient needs to be at page boundary" +.include "data/mult_orient.asm" + ; region code at start of page to keep cycle count consistent +.assert <* = 0, error, "check_region needs to be at page boundary" .include "util/check_region.asm" .include "audio.asm" @@ -27,10 +32,6 @@ initRam: mainLoop: jsr branchOnGameMode - cmp gameModeState - bne @continue - jsr updateAudioWaitForNmiAndResetOamStaging -@continue: jmp mainLoop .include "nmi/nmi.asm" @@ -92,9 +93,6 @@ mainLoop: .include "modes/qtap.asm" .include "modes/garbage.asm" -.align $100 -; these tables benefit from page alignment -.include "data/mult_orient.asm" .segment "PRG_chunk3": absolute diff --git a/src/nametables.asm b/src/nametables.asm index 70dd5f9d..342a48e1 100644 --- a/src/nametables.asm +++ b/src/nametables.asm @@ -1,7 +1,7 @@ -game_type_menu_nametable: ; RLE - .incbin "nametables/game_type_menu_nametable_practise.bin" -game_type_menu_nametable_extra: ; RLE - .incbin "nametables/game_type_menu_nametable_extra.bin" +; game_type_menu_nametable: ; RLE +; .incbin "nametables/game_type_menu_nametable_practise.bin" +; game_type_menu_nametable_extra: ; RLE +; .incbin "nametables/game_type_menu_nametable_extra.bin" level_menu_nametable: ; RLE .incbin "nametables/level_menu_nametable_practise.bin" game_nametable: ; RLE @@ -12,9 +12,27 @@ rocket_nametable: ; RLE .incbin "nametables/rocket_nametable.bin" legal_nametable: ; RLE .incbin "nametables/legal_nametable.bin" + + + +; added for timing test +legal_nametable_patch: ; stripe + .byte $21, $69, $5, "LEGAL" + .byte $21, $89, $9, "GAMEMODE0" + .byte $FF + +; modified for timing test title_nametable_patch: ; stripe - .byte $21, $69, $5, $1D, $12, $1D, $15, $E + .byte $21, $69, $5, "TITLE" + .byte $21, $89, $9, "GAMEMODE1" + .byte $FF + +; blank tile + MENU, temporary for timing test framework +menu_nametable_patch: ; stripe + .byte $21, $69, $5, "MENU", $FF + .byte $21, $89, $9, "GAMEMODE2" .byte $FF + rocket_nametable_patch: ; stripe .byte $20, $83, 5, $19, $1B, $E, $1c, $1c .byte $20, $A3, 5, $1c, $1d, $a, $1b, $1d diff --git a/src/nmi/render.asm b/src/nmi/render.asm index 1858e84d..8f981f87 100644 --- a/src/nmi/render.asm +++ b/src/nmi/render.asm @@ -1,5 +1,5 @@ render: branchTo renderMode, \ - render_mode_static, \ + render_mode_0, \ render_mode_scroll, \ render_mode_congratulations_screen, \ render_mode_play_and_demo, \ diff --git a/src/ram.asm b/src/ram.asm index 4015c984..4fedf22e 100644 --- a/src/ram.asm +++ b/src/ram.asm @@ -15,7 +15,8 @@ cycleCount: .res 2 ; $0012 ; 2 bytes ; used for crash oneThirdPRNG: .res 1 ; $0014 ; used for crash .res $2 -rng_seed: .res 2 ; $0017 +rng_seed: .res 1 ; $0017 +rng_seed_hi: .res 1; spawnID: .res 1 ; $0019 spawnCount: .res 1 ; $001A pointerAddr: .res 2 ; $001B ; used in debug, harddrop @@ -102,7 +103,8 @@ positionValidTmp: .res 1 ; $00AD ; 0-level, 1-height originalY: .res 1 ; $00AE dropSpeed: .res 1 ; $00AF tmpCurrentPiece: .res 1 ; $00B0 ; Only used as a temporary -frameCounter: .res 2 ; $00B1 +frameCounter: .res 1 ; $00B1 +frameCounterHi: .res 1 oamStagingLength: .res 1 ; $00B3 .res 1 newlyPressedButtons: .res 1 ; $00B5 ; Active player's buttons diff --git a/tests/Cargo.lock b/tests/Cargo.lock index a4cffd9d..e2574d7b 100644 --- a/tests/Cargo.lock +++ b/tests/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -95,6 +104,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror", + "winapi", +] + [[package]] name = "flips" version = "0.2.1" @@ -205,6 +225,16 @@ dependencies = [ "slab", ] +[[package]] +name = "gag" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a713bee13966e9fbffdf7193af71d54a6b35a0bb34997cd6c9519ebeb5005972" +dependencies = [ + "filedescriptor", + "tempfile", +] + [[package]] name = "gumdrop" version = "0.8.1" @@ -231,9 +261,11 @@ version = "0.1.0" dependencies = [ "flips", "flips-sys", + "gag", "gumdrop", "md5", "minifb", + "regex", "rustico-core", ] @@ -455,6 +487,35 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rustico-core" version = "0.2.0" @@ -590,6 +651,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "thiserror" +version = "1.0.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e3de26b0965292219b4287ff031fcba86837900fe9cd2b34ea8ad893c0953d2" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "268026685b2be38d7103e9e507c938a1fcb3d7e6eb15e87870b617bf37b6d581" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.43", +] + [[package]] name = "unicode-ident" version = "1.0.12" diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 9f2f3c32..ec0121d5 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -7,6 +7,8 @@ edition = "2021" gumdrop = "0.8.1" rustico-core = { git = "https://github.com/zeta0134/rustico.git", rev = "d31d380b1e15bf7cc80e8827730e56a6df77ae46" } minifb = "0.27" +gag = "1.0.0" +regex = "1.12.3" # for patch testing flips = "0.2.1" diff --git a/tests/src/main.rs b/tests/src/main.rs index c47905fd..df88c6ca 100644 --- a/tests/src/main.rs +++ b/tests/src/main.rs @@ -15,6 +15,7 @@ mod garbage; mod harddrop; mod mapper; mod palettes; +mod parity; mod pushdown; mod rng; mod score; @@ -26,12 +27,31 @@ mod nmi; mod constants; mod patch; +use std::path::PathBuf; use gumdrop::Options; fn parse_hex(s: &str) -> Result { u32::from_str_radix(s, 16) } +#[derive(Debug, Options)] +enum Command { + #[options(help = "run parity tests against vanilla rom")] + Parity(ParityOptions), +} + +#[derive(Debug, Options)] +struct ParityOptions { + #[options(help = ".fm2 file", free)] + tasfile: PathBuf, + #[options(help = "Write logfiles")] + write: bool, + #[options(help = "Be printy")] + verbose: bool, + #[options(help = "Show video")] + render: bool, +} + #[derive(Debug, Options)] struct TestOptions { help: bool, @@ -52,6 +72,8 @@ struct TestOptions { #[options(help = "list drought mode probabilities")] drought_probs: bool, foo: bool, + #[options(command)] + command: Option, } fn main() { @@ -77,6 +99,22 @@ fn main() { ("harddrop", harddrop::test), ]; + match options.command { + None => {} + Some(Command::Parity(ref opts)) => { + if *opts.tasfile == *"" { + println!("tas file required!"); + return; + } + parity::compare( + &opts.tasfile, + &opts.write, + &opts.verbose, + &opts.render, + ) + } + } + // run tests if options.test { tests.iter().for_each(|(name, test)| { diff --git a/tests/src/nmi.rs b/tests/src/nmi.rs index eca1d6f1..37650688 100644 --- a/tests/src/nmi.rs +++ b/tests/src/nmi.rs @@ -7,7 +7,7 @@ pub fn test() { let main_loop = labels::get("mainLoop"); let game_mode = labels::get("gameMode") as usize; let level_number = labels::get("levelNumber") as usize; - let nmi_label = labels::get("nmi"); + let _nmi_label = labels::get("nmi"); let hz_flag = labels::get("hzFlag") as usize; let render_flags = labels::get("renderFlags") as usize; diff --git a/tests/src/parity.rs b/tests/src/parity.rs new file mode 100644 index 00000000..3c071517 --- /dev/null +++ b/tests/src/parity.rs @@ -0,0 +1,249 @@ +use regex::Regex; +use rustico_core::nes::NesState; +use std::fs::read_to_string; +use std::fs::File; +use std::io::BufWriter; +use std::io::Result; +use std::io::Write; +use std::io::stdout; +use std::path::PathBuf; + +use gag::Gag; + +use crate::labels; +use crate::util; +use crate::video; + +static EXTRA_FRAMES: usize = 10; +static FAILURE_LOG_FRAMES: usize = 100; +static COMPARE_BYTES: &[&str] = &[ + "rng_seed", + "rng_seed_hi", + "frameCounter", + "frameCounterHi", + "gameMode", + "autorepeatX", + "spawnCount", +]; + +pub fn compare(tasfile: &PathBuf, write: &bool, verbose: &bool, render: &bool) { + // println!("{:?} {:?}", tasfile, write,); + let tas_buttons = get_buttons_from_tasfile(tasfile, verbose); + + if *write { + let (vanilla, gym) = run_tas_and_compare(&tas_buttons, verbose, &render); + let _ = write_bytes_to_file(&get_new_filename(tasfile, "clean"), &vanilla); + let _ = write_bytes_to_file(&get_new_filename(tasfile, "gym"), &gym); + } else { + print!(">> comparing results for {tasfile:?}... "); + stdout().flush().unwrap(); + let vanilla_logfile = get_new_filename(tasfile, "clean"); + let compare_bytes: Vec = read_bytes_from_file(&vanilla_logfile); + compare_with_vanilla(&tas_buttons, &compare_bytes, &verbose, &render); + println!(" ✅"); + } +} + +struct OptionalVideo { + video: Option, +} + +impl OptionalVideo { + fn new(render: &bool) -> Self { + let video = if *render { + Some(video::Video::new()) + } else { + None + }; + + Self { video } + } + fn set_position(&mut self, x: isize, y: isize) { + if let Some(video) = &mut self.video { + video.window.set_position(x, y); + } + } + fn render(&mut self, emu: &mut rustico_core::nes::NesState) { + if let Some(video) = &mut self.video { + video.render(emu); + } + } +} + +fn read_bytes_from_file(filename: &PathBuf) -> Vec { + let mut result = Vec::new(); + + for line in read_to_string(filename).unwrap().lines() { + for byte in line.split_whitespace() { + result.push(u8::from_str_radix(byte, 16).unwrap()) + } + } + + result +} + +fn write_bytes_to_file(filename: &PathBuf, bytes: &Vec) -> Result<()> { + let file = File::create(filename)?; + let mut writer = BufWriter::new(file); + + for chunk in bytes.chunks(COMPARE_BYTES.len()) { + for (i, b) in chunk.iter().enumerate() { + if i > 0 { + write!(writer, " ")?; + } + write!(writer, "{:02X}", b)?; + } + writeln!(writer)?; + } + Ok(()) +} + +fn get_new_filename(filename: &PathBuf, suffix: &str) -> PathBuf { + let stem = filename.file_stem().unwrap(); + let mut name = stem.to_os_string(); + name.push(format!("-{}.log", suffix)); + filename.with_file_name(name) +} + +fn extract_values_from_labels(emu: &mut NesState) -> Vec { + let mut result: Vec = Vec::new(); + for label in COMPARE_BYTES { + let addr = labels::get(label) as usize; + let value = emu.memory.iram_raw[addr]; + result.push(value) + } + + return result; +} + +fn compare_with_vanilla( + tas_buttons: &Vec, + compare_bytes: &Vec, + _verbose: &bool, + render: &bool, +) { + let mut gym; + { + let _print_gag = Gag::stdout().unwrap(); + gym = util::emulator(None); + } + let mut ptr = 0; + let mut view = OptionalVideo::new(render); + for (i, buttons) in tas_buttons.into_iter().enumerate() { + let expected = &compare_bytes[ptr..ptr + COMPARE_BYTES.len()]; + ptr += COMPARE_BYTES.len(); + let values = extract_values_from_labels(&mut gym); + if expected != values { + eprintln!("❌"); + eprintln!("Gym: {:?}", values); + eprintln!("Vog: {:?}", expected); + panic!("Mismatch on line {}!", i + 1); + } + + gym.run_until_vblank(); + util::set_controller_emu_native(&mut gym, *buttons); + view.render(&mut gym); + } +} +fn run_tas_and_compare(tas_buttons: &Vec, verbose: &bool, render: &bool) -> (Vec, Vec) { + let mut og; + let mut gym; + { + // suppress rustico's output when loading roms + let _print_gag = Gag::stdout().unwrap(); + og = util::emulator(Some(util::OG_ROM)); + gym = util::emulator(None); + } + let mut og_bytes = Vec::new(); + let mut gym_bytes = Vec::new(); + let mut vanilla_view = OptionalVideo::new(render); + let mut gym_view = OptionalVideo::new(render); + let mut fail_frames: usize = 0; + gym_view.set_position(512, 30); + for buttons in tas_buttons.iter() { + if fail_frames > FAILURE_LOG_FRAMES { + break; + } + let gym_values = extract_values_from_labels(&mut gym); + gym_bytes.extend(gym_values.clone()); + + let og_values = extract_values_from_labels(&mut og); + og_bytes.extend(og_values.clone()); + + if gym_values.as_slice() != og_values.as_slice() { + fail_frames += 1; + } + + if *verbose { + println!("OG: {:?}", og_values); + println!("Gym: {:?}", gym_values); + } + + og.run_until_vblank(); + gym.run_until_vblank(); + util::set_controller_emu_native(&mut og, *buttons); + util::set_controller_emu_native(&mut gym, *buttons); + vanilla_view.render(&mut og); + gym_view.render(&mut gym); + } + + return (og_bytes, gym_bytes); +} + +fn get_buttons_from_tasfile(filename: &PathBuf, verbose: &bool) -> Vec { + let contents = read_to_string(filename).expect("can't open tasfile"); + + // let reader = BufReader::new(file); + let mut tas_buttons: Vec = Vec::new(); + let tas_line = + // RLDUTSBA + Regex::new(r"(?m)^\|0\|([R.])([L.])([D.])([U.])([T.])([S.])([B.])([A.])\|\|\|").unwrap(); + + for (i, (_, [right, left, down, up, start, select, b, a])) in tas_line + .captures_iter(&contents) + .map(|c| c.extract()) + .enumerate() + { + // helps line things up + if i < 2 { + continue; + } + let mut button: u8 = 0; + if right == "R" { + button |= 0x80 // input::RIGHT 0x01 + } + if left == "L" { + button |= 0x40 // input::LEFT 0x02 + } + if down == "D" { + button |= 0x20 // input::DOWN 0x04 + } + if up == "U" { + button |= 0x10 // input::UP 0x08 + } + if start == "T" { + button |= 0x08 // input::START 0x10 + } + if select == "S" { + button |= 0x04 // input::SELECT 0x20 + } + if b == "B" { + button |= 0x02 // input::B 0x40 + } + if a == "A" { + button |= 0x01 // input::A 0x80 + } + tas_buttons.push(button); + + if *verbose { + eprintln!( + "{:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:08b}", + right, left, down, up, start, select, a, b, button, + ); + } + } + for _ in 0..EXTRA_FRAMES { + tas_buttons.push(0); + } + return tas_buttons; +} diff --git a/tests/src/tspins.rs b/tests/src/tspins.rs index 6029f11e..098d2346 100644 --- a/tests/src/tspins.rs +++ b/tests/src/tspins.rs @@ -3,7 +3,7 @@ use crate::{util, labels, playfield}; pub fn test() { let mut emu = util::emulator(None); - for _ in 0..4 { + for _ in 0..6 { emu.run_until_vblank(); } diff --git a/tests/src/util.rs b/tests/src/util.rs index a27fe0de..279ed0dc 100644 --- a/tests/src/util.rs +++ b/tests/src/util.rs @@ -35,6 +35,10 @@ pub fn set_controller_raw(emu: &mut NesState, buttons: u8) { emu.p1_input = flipped_buttons; } +pub fn set_controller_emu_native(emu: &mut NesState, buttons: u8) { + emu.p1_input = buttons; +} + pub fn set_controller(emu: &mut NesState, button: char) { set_controller_raw(emu, match button { 'L' => input::LEFT, diff --git a/testtas.sh b/testtas.sh new file mode 100755 index 00000000..9590fe77 --- /dev/null +++ b/testtas.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +cargo build --release --manifest-path tests/Cargo.toml + +node build.js + +while read -r -d '' tas; do + clean="${tas%.*}"-clean.log + if test -f "$clean"; then + ./tests/target/release/gym-tests parity "$tas" + fi +done < <(find tases/ -name "*.fm2" -print0) diff --git a/tools/log.lua b/tools/log.lua new file mode 100644 index 00000000..bc64745c --- /dev/null +++ b/tools/log.lua @@ -0,0 +1,127 @@ +-- line up with parity.rs indexing +-- frameCount starts at 2 +local OFFSET = -2 + +-- local TEST_LENGTH = 1794 +-- local START_FRAMES = {265, 270, 274, 286} +local TEST_LENGTH = 10000 +local START_FRAMES = {265} +-- local START_FRAMES = {} + +local BREAK_FRAME = nil + +local COMPARE = true + +local rom = emu.getRomInfo() +local romPath, _ = string.gsub(rom.path, rom.name, "") +local filename = rom.name:gsub("%.%w+$", "") .. ".log" +local path = string.gsub(rom.path, rom.name, filename) + +emu.log("Opening " .. path) +local original_results = {} +local file +if COMPARE then + local og_path = string.gsub(rom.path, rom.name, "clean.log") + for line in io.lines(og_path) do + line_nos = {} + for num in string.gmatch(line, "%S+") do + table.insert(line_nos, num) + end + table.insert( + original_results, + { + tonumber(line_nos[2], 16), + tonumber(line_nos[3], 16), + tonumber(line_nos[4], 16) + }) + end + emu.log("Comparing. Loaded " .. #original_results .. " lines") +end +local file = io.open(path, "w") +-- workaround to get a global state +local startIdx = {1} +local cmpIdx = {1} + + +function logFrame() + state = emu.getState() + local frameCount = state.frameCount + OFFSET + + if BREAK_FRAME == frameCount then + emu.breakExecution() + end + + if startIdx[1] < #START_FRAMES + 1 then + if frameCount == START_FRAMES[startIdx[1]] then + emu.log(string.format("%04d", frameCount) .. " pressing start") + emu.setInput({start = true}) + startIdx[1] = startIdx[1] + 1 + end + end + + local rng_seed = valueAtLabel16('rng_seed') + local frameCounter = valueAtLabel16('frameCounter') + local gameMode = valueAtLabel('gameMode') + local generalCounter = valueAtLabel('generalCounter') + local sleepCounter = valueAtLabel('sleepCounter') + + if COMPARE then + if not original_results[cmpIdx[1]] then + emu.log("Ran out of frames to compare") + emu.breakExecution() + return + end + expected_rng = original_results[cmpIdx[1]][1] + expected_fc = original_results[cmpIdx[1]][2] + expected_gm = original_results[cmpIdx[1]][3] + cmpIdx[1] = cmpIdx[1] + 1 + + if ( + expected_rng ~= rng_seed or + expected_fc & 0xFF ~= frameCounter & 0xFF -- or + -- expected_gm ~= gameMode + ) then + emu.log( + "Expect: " + .. string.format("%04X", expected_rng) .. " " + .. string.format("%04X", expected_fc) .. " " + .. string.format("%02X", expected_gm) + ) + emu.log( + "Actual: " + .. string.format("%04X", rng_seed) .. " " + .. string.format("%04X", frameCounter) .. " " + .. string.format("%02X", gameMode) + ) + emu.breakExecution() + end + else + + file:write( + string.format("%04d", frameCount) .. " " + .. string.format("%04X", rng_seed) .. " " + .. string.format("%04X", frameCounter) .. " " + -- .. string.format("%02X", sleepCounter) .. " " + -- .. string.format("%02X", generalCounter) .. " " + .. string.format("%02X", gameMode) .. "\n" + ) + + if frameCount >= TEST_LENGTH then + file:close() + emu.breakExecution() + emu.log("Wrote " .. frameCount + 1 .. " lines to " .. path) + end + end +end + +function valueAtLabel(label) + local addr = emu.getLabelAddress(label) + return emu.read(addr.address, addr.memType) +end + +function valueAtLabel16(label) + local addr = emu.getLabelAddress(label) + return emu.read16(addr.address, addr.memType) +end + +emu.addEventCallback(logFrame, emu.eventType.inputPolled); diff --git a/tools/tas.lua b/tools/tas.lua new file mode 100644 index 00000000..ac2b61cd --- /dev/null +++ b/tools/tas.lua @@ -0,0 +1,110 @@ +-- https://tasvideos.org/7625S +-- local TASFILE = "tases/r57shell-Archanfel-Tetris-fastest999999.fm2" +local TASFILE = "tases/start-pattern-a.fm2" + +local rom = emu.getRomInfo() +local romPath, _ = string.gsub(rom.path, rom.name, "") +local tasFile = romPath .. TASFILE +local compareFile = tasFile:gsub("%.%w+$", "") .. "-clean.log" + +local COMPARE_BYTES = { + "rng_seed", + "rng_seed_hi", + "frameCounter", + "frameCounterHi", + "gameMode", + "autorepeatX", + "spawnCount", +} + +local cmpIdx = {1 + #COMPARE_BYTES} -- skip a frame to line things up + +local inputs = {} +for line in io.lines(tasFile) do + local buttons = line:match("^|0|(........)|||$") + if buttons then + local input = {} + local mapped = { + "right", + "left", + "down", + "up", + "start", + "select", + "b", + "a", + } + for i = 1, 8 do + input[mapped[i]] = not (buttons:sub(i, i) == ".") + end + table.insert(inputs, input) + end +end + +-- extra input to force extra frame (so last input is processed) +table.insert(inputs, {}) + +local compareBytes = {} +for line in io.lines(compareFile) do + for num in string.gmatch(line, "%S+") do + table.insert(compareBytes, tonumber(num, 16)) + end +end + + +function applyInputs() + local state = emu.getState() + local input = inputs[state["frameCount"] + 1] + if not input then + emu.breakExecution() + return + else + emu.setInput(input) + end + local expect = slice(compareBytes, cmpIdx[1], cmpIdx[1] + #COMPARE_BYTES) + local actual = getValuesFromLabels() + + local expectStr = getHexString(expect) + local actualStr = getHexString(actual) + if expectStr ~= actualStr then + emu.log("Expect: " .. expectStr) + emu.log("Actual: " .. actualStr) + emu.breakExecution() + end + cmpIdx[1] = cmpIdx[1] + #COMPARE_BYTES +end + +function getHexString(numbers) + result = {} + for _, number in ipairs(numbers) do + table.insert(result, string.format("%02X", number)) + end + return table.concat(result, " ") + +end + +function slice(values, i, n) + local result = {} + local idx = i + while idx < n do + table.insert(result, values[idx]) + idx = idx + 1 + end + return result +end + +function getValuesFromLabels() + local result = {} + for _, label in ipairs(COMPARE_BYTES) do + local value = valueAtLabel(label) + table.insert(result, value ) + end + return result +end + +function valueAtLabel(label) + local addr = emu.getLabelAddress(label) + return emu.read(addr.address, addr.memType) +end + +emu.addEventCallback(applyInputs, emu.eventType.inputPolled);