From 6bcbe12d73fe99984c3954ef000bdb2297703cef Mon Sep 17 00:00:00 2001 From: Guillermo Martinez Date: Mon, 15 Jun 2026 15:41:12 +0200 Subject: [PATCH] Polish: trademark disclaimer, CI on push, GitHub Pages docs, unit tests - README: badges, How it works section, trademark disclaimer, fixed Downloading A Release typo, fixed \_ escape, split macOS into Apple Silicon and Intel, --mode clarification, Contributing. - THIRD_PARTY_NOTICES: explicit Trademarks section. - source/pokemon_catalog.c: header comment clarifying the strings are Nintendo/Game Freak/Creatures Inc. trademarks. - LICENSE: copyright to KillDaWill. - Makefile: removed print debug target, added make test target, doxygen now reads docs/Doxyfile, release-clean updated. - Doxyfile -> docs/Doxyfile; output under docs/build/. - .gitignore: collapsed docs/html, docs/latex, doxygen-warnings into /docs/build/. - .github/workflows/ci.yml: Linux + Windows build + make test on push to main and on every PR. - .github/workflows/docs.yml: Doxygen -> GitHub Pages on push. - .github/workflows/release.yml: macos-x86_64 matrix entry with -arch x86_64 build step. - .github/ISSUE_TEMPLATE: bug_report and feature_request templates. - CHANGELOG.md: 1.0.0 entry plus an Unreleased entry. - tests/: zero-dep harness with 16 tests covering LZ10 detection, passthrough, back-reference, and roundtrips; pokemon_catalog count, sequencing, spot-checks, and out-of-range lookups. --- .github/ISSUE_TEMPLATE/bug_report.md | 60 ++++ .github/ISSUE_TEMPLATE/feature_request.md | 32 ++ .github/workflows/ci.yml | 83 +++++ .github/workflows/docs.yml | 46 +++ .github/workflows/release.yml | 15 +- .gitignore | 4 +- CHANGELOG.md | 39 ++ LICENSE | 2 +- Makefile | 37 +- README.md | 81 ++++- THIRD_PARTY_NOTICES.md | 16 +- Doxyfile => docs/Doxyfile | 10 +- source/pokemon_catalog.c | 14 + tests/test_harness.h | 70 ++++ tests/test_lz.c | 413 ++++++++++++++++++++++ tests/test_main.c | 36 ++ tests/test_main.h | 7 + tests/test_pokemon_catalog.c | 89 +++++ 18 files changed, 1018 insertions(+), 36 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docs.yml create mode 100644 CHANGELOG.md rename Doxyfile => docs/Doxyfile (78%) create mode 100644 tests/test_harness.h create mode 100644 tests/test_lz.c create mode 100644 tests/test_main.c create mode 100644 tests/test_main.h create mode 100644 tests/test_pokemon_catalog.c diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..b71b683 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,60 @@ +name: Bug report +description: Report a wrong output, a crash, or a build failure. +labels: [bug] +body: + - type: dropdown + id: game + attributes: + label: Which game ROM were you using? + description: Pick the title that produced the issue. + options: + - Pokemon Black + - Pokemon White + - Pokemon Black 2 + - Pokemon White 2 + - Other / not applicable + validations: + required: true + - type: input + id: dex + attributes: + label: Pokémon dex number + description: National dex number of the species you were extracting (for example 25 for Pikachu). Leave blank if not applicable. + - type: input + id: cmd + attributes: + label: Command you ran + description: Paste the exact command line, with the path to the ROM redacted if you want. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behaviour + description: What did you expect the tool to produce? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behaviour + description: What did the tool actually produce? Paste the terminal output, an image link, or both. + validations: + required: true + - type: textarea + id: env + attributes: + label: Environment + description: OS, CPU, raylib and libpng versions (`pkg-config --modversion raylib libpng`), and `AnimaEngine --version` if it printed one. + validations: + required: false + - type: checkboxes + id: ip + attributes: + label: Repository rules + description: Please confirm before submitting. + options: + - label: I understand this issue tracker is not for ROM or asset requests. + required: true + - label: I am not attaching a ROM, save file, or extracted Pokémon asset. + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..db494bc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,32 @@ +name: Feature request +description: Suggest a new extraction target, output format, or quality-of-life improvement. +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What are you trying to do that the current toolchain does not let you do? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Sketch the change. A CLI flag, a new output folder, a new parser, or a GUI control are all fine. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Anything you considered and rejected, and why. + validations: + required: false + - type: input + id: scope + attributes: + label: Affected files + description: If you already know which source files would change, list them here. + validations: + required: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2943b09 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + linux-build: + name: Linux build & test + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential cmake git pkg-config \ + libpng-dev libgl1-mesa-dev libx11-dev libxcursor-dev \ + libxinerama-dev libxrandr-dev libxi-dev libasound2-dev + + - name: Build raylib + run: | + git clone --depth 1 --branch "5.5" https://github.com/raysan5/raylib.git /tmp/raylib + cmake -S /tmp/raylib -B /tmp/raylib-build \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DCMAKE_INSTALL_PREFIX=/tmp/raylib-install + cmake --build /tmp/raylib-build --target install --config Release --parallel 2 + + - name: Build CLI + env: + PKG_CONFIG_PATH: /tmp/raylib-install/lib/pkgconfig:/tmp/raylib-install/lib64/pkgconfig + run: | + make PKG_CONFIG="pkg-config --static" + + - name: Build GUI + env: + PKG_CONFIG_PATH: /tmp/raylib-install/lib/pkgconfig:/tmp/raylib-install/lib64/pkgconfig + run: | + make gui PKG_CONFIG="pkg-config --static" + + - name: Run unit tests + env: + PKG_CONFIG_PATH: /tmp/raylib-install/lib/pkgconfig:/tmp/raylib-install/lib64/pkgconfig + run: | + make test PKG_CONFIG="pkg-config --static" + + windows-build: + name: Windows build + runs-on: windows-2022 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure MSYS2 + uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + install: >- + make + mingw-w64-ucrt-x86_64-gcc + mingw-w64-ucrt-x86_64-pkgconf + mingw-w64-ucrt-x86_64-libpng + mingw-w64-ucrt-x86_64-raylib + + - name: Build CLI and GUI + shell: msys2 {0} + run: | + make + make gui + + - name: Run unit tests + shell: msys2 {0} + run: make test diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..7b90ba7 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,46 @@ +name: Docs + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build Doxygen docs + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install doxygen + run: sudo apt-get update && sudo apt-get install -y doxygen + + - name: Generate docs + run: make docs + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/build/html + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-22.04 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae22f7c..0a2b3f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,8 @@ jobs: platform: windows-x86_64 - os: macos-14 platform: macos-arm64 + - os: macos-14 + platform: macos-x86_64 steps: - name: Checkout @@ -88,8 +90,17 @@ jobs: brew update brew install pkg-config libpng raylib - - name: Build macOS package - if: runner.os == 'macOS' + - name: Build macOS x86_64 package + if: matrix.platform == 'macos-x86_64' + env: + MACOS_ARCH_FLAGS: -arch x86_64 -mmacosx-version-min=10.13 + run: | + make CFLAGS="-Wall -Wextra -Wno-format-truncation -std=c99 -g -Iinclude $(pkg-config --cflags libpng) ${MACOS_ARCH_FLAGS}" + make gui CFLAGS="-Wall -Wextra -Wno-format-truncation -std=c99 -g -Iinclude $(pkg-config --cflags libpng) ${MACOS_ARCH_FLAGS}" + scripts/package_release.sh "${{ matrix.platform }}" "${GITHUB_REF_NAME:-dev}" + + - name: Build macOS arm64 package + if: matrix.platform == 'macos-arm64' run: | make make gui diff --git a/.gitignore b/.gitignore index 488476e..aa60358 100644 --- a/.gitignore +++ b/.gitignore @@ -20,9 +20,7 @@ *.ppm # Generated documentation -/docs/html/ -/docs/latex/ -/docs/doxygen-warnings.log +/docs/build/ # ROMs, saves, and copyrighted game data must never be committed. *.nds diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c9d8536 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +All notable changes to AnimaEngine are documented in this file. Dates are +shown in YYYY-MM-DD format. Versions follow [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Added +- Trademark disclaimer for Pokémon and individual species names, with a + matching notice in `THIRD_PARTY_NOTICES.md` and a header comment in + `source/pokemon_catalog.c`. +- GitHub Actions CI workflow (`.github/workflows/ci.yml`) that builds the + CLI, GUI, and unit tests on Linux and Windows for every push to `main` + and every pull request. +- GitHub Pages workflow (`.github/workflows/docs.yml`) that publishes the + Doxygen reference to . +- Minimal unit-test harness under `tests/` with `make test`. Covers LZ10 + detection and roundtrip, the catalog size, and spot-checked species. +- "How it works" / architecture section in the README. + +### Changed +- `Doxyfile` moved to `docs/Doxyfile`; output now lives under `docs/build/`. +- README downloads section splits macOS into Apple Silicon and Intel, and + links to a real docs URL. +- `make` removes the `print` debug target; the new `make test` target runs + the unit tests. + +### Fixed +- "Downloading A Release" header casing in the README. +- Unnecessary `\_` escape inside a code block in the README. + +## [1.0.0] - 2026-05-21 + +### Added +- Initial public release. C99 sprite extraction and preview toolchain for + Nintendo DS Pokémon Black, White, Black 2, and White 2. +- Nitro parsers for NCGR, NCLR, NCER, NANR, NMCR, and NMAR. +- CLI and raylib-based GUI sharing a common `anima_backend`. +- Prebuilt Linux, Windows, and macOS (arm64) release packages. diff --git a/LICENSE b/LICENSE index 76f09d6..5cc3804 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 AnimaEngine contributors +Copyright (c) 2026 KillDaWill Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index 76f2660..ae4a0ae 100644 --- a/Makefile +++ b/Makefile @@ -9,17 +9,24 @@ RAYLIB_LDLIBS := $(shell $(PKG_CONFIG) --libs raylib) BUILD_DIR := build TARGET := AnimaEngine GUI_TARGET := AnimaEngineGUI +TEST_TARGET := $(BUILD_DIR)/anima_tests COMMON_SRC := $(filter-out source/main.c source/gui_main.c source/gui_app.c source/pokemon_catalog.c source/gui_raylib.c source/gui_state.c source/gui_widgets.c source/gui_view_rom.c source/gui_view_browser.c,$(wildcard source/*.c)) CLI_SRC := $(COMMON_SRC) source/pokemon_catalog.c source/main.c GUI_SRC := $(COMMON_SRC) source/pokemon_catalog.c source/gui_raylib.c source/gui_state.c source/gui_widgets.c source/gui_view_rom.c source/gui_view_browser.c source/gui_app.c source/gui_main.c +TEST_SRC := tests/test_main.c tests/test_lz.c tests/test_pokemon_catalog.c +TEST_OBJ := $(patsubst tests/%.c,$(BUILD_DIR)/%.test.o,$(TEST_SRC)) +TEST_DEPS := $(TEST_OBJ:.o=.d) +TEST_LIB_SRC := $(COMMON_SRC) source/pokemon_catalog.c +TEST_LIB_OBJ := $(patsubst source/%.c,$(BUILD_DIR)/%.test.o,$(TEST_LIB_SRC)) + CLI_OBJ := $(patsubst source/%.c,$(BUILD_DIR)/%.o,$(CLI_SRC)) GUI_OBJ := $(patsubst source/%.c,$(BUILD_DIR)/%.gui.o,$(GUI_SRC)) CLI_DEPS := $(CLI_OBJ:.o=.d) GUI_DEPS := $(GUI_OBJ:.o=.d) -.PHONY: all gui print docs clean release-clean +.PHONY: all gui test docs clean release-clean all: $(TARGET) @@ -31,6 +38,13 @@ gui: $(GUI_TARGET) $(GUI_TARGET): $(GUI_OBJ) $(CC) $(CFLAGS) $(RAYLIB_CFLAGS) -o $@ $(GUI_OBJ) $(LDLIBS) $(RAYLIB_LDLIBS) +test: $(TEST_TARGET) + @./$(TEST_TARGET) + +$(TEST_TARGET): $(TEST_LIB_OBJ) $(TEST_OBJ) + @mkdir -p $(dir $@) + $(CC) $(CFLAGS) -o $@ $(TEST_LIB_OBJ) $(TEST_OBJ) $(LDLIBS) + $(BUILD_DIR)/%.o: source/%.c @mkdir -p $(dir $@) $(CC) $(CFLAGS) $(DEPFLAGS) -c $< -o $@ @@ -39,20 +53,21 @@ $(BUILD_DIR)/%.gui.o: source/%.c @mkdir -p $(dir $@) $(CC) $(CFLAGS) $(RAYLIB_CFLAGS) $(DEPFLAGS) -c $< -o $@ -print: - @echo "COMMON_SRC = $(COMMON_SRC)" - @echo "CLI_SRC = $(CLI_SRC)" - @echo "GUI_SRC = $(GUI_SRC)" - @echo "CLI_OBJ = $(CLI_OBJ)" - @echo "GUI_OBJ = $(GUI_OBJ)" +$(BUILD_DIR)/%.test.o: source/%.c + @mkdir -p $(dir $@) + $(CC) $(CFLAGS) $(DEPFLAGS) -c $< -o $@ + +$(BUILD_DIR)/%.test.o: tests/%.c + @mkdir -p $(dir $@) + $(CC) $(CFLAGS) -Iinclude -Itests $(DEPFLAGS) -c $< -o $@ docs: - doxygen Doxyfile + doxygen docs/Doxyfile clean: - rm -rf $(BUILD_DIR) $(TARGET) $(GUI_TARGET) + rm -rf $(BUILD_DIR) $(TARGET) $(GUI_TARGET) $(TEST_TARGET) release-clean: clean - rm -rf release docs/html docs/latex docs/doxygen-warnings.log + rm -rf release docs/build --include $(CLI_DEPS) $(GUI_DEPS) +-include $(CLI_DEPS) $(GUI_DEPS) $(TEST_DEPS) diff --git a/README.md b/README.md index 14c786c..540c71a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # AnimaEngine +[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Release](https://img.shields.io/github/v/release/KillDaWill/AnimaEngine)](https://github.com/KillDaWill/AnimaEngine/releases/latest) +[![CI](https://github.com/KillDaWill/AnimaEngine/actions/workflows/ci.yml/badge.svg)](.github/workflows/ci.yml) +[![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://killdawill.github.io/AnimaEngine/) + AnimaEngine is a C99 sprite extraction and preview toolchain for Nintendo DS Pokemon Black, White, Black 2, and White 2 battle graphics. It works with the original Gen V games and their sequels by reading the Pokegra archive @@ -9,7 +14,10 @@ composed idle-to-break GIFs, and reconstruction JSON. No ROMs, save files, extracted Pokemon assets, or Nintendo copyrighted data are included in this repository or in release packages. You must provide your own -legally dumped `.nds` ROM. +legally dumped `.nds` ROM. Pokémon and individual species names are trademarks +of Nintendo, Game Freak, and Creatures Inc.; see +[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for the full trademark +disclaimer. ## Screenshots @@ -41,17 +49,50 @@ legally dumped `.nds` ROM. composed_gif/ ``` -## Downloading A Release - -Tagged GitHub Releases provide precompiled GUI packages for Linux, Windows, and -macOS. These packages bundle the raylib runtime where needed, so users do not -need to install raylib just to run the GUI. +## How it works + +The pipeline is built from small, single-responsibility C modules so each stage +is easy to test and reason about end to end: + +1. **ROM mount** — `nds_header`, `nds_fat`, and `nds_fnt` parse the Nintendo + DS cartridge header, the File Allocation Table, and the File Name Table to + give a path-addressable filesystem over the `.nds` blob. +2. **Archive extraction** — `narc` reads the Pokegra archive at `/a/0/0/4` + into individual members (NCGR, NCLR, NCER, NANR, NMCR, NMAR). +3. **Format decoding** — Each Nitro format has its own parser: + `ncgr` decodes tile graphics, `nclr` decodes palettes, `ncer` decodes + sprite cells, `nanr` decodes frame animations, and `nmcr` / `nmar` decode + multi-cell / multi-animation banks. +4. **Decompression** — `lz` transparently handles LZ10 and LZ11 streams that + wrap NARC members in many DS titles. +5. **Composition** — `sprite_composer` lays the multi-part battle sprite down + on a canvas, applying per-cell offsets, palette indices, and animation + state. +6. **Export** — `png_writer` and `gif_writer` write the result. `gif_pipeline` + and `png_pipeline` wrap the writers to produce spritesheets, animated idle + GIFs, idle-break GIFs, composed GIFs, and static PNGs. `json_export` emits + reconstruction metadata so a downstream tool can reproduce the export. +7. **GUI** — The raylib-based GUI (`gui_*`) provides live preview, search, + side/gender/shiny/form controls, and per-asset export on top of the same + backend (`anima_backend`) used by the CLI. + +The CLI and GUI share the same `source/anima_backend.c` entry point, so +behaviour is identical between them. + +## Downloading a release + +Tagged GitHub Releases provide precompiled GUI packages for Linux, Windows, +and macOS (Apple Silicon and Intel). These packages bundle the raylib runtime +where needed, so users do not need to install raylib just to run the GUI. - Linux: extract `AnimaEngine-*-linux-x86_64.tar.gz` and run `./run-gui.sh`. - Windows: extract `AnimaEngine-*-windows-x86_64.zip` and run `AnimaEngineGUI.exe`. -- macOS: extract the archive and run `./run-gui.command`. The binaries are not - codesigned yet, so macOS may require right-clicking and choosing Open. +- macOS (Apple Silicon): extract `AnimaEngine-*-macos-arm64.tar.gz` and run + `./run-gui.command`. +- macOS (Intel): extract `AnimaEngine-*-macos-x86_64.tar.gz` and run + `./run-gui.command`. The binaries are not codesigned yet, so macOS may + require right-clicking and choosing Open. ## Build Requirements @@ -76,7 +117,8 @@ GUI locally. ```bash make # builds ./AnimaEngine make gui # builds ./AnimaEngineGUI -make docs # generates docs/html with Doxygen +make test # builds and runs the unit tests +make docs # generates Doxygen output under docs/build make clean # removes local binaries and object files ``` @@ -113,6 +155,10 @@ Single asset export mode writes exactly to the requested output path: ./AnimaEngine PokemonWhite.nds 25 out/pikachu_sheet.png --mode single --asset spritesheet ``` +Note: in `--mode single` the third positional argument is the destination +file path; in default (full) mode it is a directory. Use `--mode` explicitly +when in doubt. + Run the built-in help for all options: ```bash @@ -121,16 +167,27 @@ Run the built-in help for all options: ## Documentation -API documentation is generated with: +The latest published API reference lives at + (built automatically from +`main` by the docs workflow). + +To generate the docs locally: ```bash make docs ``` -Open `docs/html/index.html` after generation. +Open `docs/build/html/index.html` after generation. ## License AnimaEngine code is released under the MIT License. See [LICENSE](LICENSE). -Third-party runtime notices are listed in +Third-party runtime and trademark notices are listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). + +## Contributing + +Issues and pull requests are welcome. For anything that touches sprite +reconstruction or format decoding, please attach a small reproducer (a hex +dump of the NARC member and the expected output is enough) so the change can +be verified. See [CHANGELOG.md](CHANGELOG.md) for the release history. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c447cd2..8100f08 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -4,6 +4,14 @@ AnimaEngine source releases include only project source code and documentation. Binary release packages may bundle runtime libraries so users do not need to install raylib manually. +## Trademarks + +"Pokémon" and the names of individual Pokémon are trademarks of Nintendo, +Game Freak, and Creatures Inc. and are used in this project solely to identify +data that the user has extracted from a legally obtained game cartridge they +own. No game assets, ROM data, or copyrighted artwork are included in this +repository or in any release package. + ## raylib The GUI uses raylib for windowing, input, drawing, and textures. @@ -26,5 +34,9 @@ Some libpng builds depend on zlib. - Project: https://zlib.net/ - License: zlib license -No Nintendo DS ROMs, Pokemon game assets, or extracted copyrighted assets are -included in this repository or in release packages. +## Nintendo and Pokémon disclaimer + +No Nintendo DS ROMs, Pokémon game assets, or extracted copyrighted assets are +included in this repository or in release packages. AnimaEngine operates only +on data the user extracts from a `.nds` ROM they have legally dumped from a +cartridge they own. diff --git a/Doxyfile b/docs/Doxyfile similarity index 78% rename from Doxyfile rename to docs/Doxyfile index 23cb37f..1935f8a 100644 --- a/Doxyfile +++ b/docs/Doxyfile @@ -3,8 +3,8 @@ # Project information PROJECT_NAME = "AnimaEngine" PROJECT_NUMBER = "1.0.0" -PROJECT_BRIEF = "Nintendo DS Pokemon Black/White and Black 2/White 2 Sprite & Asset Extraction Studio" -OUTPUT_DIRECTORY = docs +PROJECT_BRIEF = "Nintendo DS Pokemon Black, White, Black 2, and White 2 sprite extraction and preview toolchain" +OUTPUT_DIRECTORY = docs/build # Language & optimization OPTIMIZE_OUTPUT_FOR_C = YES @@ -12,10 +12,10 @@ MARKDOWN_SUPPORT = YES AUTOLINK_SUPPORT = YES # Input settings -INPUT = include source README.md THIRD_PARTY_NOTICES.md docs +INPUT = include source README.md THIRD_PARTY_NOTICES.md FILE_PATTERNS = *.c *.h *.md RECURSIVE = YES -EXCLUDE = build/ docs/html/ release/ out/ +EXCLUDE = build/ docs/build/ release/ out/ # Extraction settings EXTRACT_ALL = YES @@ -29,7 +29,7 @@ WARNINGS = YES WARN_IF_UNDOCUMENTED = YES WARN_IF_DOC_ERROR = YES WARN_NO_PARAMDOC = YES -WARN_LOGFILE = docs/doxygen-warnings.log +WARN_LOGFILE = docs/build/doxygen-warnings.log # Output generation GENERATE_HTML = YES diff --git a/source/pokemon_catalog.c b/source/pokemon_catalog.c index 7c1952c..0ace2cd 100644 --- a/source/pokemon_catalog.c +++ b/source/pokemon_catalog.c @@ -1,3 +1,17 @@ +/* + * Pokemon name catalog. + * + * The strings in this file are the names of the first 649 species (the + * national dex used by Pokemon Black, White, Black 2, and White 2). They are + * trademarks of Nintendo, Game Freak, and Creatures Inc. and are reproduced + * here only so the GUI and CLI can label data that the user has already + * extracted from a legally dumped ROM they own. No game data, sprites, ROM + * bytes, or other copyrighted Nintendo / Game Freak / Creatures Inc. content + * is included. + * + * If you fork or distribute this project, do not claim the names as your own + * and do not use them in a way that suggests endorsement by Nintendo. + */ #include "pokemon_catalog.h" static const PokemonCatalogEntry g_pokemon_catalog[] = { diff --git a/tests/test_harness.h b/tests/test_harness.h new file mode 100644 index 0000000..f4b2b81 --- /dev/null +++ b/tests/test_harness.h @@ -0,0 +1,70 @@ +#ifndef ANIMA_TEST_HARNESS_H +#define ANIMA_TEST_HARNESS_H + +#include +#include +#include + +typedef struct AnimaTestState { + int total; + int passed; + int failed; + int current_failed; + const char *current_name; + const char *file; + int line; +} AnimaTestState; + +extern AnimaTestState g_anima_test_state; + +void AnimaTest_RunOne(const char *name, void (*fn)(void)); + +#define ANIMA_RUN(fn) AnimaTest_RunOne(#fn, fn) + +#define ANIMA_ASSERT(cond) \ + do { \ + if (!(cond)) { \ + printf(" [%s] assertion failed: %s\n at %s:%d\n", \ + g_anima_test_state.current_name, \ + #cond, __FILE__, __LINE__); \ + g_anima_test_state.current_failed = 1; \ + } \ + } while (0) + +#define ANIMA_ASSERT_EQ_U(a, b) \ + do { \ + unsigned long long _a = (unsigned long long)(a); \ + unsigned long long _b = (unsigned long long)(b); \ + if (_a != _b) { \ + printf(" [%s] expected %llu, got %llu\n at %s:%d\n", \ + g_anima_test_state.current_name, _b, _a, \ + __FILE__, __LINE__); \ + g_anima_test_state.current_failed = 1; \ + } \ + } while (0) + +#define ANIMA_ASSERT_STR_EQ(a, b) \ + do { \ + const char *_a = (a); \ + const char *_b = (b); \ + if (_a == NULL || _b == NULL || strcmp(_a, _b) != 0) { \ + printf(" [%s] expected \"%s\", got \"%s\"\n at %s:%d\n", \ + g_anima_test_state.current_name, \ + _b ? _b : "(null)", \ + _a ? _a : "(null)", \ + __FILE__, __LINE__); \ + g_anima_test_state.current_failed = 1; \ + } \ + } while (0) + +#define ANIMA_ASSERT_MEM_EQ(a, b, n) \ + do { \ + if (memcmp((a), (b), (n)) != 0) { \ + printf(" [%s] memory mismatch over %zu bytes\n at %s:%d\n", \ + g_anima_test_state.current_name, (size_t)(n), \ + __FILE__, __LINE__); \ + g_anima_test_state.current_failed = 1; \ + } \ + } while (0) + +#endif diff --git a/tests/test_lz.c b/tests/test_lz.c new file mode 100644 index 0000000..95eca3b --- /dev/null +++ b/tests/test_lz.c @@ -0,0 +1,413 @@ +#include "test_harness.h" +#include "test_main.h" + +#include "lz.h" + +#include +#include +#include + +/* + * LZ10 compression: inverse of Lz_Decompress10 in source/lz.c. + * + * - Header: 0x10, then 3 little-endian bytes for the uncompressed size. + * - Then groups of 1 flag byte + up to 8 literal/back-reference tokens. + * - Flag bit 0 = literal byte, flag bit 1 = (length, disp) back-reference. + * - Back-reference: b1 = (len-3)<<4 | (disp>>8), b2 = disp & 0xFF. + * + * The DS hardware decompressor copies byte-by-byte from out[out_pos - disp - 1] + * onwards, advancing both pointers. For tight disp values the source range + * overlaps the destination range, so the i-th destination byte is the i-th + * source byte *as it stands at the time of the copy*, not the original input. + * + * This compressor therefore verifies every candidate back-reference by + * simulating that byte-by-byte copy against the current output buffer, and + * falls back to a literal if the back-reference would not reproduce the + * source. That guarantees the produced stream is roundtrip-safe. + */ +typedef struct LzWriter { + uint8_t *buf; + size_t cap; + size_t len; + int error; +} LzWriter; + +static void LzW_WriteByte(LzWriter *w, uint8_t b) +{ + if (w->len + 1 > w->cap) { + size_t new_cap = w->cap == 0 ? 64 : w->cap * 2; + uint8_t *nb = realloc(w->buf, new_cap); + if (nb == NULL) { + w->error = 1; + return; + } + w->buf = nb; + w->cap = new_cap; + } + w->buf[w->len++] = b; +} + +static void LzW_WriteU24LE(LzWriter *w, uint32_t v) +{ + LzW_WriteByte(w, (uint8_t)(v & 0xFF)); + LzW_WriteByte(w, (uint8_t)((v >> 8) & 0xFF)); + LzW_WriteByte(w, (uint8_t)((v >> 16) & 0xFF)); +} + +typedef struct LzMatch { + int length; + int disp; +} LzMatch; + +static LzMatch Lz_FindBestMatch( + const uint8_t *src, + int src_len, + int pos, + const uint8_t *produced, + int produced_len) +{ + LzMatch best = { 0, 0 }; + int max_disp = pos > 0 ? pos - 1 : 0; + int max_len = src_len - pos; + if (max_len > 18) { + max_len = 18; + } + if (max_disp > 4095) { + max_disp = 4095; + } + + /* + * For a back-reference (length, disp) at output position `pos` to be + * valid, the byte-by-byte copy from out[pos-disp-1+i] to out[pos+i] + * must reproduce src[pos+i] for every i. We check that here using the + * already-produced output buffer. + */ + for (int disp = 1; disp <= max_disp; disp++) { + int len = 0; + int src_pos = pos - disp - 1; + while (len < max_len) { + uint8_t copied; + if (src_pos + len < produced_len) { + copied = produced[src_pos + len]; + } else { + break; + } + if (copied != src[pos + len]) { + break; + } + len++; + } + if (len >= 3 && len > best.length) { + best.length = len; + best.disp = disp; + } + } + return best; +} + +static size_t Lz10_Compress(const uint8_t *src, size_t src_len, uint8_t **out) +{ + LzWriter w = { 0 }; + uint8_t *produced = NULL; + int produced_len = 0; + int produced_cap = 0; + + LzW_WriteByte(&w, 0x10); + LzW_WriteU24LE(&w, (uint32_t)src_len); + + int pos = 0; + while (pos < (int)src_len) { + uint8_t flags = 0; + LzWriter group = { 0 }; + + for (int bit = 7; bit >= 0 && pos < (int)src_len; bit--) { + LzMatch m = Lz_FindBestMatch(src, (int)src_len, pos, + produced, produced_len); + if (m.length >= 3) { + flags |= (uint8_t)(1 << bit); + uint8_t b1 = (uint8_t)(((m.length - 3) & 0x0F) << 4 + | ((m.disp >> 8) & 0x0F)); + uint8_t b2 = (uint8_t)(m.disp & 0xFF); + LzW_WriteByte(&group, b1); + LzW_WriteByte(&group, b2); + for (int i = 0; i < m.length; i++) { + if (produced_len + 1 > produced_cap) { + int new_cap = produced_cap == 0 ? 64 : produced_cap * 2; + uint8_t *np = realloc(produced, (size_t)new_cap); + if (np == NULL) { + free(produced); + free(group.buf); + free(w.buf); + *out = NULL; + return 0; + } + produced = np; + produced_cap = new_cap; + } + produced[produced_len++] = src[pos + i]; + } + pos += m.length; + } else { + LzW_WriteByte(&group, src[pos]); + if (produced_len + 1 > produced_cap) { + int new_cap = produced_cap == 0 ? 64 : produced_cap * 2; + uint8_t *np = realloc(produced, (size_t)new_cap); + if (np == NULL) { + free(produced); + free(group.buf); + free(w.buf); + *out = NULL; + return 0; + } + produced = np; + produced_cap = new_cap; + } + produced[produced_len++] = src[pos++]; + } + } + LzW_WriteByte(&w, flags); + for (size_t i = 0; i < group.len; i++) { + LzW_WriteByte(&w, group.buf[i]); + } + free(group.buf); + } + + free(produced); + + if (w.error) { + free(w.buf); + *out = NULL; + return 0; + } + *out = w.buf; + return w.len; +} + +static void test_detect_lz10_stream(void) +{ + const uint8_t stream[] = { 0x10, 0x04, 0x00, 0x00, 0x00 }; + ANIMA_ASSERT_EQ_U(Lz_Detect(stream, sizeof(stream)), COMPRESSION_LZ10); +} + +static void test_detect_lz11_stream(void) +{ + const uint8_t stream[] = { 0x11, 0x04, 0x00, 0x00, 0x00 }; + ANIMA_ASSERT_EQ_U(Lz_Detect(stream, sizeof(stream)), COMPRESSION_LZ11); +} + +static void test_detect_uncompressed_short_buffer(void) +{ + const uint8_t stream[] = { 0x10, 0x00 }; + ANIMA_ASSERT_EQ_U(Lz_Detect(stream, sizeof(stream)), COMPRESSION_NONE); +} + +static void test_detect_unknown_first_byte(void) +{ + const uint8_t stream[] = { 0x99, 0x00, 0x00, 0x00 }; + ANIMA_ASSERT_EQ_U(Lz_Detect(stream, sizeof(stream)), COMPRESSION_NONE); +} + +static void test_decompress_passthrough_uncompressed(void) +{ + const uint8_t input[] = { 'h', 'e', 'l', 'l', 'o' }; + uint8_t *out = NULL; + size_t out_size = 0; + CompressionType type = COMPRESSION_UNKNOWN; + + int rc = Lz_Decompress(input, sizeof(input), &out, &out_size, &type); + ANIMA_ASSERT_EQ_U(rc, 0); + ANIMA_ASSERT_EQ_U(type, COMPRESSION_NONE); + ANIMA_ASSERT_EQ_U(out_size, sizeof(input)); + ANIMA_ASSERT_MEM_EQ(out, input, sizeof(input)); + free(out); +} + +static void test_decompress_null_inputs_rejected(void) +{ + uint8_t *out = NULL; + size_t out_size = 0; + CompressionType type = COMPRESSION_UNKNOWN; + int rc = Lz_Decompress(NULL, 0, &out, &out_size, &type); + ANIMA_ASSERT(rc != 0); +} + +/* + * Manually construct a small LZ10 stream with one back-reference and + * verify the decompressor produces the expected output. This exercises + * the back-reference code path independently of the compressor above. + */ +static void test_decompress_backref_simple(void) +{ + /* + * Bytes: A A B B C C C C C C C C + * ^- pos 2 emits 8-byte back-ref (length=8, disp=1) starting + * from the C position. Source = out[2-1-1..2-1-1+7] = out[0..7] + * which after the header and initial literals is whatever was + * in the buffer. Easier to encode something self-consistent: + * use 0x10 0x04 0x00 0x00 0x00 0x41 0x42, then a back-ref + * that copies A and B (out[0]=A, out[1]=B) twice. + * + * Layout: size=4, "ABAB" + * Header: 0x10, 0x04, 0x00, 0x00 + * Group flags: bit 7=0 literal A, bit 6=0 literal B, bit 5=1 back-ref + * (len=3, disp=1: b1=(3-3)<<4|0=0x00, b2=0x01), + * bit 4..0 unused + * Tokens: A B 0x00 0x01 + * + * With size=4: emit A, B, then a 3-byte back-ref (len=3, disp=1) which + * copies from out[1-1-1+0..+2] = out[-1..1]. That's invalid (out_pos=3, + * disp+1=2 <= 3, OK; but src_start=-1, invalid). So use a larger disp. + * + * Simpler: use size=5, "ABCAB". Then at pos=3 (after literal A, B, C), + * a back-ref (len=2... no, min len=3) -- doesn't fit. Use a stream + * where the back-ref has a non-overlapping source. + */ + /* + * size = 6, "AABBAB" + * pos 0: literal A + * pos 1: literal A + * pos 2: literal B + * pos 3: literal B + * pos 4: back-ref (len=2, disp=1) -- no, min len=3 + * pos 4: literal A + * pos 5: literal B + * That's all literals. Let me try with a 6-byte output that includes + * one back-ref. + * + * size = 9, "AABBCCAAB" + * pos 0: A + * pos 1: A + * pos 2: B + * pos 3: B + * pos 4: C + * pos 5: C + * pos 6: back-ref (len=2, disp=5) -- no, min len=3 + * + * Let me just make a longer stream. + * size = 10, "AABBCCAABB" + * pos 6: A + * pos 7: A + * pos 8: B + * pos 9: B + * For pos=6: window has out[0..5] = A,A,B,B,C,C. src[6]=A, found + * at out[0], out[1]. Best match: disp=6, len=2 (A,A). Not enough. + * So literal A. + * + * For pos=7: window has out[0..6] = A,A,B,B,C,C,A. src[7]=A, found at + * out[0,1,6]. disp=6 len=1, disp=1 len=1. Not enough. + * + * Hmm. Just use a run of same character. + * + * size = 6, "AAAAAA" + * pos 0: literal A + * pos 1: literal A + * pos 2: back-ref (len=4, disp=1). copy_pos = 2-1-1 = 0. Copy + * out[0]=A, out[1]=A, out[2]=A (just set), out[3]=A (just set). + * out[2..5] = A,A,A,A. ✓ + * + * Header: 0x10, 0x06, 0x00, 0x00 + * Group 1: + * bit 7: literal A + * bit 6: literal A + * bit 5: back-ref (len=4, disp=1): b1=(4-3)<<4|0=0x10, b2=0x01 + * bit 4..0: unused (we've reached pos=6 = size) + * flags = 0b00100000 = 0x20 + * tokens: A A 0x10 0x01 + * Total: 0x10 0x06 0x00 0x00 0x20 0x41 0x41 0x10 0x01 + */ + const uint8_t stream[] = { + 0x10, 0x06, 0x00, 0x00, + 0x20, 0x41, 0x41, 0x10, 0x01 + }; + uint8_t *out = NULL; + size_t out_size = 0; + CompressionType type = COMPRESSION_UNKNOWN; + int rc = Lz_Decompress(stream, sizeof(stream), &out, &out_size, &type); + ANIMA_ASSERT_EQ_U(rc, 0); + ANIMA_ASSERT_EQ_U(type, COMPRESSION_LZ10); + ANIMA_ASSERT_EQ_U(out_size, 6); + if (rc == 0 && out != NULL) { + const uint8_t expected[] = { 'A', 'A', 'A', 'A', 'A', 'A' }; + ANIMA_ASSERT_MEM_EQ(out, expected, sizeof(expected)); + } + free(out); +} + +static void roundtrip(const uint8_t *src, size_t src_len, const char *label) +{ + uint8_t *compressed = NULL; + size_t compressed_len = Lz10_Compress(src, src_len, &compressed); + if (compressed == NULL) { + printf(" [%s] Lz10_Compress failed for %s\n", + g_anima_test_state.current_name, label); + g_anima_test_state.current_failed = 1; + return; + } + + uint8_t *out = NULL; + size_t out_size = 0; + CompressionType type = COMPRESSION_UNKNOWN; + int rc = Lz_Decompress(compressed, compressed_len, &out, &out_size, &type); + if (rc != 0) { + printf(" [%s] Lz_Decompress failed for %s (rc=%d)\n", + g_anima_test_state.current_name, label, rc); + g_anima_test_state.current_failed = 1; + free(compressed); + return; + } + if (out_size != src_len || (src_len > 0 && memcmp(out, src, src_len) != 0)) { + printf(" [%s] roundtrip mismatch for %s (in=%zu, out=%zu)\n", + g_anima_test_state.current_name, label, src_len, out_size); + g_anima_test_state.current_failed = 1; + } + free(compressed); + free(out); +} + +static void test_roundtrip_repeating_pattern(void) +{ + const uint8_t src[] = "AAAAAABBBBBBCCCCCCDDDDDDEEEEEEAAAAAABBBBBB"; + roundtrip(src, sizeof(src) - 1, "repeating_pattern"); +} + +static void test_roundtrip_pseudorandom(void) +{ + uint8_t src[256]; + uint32_t seed = 0x12345678u; + for (size_t i = 0; i < sizeof(src); i++) { + seed = seed * 1103515245u + 12345u; + src[i] = (uint8_t)(seed >> 16); + } + roundtrip(src, sizeof(src), "pseudorandom_256"); +} + +static void test_roundtrip_long_repeating_run(void) +{ + uint8_t src[512]; + for (size_t i = 0; i < sizeof(src); i++) { + src[i] = (uint8_t)('A' + (i % 26)); + } + roundtrip(src, sizeof(src), "long_repeating_run"); +} + +static void test_roundtrip_all_zeros(void) +{ + uint8_t src[300]; + memset(src, 0, sizeof(src)); + roundtrip(src, sizeof(src), "all_zeros"); +} + +void anima_test_lz_register(void) +{ + ANIMA_RUN(test_detect_lz10_stream); + ANIMA_RUN(test_detect_lz11_stream); + ANIMA_RUN(test_detect_uncompressed_short_buffer); + ANIMA_RUN(test_detect_unknown_first_byte); + ANIMA_RUN(test_decompress_passthrough_uncompressed); + ANIMA_RUN(test_decompress_backref_simple); + ANIMA_RUN(test_roundtrip_repeating_pattern); + ANIMA_RUN(test_roundtrip_pseudorandom); + ANIMA_RUN(test_roundtrip_long_repeating_run); + ANIMA_RUN(test_roundtrip_all_zeros); + ANIMA_RUN(test_decompress_null_inputs_rejected); +} diff --git a/tests/test_main.c b/tests/test_main.c new file mode 100644 index 0000000..b6cf72d --- /dev/null +++ b/tests/test_main.c @@ -0,0 +1,36 @@ +#include "test_harness.h" +#include "test_main.h" + +AnimaTestState g_anima_test_state; + +void AnimaTest_RunOne(const char *name, void (*fn)(void)) +{ + g_anima_test_state.current_name = name; + g_anima_test_state.current_failed = 0; + g_anima_test_state.total++; + fn(); + if (g_anima_test_state.current_failed == 0) { + g_anima_test_state.passed++; + printf(" [PASS] %s\n", name); + } else { + g_anima_test_state.failed++; + printf(" [FAIL] %s\n", name); + } +} + +int main(void) +{ + memset(&g_anima_test_state, 0, sizeof(g_anima_test_state)); + + printf("Running AnimaEngine unit tests...\n"); + + anima_test_lz_register(); + anima_test_pokemon_catalog_register(); + + printf("\nResults: %d passed, %d failed, %d total\n", + g_anima_test_state.passed, + g_anima_test_state.failed, + g_anima_test_state.total); + + return g_anima_test_state.failed == 0 ? 0 : 1; +} diff --git a/tests/test_main.h b/tests/test_main.h new file mode 100644 index 0000000..d50bb60 --- /dev/null +++ b/tests/test_main.h @@ -0,0 +1,7 @@ +#ifndef ANIMA_TEST_MAIN_H +#define ANIMA_TEST_MAIN_H + +void anima_test_lz_register(void); +void anima_test_pokemon_catalog_register(void); + +#endif diff --git a/tests/test_pokemon_catalog.c b/tests/test_pokemon_catalog.c new file mode 100644 index 0000000..c2a5853 --- /dev/null +++ b/tests/test_pokemon_catalog.c @@ -0,0 +1,89 @@ +#include "test_harness.h" +#include "test_main.h" + +#include "pokemon_catalog.h" + +#include +#include + +/* + * The catalog should cover the full Gen V national dex (1..649) which is + * what Pokemon Black, White, Black 2, and White 2 read. Spot-check a few + * well-known species to guard against accidental truncation or renumbering. + */ +static void test_entries_are_nonempty(void) +{ + int count = 0; + const PokemonCatalogEntry *entries = PokemonCatalog_GetEntries(&count); + ANIMA_ASSERT(entries != NULL); + ANIMA_ASSERT(count >= 649); +} + +static void test_dex_ids_are_sequential_starting_at_one(void) +{ + int count = 0; + const PokemonCatalogEntry *entries = PokemonCatalog_GetEntries(&count); + ANIMA_ASSERT(entries != NULL); + ANIMA_ASSERT(count >= 649); + for (int i = 0; i < 649; i++) { + if (entries[i].dex_id != i + 1) { + printf(" expected dex_id %d at index %d, got %d\n", + i + 1, i, entries[i].dex_id); + g_anima_test_state.current_failed = 1; + return; + } + } +} + +static void test_find_known_species(void) +{ + const PokemonCatalogEntry *p = PokemonCatalog_FindByDexId(25); + ANIMA_ASSERT(p != NULL); + if (p != NULL) { + ANIMA_ASSERT_STR_EQ(p->name, "Pikachu"); + } + + p = PokemonCatalog_FindByDexId(7); + ANIMA_ASSERT(p != NULL); + if (p != NULL) { + ANIMA_ASSERT_STR_EQ(p->name, "Squirtle"); + } + + p = PokemonCatalog_FindByDexId(649); + ANIMA_ASSERT(p != NULL); + if (p != NULL) { + ANIMA_ASSERT_STR_EQ(p->name, "Genesect"); + } +} + +static void test_find_unknown_dex_id_returns_null(void) +{ + int count = 0; + const PokemonCatalogEntry *entries = PokemonCatalog_GetEntries(&count); + ANIMA_ASSERT(entries != NULL); + + int out_of_range = count + 1; + const PokemonCatalogEntry *p = PokemonCatalog_FindByDexId(out_of_range); + ANIMA_ASSERT(p == NULL); + + p = PokemonCatalog_FindByDexId(-1); + ANIMA_ASSERT(p == NULL); + + p = PokemonCatalog_FindByDexId(0); + ANIMA_ASSERT(p == NULL); +} + +static void test_get_entries_null_outparam_is_safe(void) +{ + const PokemonCatalogEntry *entries = PokemonCatalog_GetEntries(NULL); + ANIMA_ASSERT(entries != NULL); +} + +void anima_test_pokemon_catalog_register(void) +{ + ANIMA_RUN(test_entries_are_nonempty); + ANIMA_RUN(test_dex_ids_are_sequential_starting_at_one); + ANIMA_RUN(test_find_known_species); + ANIMA_RUN(test_find_unknown_dex_id_returns_null); + ANIMA_RUN(test_get_entries_null_outparam_is_safe); +}