diff --git a/.editorconfig b/.editorconfig index a922223..85b6851 100644 --- a/.editorconfig +++ b/.editorconfig @@ -14,7 +14,6 @@ insert_final_newline = true [Makefile] indent_style = tab indent_size = 2 -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true + +[*.py] +generated_code = true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6382ce4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,83 @@ +name: Pytest + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + container: + image: scottyhardy/docker-wine:latest + env: + # USER_NAME: runner + # USER_UID: ${USER_UID:-1010} + # USER_GID: ${USER_GID:-"${USER_UID}"} + # USER_HOME: ${USER_HOME:-/home/"${USER_NAME}"} + # USER_PASSWD: ${USER_PASSWD:-"$(openssl passwd -1 -salt "$(openssl rand -base64 6)" "${USER_NAME}")"} + # USER_SUDO: ${USER_SUDO:-yes} + RDP_SERVER: yes + # RUN_AS_ROOT: ${RUN_AS_ROOT:-no} + # FORCED_OWNERSHIP: ${FORCED_OWNERSHIP:-no} + # TZ: ${TZ:-UTC} + # USE_XVFB: ${USE_XVFB:-no} + DUMMY_PULSEAUDIO: yes # ${DUMMY_PULSEAUDIO:-no} + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: robinraju/release-downloader@v1 + with: + repository: "gbdk-2020/gbdk-2020" + tag: "4.3.0" + fileName: "gbdk-linux64.tar.gz" + extract: true + # - uses: pyvista/setup-headless-display-action@v2 + # - name: Install wine + # run: | + # sudo dpkg --add-architecture i386 + # sudo mkdir -pm755 /etc/apt/keyrings + # sudo wget -O /etc/apt/keyrings/winehq-archive.key https://dl.winehq.org/wine-builds/winehq.key + # sudo wget -NP /etc/apt/sources.list.d/ https://dl.winehq.org/wine-builds/ubuntu/dists/jammy/winehq-jammy.sources + # sudo apt update + # sudo apt install -y --install-recommends winehq-stable winbind + # wine --version + # - name: Install wine-mono + # run: | + # wget https://github.com/madewokherd/wine-mono/releases/download/wine-mono-9.0.0/wine-mono-9.0.0-x86.msi + # WINEDEBUG=fixme-all WINEDLLOVERRIDES=mscoree=d wine wine-mono-9.0.0-x86.msi + - name: pip install + shell: bash + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + working-directory: ./tests + - name: make roms + shell: bash + run: | + make + ls build + working-directory: ./tests + - name: test single rom (python) + shell: bash + run: | + python test.py + working-directory: ./tests + continue-on-error: true + - run: | + ls -alF + shell: bash + working-directory: ./tests/build + - name: test single rom (bash) + run: | + wine ../bgb/bgb.exe -autoexit -hf -stateonexit -screenonexit build/wav_test_load_and_play.bmp -rom build/wav_test_load_and_play.gb + working-directory: ./tests + - run: | + ls -alF + shell: bash + working-directory: ./tests/build + # run: | + # pytest -v -s + # working-directory: ./tests diff --git a/.gitignore b/.gitignore index 58e4f32..e6f1988 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .vscode/settings.json +.venv/ gbdk/ Source/obj Source/out mgb.gb +bgb diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d48062a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "tests/gbdk_unit_test"] + path = tests/gbdk_unit_test + url = git@github.com:untoxa/gbdk_unit_test.git diff --git a/.vscode/tasks.json b/.vscode/tasks.json index de8d61f..69a39aa 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -66,6 +66,16 @@ }, "dependsOn": "make-debug", "problemMatcher": [] + }, + { + "label": "make-test-roms", + "type": "shell", + "command": "make", + "args": [], + "options": { + "cwd": "tests/" + }, + "problemMatcher": [] } ] } diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..f50aa38 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,4 @@ +/build +/unused +__pycache__ +.pytest_cache diff --git a/tests/Makefile b/tests/Makefile new file mode 100644 index 0000000..285f813 --- /dev/null +++ b/tests/Makefile @@ -0,0 +1,39 @@ +MYDIR = . +BLDDIR = $(MYDIR)/build +FW = $(MYDIR)/framework +CC = ../gbdk/bin/lcc -tempdir=$(BLDDIR) -Wl-j -Wl-m -Wl-w -Wl-yt2 -Wl-yo4 -Wl-ya4 + +TESTS = $(wildcard *.c) +OBJS = $(TESTS:%.c=$(BLDDIR)/%.o) +TESTROMS = $(TESTS:%.c=$(BLDDIR)/%.gb) + +BGBDIR = ../bgb +# GBDKDIR = ../gbdk + +all: clean mkdirs $(BGBDIR) build-all + +.PHONY: clean +clean: + rm -rf $(BLDDIR) + +.PHONY: mkdirs +mkdirs: + mkdir -p $(BLDDIR) + +$(BGBDIR): + wget https://bgb.bircd.org/bgb.zip + unzip -jo -qq bgb.zip -d ../bgb + rm bgb.zip + +# gbdk-linux64.tar.gz: +# wget https://github.com/gbdk-2020/gbdk-2020/releases/download/4.3.0/gbdk-linux64.tar.gz + +# $(GBDKDIR): +# tar -zxvf gbdk-linux64.tar.gz -C $(GBDKDIR) +# # rm gbdk-linux64.tar.gz + +$(BLDDIR)/%.gb: $(MYDIR)/%.c mkdirs $(BGBDIR) + $(CC) -o $@ $< + +.PHONY: build-all +build-all: $(TESTROMS) diff --git a/tests/bgb_get_snapshot.py b/tests/bgb_get_snapshot.py new file mode 100644 index 0000000..8cc6754 --- /dev/null +++ b/tests/bgb_get_snapshot.py @@ -0,0 +1,152 @@ +import subprocess +from array import array +import os +from pathlib import Path +from PIL import Image, ImageChops + +from gbdk_unit_test.framework.BGB_toolkit import load_noi, read_bgb_snspshot + +base_dir = os.path.dirname(os.path.realpath(__file__)) +base_path = Path(base_dir) + + +def load_rom_snapshot(rom_relative): + + make_and_run(rom_relative) + + snapshot_file = base_path.joinpath(rom_relative).with_suffix('.sna') + + if not snapshot_file.is_file(): + raise Exception("Cannot load snapshot: " + snapshot_file) + + noi_file = base_path.joinpath(rom_relative).with_suffix('.noi') + if not snapshot_file.is_file(): + raise Exception("Cannot load symbols: " + noi_file) + + screenshot = base_path.joinpath(rom_relative).with_suffix('.bmp') + + snapshot = read_bgb_snspshot(snapshot_file) + symbols = load_noi(noi_file) + symbols = {value: key for key, value in symbols.items()} + + snapshot['symbols'] = symbols + snapshot['screenshot'] = screenshot + + return snapshot + + +def make_and_run(rom_relative): + make_rom(rom_relative) + + rom_path_full = base_path.joinpath(rom_relative) + screenshot_path = rom_path_full.with_suffix('.bmp') + + bgb_exe = ["wine", "../bgb/bgb64.exe"] + + # windows + if os.name == 'nt': + bgb_exe = ["../bgb/bgb64.exe"] + + cmd = [ + *bgb_exe, + "-set \"DebugSrcBrk=1\"", + "-autoexit", + "-hf", + "-stateonexit", + # "-screenonexit", screenshot_path.absolute().as_posix(), + rom_relative, + ] + + print("executing bgb: ", cmd) + my_env = os.environ.copy() + my_env["WINEDEBUG"] = "fixme-all" + + subprocess.call(cmd, + cwd=base_dir, + env=my_env, + # stdout=subprocess.DEVNULL, + # stderr=subprocess.DEVNULL, + ) + + print("bgb: finished") + + if not rom_path_full.with_suffix(".sna").is_file(): + raise Exception("Tried to run rom, failed " + rom_relative) + + +def make_rom(rom_relative): + print("calling make", rom_relative) + result = subprocess.call( + ["make", rom_relative], + cwd=base_dir, + # stdout=subprocess.DEVNULL + ) + + print("make: finished", rom_relative) + + if result != 0: + raise Exception("Rom make failed " + rom_relative) + + +# The following code is repurposed from unit_checker.py by untoxa (MIT License) + +# WRAM = 49152 +mem_map = { + 'VRAM': 0x8000, + 'WRAM': 0xC000, + 'OAM': 0xFE00, + 'IO_REG': 0xFF00, + 'HRAM': 0xFF80, +} + + +def symbol_addr(snapshot, symbol, base): + if type(base) is str: + base = mem_map[base.upper()] + return snapshot['symbols'].get(symbol) - base + + +def get(snapshot, section, address, len=0): + if isinstance(address, str): + address = symbol_addr(snapshot, address, section) + + if len > 1: + return snapshot[section][address:address + len] + else: + return snapshot[section][address] + + +def ASCIIZ(snapshot, section, address): + ofs = address + data = snapshot[section] + fin = ofs + while data[fin] != 0: + fin += 1 + return str(data[ofs:fin], 'ascii') if fin - ofs > 0 else '' + + +def CHECKSCREEN(snapshot, file_name): + image_one = Image.open(base_path.joinpath(file_name)).convert('RGB') + image_two = Image.open(snapshot['screenshot']).convert('RGB') + + diff = ImageChops.difference(image_one, image_two) + + return (diff.getbbox() is None) + + +def find(input: dict | array, val: str | int, parent_key: str | None): + if isinstance(input, array): + for i, v in enumerate(input): + if v == val: + print("found", [parent_key, i]) + return [parent_key, i] + elif isinstance(v, array): + find(v, val, i) + return + + for k, v in input.items(): + if v == val: + print("found", [parent_key, k]) + return [parent_key, k] + elif isinstance(v, array) or isinstance(v, dict): + find(v, val, k) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..505b6c0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from bgb_get_snapshot import load_rom_snapshot + + +@pytest.fixture +def snapshot(request): + return load_rom_snapshot(request.param) diff --git a/tests/gbdk_unit_test b/tests/gbdk_unit_test new file mode 160000 index 0000000..101355e --- /dev/null +++ b/tests/gbdk_unit_test @@ -0,0 +1 @@ +Subproject commit 101355e08e190018c493fa5249ba4d09e4e5a9d1 diff --git a/tests/pu1_plays_note.c b/tests/pu1_plays_note.c new file mode 100644 index 0000000..47a8db4 --- /dev/null +++ b/tests/pu1_plays_note.c @@ -0,0 +1,35 @@ +#include +#include + +#include "../Source/io/midi.h" + +#include "../Source/mGB.h" +#include "../Source/synth/common.c" +#include "../Source/synth/pulse.c" +#include "../Source/synth/wav.c" + +bool systemIdle = true; + +uint8_t statusByte; +uint8_t addressByte; +uint8_t valueByte; +uint8_t capturedAddress; + +uint8_t result[2] = {0U, 0U}; + +void main(void) { + rAUDENA = AUDENA_ON; + rAUDVOL = AUDVOL_VOL_LEFT(7U) | AUDVOL_VOL_RIGHT(7U); + + setOutputPan(PU1, 64U); + + addressByte = 64U; // MIDI note + valueByte = 127U; // MIDI velocity + + playNotePu1(); + updatePu1(); + + delay(100); + + EMU_BREAKPOINT; +} diff --git a/tests/pu1_test.py b/tests/pu1_test.py new file mode 100644 index 0000000..9f0e998 --- /dev/null +++ b/tests/pu1_test.py @@ -0,0 +1,18 @@ +import pytest + +from bgb_get_snapshot import get + + +def describe_pu1(): + + @pytest.mark.parametrize('snapshot', ['build/pu1_plays_note.gb'], indirect=True) + def it_plays_a_note(snapshot): + rAUD1LOW = get(snapshot, 'IO_REG', '_NR13_REG') + rAUD1HIGH = get(snapshot, 'IO_REG', '_NR14_REG') + + # noteIndex = 64U - 36U == 28U + # f = freq[noteIndex] == 1650U == 0x0672 + + assert rAUD1LOW == 0x72 + retrig = 0x80 + assert rAUD1HIGH == 0x06 | retrig diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..0d06f43 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,4 @@ +pillow ~= 10.4.0 +pytest ~= 8.3.0 +pytest-describe ~= 2.2.0 +pytest-sugar ~= 1.0.0 diff --git a/tests/screens_test.py b/tests/screens_test.py new file mode 100644 index 0000000..281963c --- /dev/null +++ b/tests/screens_test.py @@ -0,0 +1,10 @@ +import pytest + +from bgb_get_snapshot import CHECKSCREEN + + +def describe_screens(): + + @pytest.mark.parametrize('snapshot', ['build/splash_screen.gb'], indirect=True) + def it_matches_snapshot(snapshot): + assert CHECKSCREEN(snapshot, "splash_screen.png") diff --git a/tests/splash_screen.c b/tests/splash_screen.c new file mode 100644 index 0000000..e1010dc --- /dev/null +++ b/tests/splash_screen.c @@ -0,0 +1,17 @@ +#include +#include + +#include "../Source/screen/splash.c" +#include "../Source/screen/utils.c" + +uint8_t j; + +void main(void) { + + displaySetup(); + + showSplashScreen(); + delay(100); + + EMU_BREAKPOINT; +} diff --git a/tests/splash_screen.png b/tests/splash_screen.png new file mode 100644 index 0000000..e2ac4aa Binary files /dev/null and b/tests/splash_screen.png differ diff --git a/tests/test.py b/tests/test.py new file mode 100644 index 0000000..1637c52 --- /dev/null +++ b/tests/test.py @@ -0,0 +1,14 @@ +import subprocess + + +result = subprocess.run([ + "wine", + "../bgb/bgb.exe", + "-autoexit", + "-hf", + "-stateonexit", + # "-screenonexit", "build/wav_test_load_and_play.bmp", + "-rom", "build/wav_test_load_and_play.gb" +]) + +print(result) diff --git a/tests/wav_test.py b/tests/wav_test.py new file mode 100644 index 0000000..4ad4def --- /dev/null +++ b/tests/wav_test.py @@ -0,0 +1,30 @@ +import pytest +from array import array + +from bgb_get_snapshot import get + + +def describe_wav_loading(): + + @pytest.mark.parametrize('snapshot', ['build/wav_test_load_and_play.gb'], indirect=True) + def it_loads_the_correct_waveform(snapshot): + _AUD3WAVERAM = get(snapshot, 'IO_REG', '__AUD3WAVERAM', 16) + + expected = array('B') + expected.frombytes(bytearray([0x22, 0x55, 0x77, 0xAA, 0xBB, 0xDD, 0xEE, 0xFF, + 0xEE, 0xDD, 0xBB, 0xAA, 0x77, 0x66, 0x44, 0x00])) + assert _AUD3WAVERAM == expected + + @pytest.mark.parametrize('snapshot', ['build/wav_test_load_and_play.gb'], indirect=True) + def it_plays_a_note(snapshot): + rAUD3LOW = get(snapshot, 'IO_REG', '_NR33_REG') + rAUD3HIGH = get(snapshot, 'IO_REG', '_NR34_REG') + + # noteIndex = 64 - 24 == 40 + # freq[noteIndex] == 1849U == 0x0739 + + retrig = 0x80 + assert rAUD3LOW == 0x39 + assert rAUD3HIGH == 0x07 | retrig + + # TODO: check frequency, env, vol etc diff --git a/tests/wav_test_load_and_play.c b/tests/wav_test_load_and_play.c new file mode 100644 index 0000000..ced7e97 --- /dev/null +++ b/tests/wav_test_load_and_play.c @@ -0,0 +1,43 @@ +#include + +#include "../Source/io/midi.h" + +#include "../Source/io/serial.c" +#include "../Source/mGB.h" +#include "../Source/synth/common.c" +#include "../Source/synth/pulse.c" +#include "../Source/synth/wav.c" + +bool systemIdle = true; + +uint8_t statusByte; +uint8_t addressByte; +uint8_t valueByte; +uint8_t capturedAddress; + +uint8_t result[2] = {0U, 0U}; + +void main(void) { + rAUDENA = AUDENA_ON; + rAUDVOL = AUDVOL_VOL_LEFT(7U) | AUDVOL_VOL_RIGHT(7U); + + setOutputPan(WAV, 64U); + + wavDataOffset = 1U * 16U; + loadWav(1U * 16U); + rAUD3LEVEL = 0x00U; // mimics mGB + + addressByte = 64U; // MIDI note + valueByte = 127U; // MIDI velocity + + playNoteWav(); + updateWav(); + // updateWavSweep(); + + delay(100); + + result[0] = wavCurrentFreq; + result[1] = wavCurrentFreq >> 8U; + + EMU_BREAKPOINT; +}