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
+[](LICENSE)
+[](https://github.com/KillDaWill/AnimaEngine/releases/latest)
+[](.github/workflows/ci.yml)
+[](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);
+}