Development¶
This is the full developer reference — build setup, IDE configuration, project structure, and release workflow. For commit message conventions, see CONTRIBUTING.md; branch naming rules are below.
Primary development happens on Fedora Linux; the Windows bench is kept for the tasks listed in What still needs the Windows bench.
Prerequisites¶
On Linux (GCC or Clang):
sudo dnf install gcc-c++ clang cmake ninja-build python3 git SDL3-devel
Any distribution works with the equivalents: a C++17 compiler, CMake 3.21+,
Ninja, Python 3 (release scripts and the real-asset test harness), and Git.
SDL3-devel serves the fxs build; where no system SDL3 exists, the
build automatically compiles a pinned, checksummed SDL3 from source instead
(see Vendored Dependencies and
ADR-0001).
On Windows (MSVC):
- Visual Studio 2022 or 2026 with the following workloads:
- Desktop development with C++
- C++ CMake tools for Windows (installs cmake.exe into the VS directory)
- Windows 10 or 11 recommended for development (target runtime is Windows 7+)
- Git, and Python 3 for the release scripts and real-asset harness
CMake ships with Visual Studio but is not added to PATH by default. Find
cmake.exe under your VS install (typically
Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\) and add that
directory to your user PATH.
Building¶
All builds go through CMake presets:
| Preset | OS | Compiler | Config | Binary dir |
|---|---|---|---|---|
gcc |
Linux | g++ | Debug | build/gcc/ |
clang |
Linux | clang++ | Debug | build/clang/ |
asan-ubsan |
Linux | clang++ + ASan/UBSan | Debug | build/asan-ubsan/ |
coverage |
Linux | g++ + gcov (--coverage) |
Debug | build/coverage/ |
fuzz |
Linux | clang++ + libFuzzer/ASan/UBSan | Debug | build/fuzz/ |
release |
Linux | default | Release | build/release/ |
msvc |
Windows | MSVC x64 | multi-config | build/ |
Linux day-to-day:
cmake --preset gcc # configure (first time per preset)
cmake --build --preset gcc # build everything
ctest --preset gcc # run the test suite
Swap gcc for clang, asan-ubsan (sanitized build), or release
(optimized). Single targets: cmake --build --preset gcc --target fx.
Binaries land in build/<preset>/cli/fx, build/<preset>/gui/fxs,
build/<preset>/lib/libfx_lib.a, and build/<preset>/tests/fx_tests.
Two options steer the GUI build:
FX_BUILD_GUI(defaultON;OFFin thecoverageandfuzzpresets) — buildfxsand its tests.FX_SDL3_VENDORED(defaultOFF) — skipfind_package(SDL3)and always build the pinned FetchContent SDL3 statically; CI and the release workflow set it so shipped binaries stay self-contained.
Windows:
cmake --preset msvc # configure
cmake --build --preset msvc-debug # Debug build (development)
cmake --build --preset msvc # Release build (CI/release parity)
ctest --preset msvc # run the test suite (Release)
The msvc preset uses the installed Visual Studio's default generator, so
artifacts keep their historical multi-config paths: build\cli\Release\fx.exe,
build\gui\Release\fxs.exe, build\lib\Release\fx_lib.lib (swap Debug
for debug builds).
Plain cmake -B build still works on both OSes for one-off configures, and is
the path embedders use (see api.md).
Platform notes¶
- macOS is unsupported and ships no release artifacts; the presets are
deliberately Linux/Windows-only. CI does compile and run the suite on
macOS as an informational, never-blocking check (a plain
cmake -Bbuild withcontinue-on-error— see the CI table below), so gross portability breaks surface early even though the platform stays unsupported. - The
msvcpreset assumes the platform-default generator is Visual Studio; aCMAKE_GENERATORenvironment override (e.g. to Ninja) conflicts with itsx64architecture setting. fxandfxsread arguments through narrowargv, but both embed an application manifest (cmake/win-utf8.manifest) declaring the UTF-8 active code page, so on Windows 10 1903+ non-ASCII file paths arrive and open correctly. On older Windows the manifest entry is ignored and paths outside the ANSI code page can still fail to open — harmless for FA's own data, which is 8.3 ASCII throughout (#165).fxs.exebuilds as aWIN32-subsystem app (no console window), so PowerShell launches it detached without waiting. For the headless--smokesweep, pipe the output so the shell waits and reports$LASTEXITCODE— see gui.md.
Testing¶
ctest --preset <name> runs several layers:
- Unit suite (
fx_tests): Catch2 codec tests against in-memory data and the committed synthetic fixtures under tests/fixtures/ (loaded throughtests/support/fixture.h). Catch2 is fetched with FetchContent on the first configure of each preset directory, which needs network access; for offline work pointFETCHCONTENT_SOURCE_DIR_CATCH2at an existing Catch2 v3.7.1 checkout. embed_smoke: configures and builds the api.md consumer contract as a child project — repo root viaadd_subdirectory, linkingfx::libinto a shared library, offline. The child inherits the parent's generator, compiler, and config, but not sanitizer flags (deliberate: the test validates the consumer contract, not instrumentation).cli_e2e_lib: round-trips a synthetic archive through the realfxbinary — pack, ls, extract, unpack, patch — byte-comparing every output.- GUI tests (label
gui):gui_testscovers the display-free gui units (string helpers, async-dialog completion queue, preview matrix math, and the audio player state machine on miniaudio's null backend viaFX_AUDIO_NULL=1) on every leg;gui_smokerunsfxs --smoke— three frames rendered headlessly — on Linux (CI wraps it inxvfb-run). - Fuzz smoke runs (
fuzzpreset only, labelfuzz): each libFuzzer harness fuzzes for 60 seconds from its committed seed corpus — see Fuzzing. - Docs checks (label
docs):check_status_selftestandcheck_status_docsruntools/check_status.pyon every preset leg, so a codec change that invalidates a format spec's front-matter claims — or leaves the generated status matrix stale — failsctestlocally, not just the CIdocs-statusjob. The same checker validates the game-executable reconstruction symbol database (db/), per-subsystem coverage, and the generated reconstruction matrix. See spec-authoring.md and db/README.md.
Test data follows the synthetic-first fixture policy
(tests/fixtures/README.md):
everything committed is synthetic — produced by our own encoders or
hand-assembled from the format specs — and validation against real game data
runs only behind FX_FA_ROOT / FX_FA_DISC1+FX_FA_DISC2 (below), under the
ctest label integration, on the local benches. CI never sees a byte of game
content.
Real-asset integration mode (FX_FA_ROOT)¶
With a licensed FA install available, configure with -DFX_FA_ROOT (or set
the FX_FA_ROOT environment variable) to register the fa_extract_manifest
test:
cmake --preset gcc -DFX_FA_ROOT="/path/to/Fighters Anthology"
ctest --preset gcc -R fa_extract_manifest
The test unpacks every .LIB in the install and verifies each extracted
file's SHA-256 against the committed manifest
(tests/integration/fa-extract.sha256).
A manifest generated on one OS and verified on the other proves the extraction
pipeline is byte-identical across platforms. To regenerate after an intended
output change:
python3 tests/integration/fa_manifest.py generate \
--fx build/release/cli/fx --fa-root "$FX_FA_ROOT" \
--out tests/integration/fa-extract.sha256 \
--work-dir build/release/fa-extract-work
Hashes are facts about the game data; the assets themselves must never enter
the repository (*.LIB, *.PIC, *.PAL, … are gitignored — keep it that way).
Real-media install mode (FX_FA_DISC1/FX_FA_DISC2)¶
FX_FA_ROOT proves the pipeline that reads an installed tree. The retail discs
prove the one that writes it — the ESA installer archive and the fx install
engine. Point both variables at the two disc roots (an ISO mount, or a copy of
each) to register the fa_disc_install test:
udisksctl loop-setup -r -f disc1.iso && udisksctl mount -b /dev/loop0 # etc.
cmake --preset gcc \
-DFX_FA_DISC1=/run/media/you/FA_1_00F1 -DFX_FA_DISC2=/run/media/you/FA_1_00F
ctest --preset gcc -R fa_disc_install
Both discs are needed: disc 1 carries the installer archive and no LIBs, disc 2
the reverse. Which is which is decided by content — the volume labels are
identical, and a Linux mount hands them out in either order, so the test asserts
the plan comes out the same whichever way round they are named. It checks the
plan for both scripts, extracts every SETUP.ESA entry against the committed
manifest (tests/integration/fa-esa.sha256),
repacks the 110 MB archive byte-for-byte, and runs a real minimal install (73 MiB)
that it then verifies back against the disc. Regenerate the manifest exactly as
for fa_manifest.py, with fa_disc.py generate --out ….
Three options extend it:
-DFX_FA_DISC_FULL=ONalso executes the full 989 MiB install. The plan for it is checked either way — a plan is pure — so this only adds the copy.-DFX_FA_ROOT=…set alongside enables the cross-build oracle: a fresh install off the 1.00F discs is compared file-by-file against that 1.02F tree, and must differ in exactly the set the patch rewrites (FA.EXE,FA.SMS,FA_1.LIB,FA_2.LIB) and no other. That check is the executable statement of the gap the RTPatch codec closes: the discs ship 1.00F,db/describes 1.02F.-DFX_FA_PATCH=…(the updaterfae102.exe) set alongside closes that gap: the harness installs 1.00F, runsfx install --patch, and checks the four rebuilt game files against the committed 1.02F manifest. Where the cross-build oracle asserts which files differ, this proves the patch produces the 1.02F tree. (FX_FA_PATCHalso registers a standalonefa_patch_applytest — see below.)
The three self-oracles here need no committed hash at all: the four entries that
sit both inside SETUP.ESA and loose on disc 1 (README.TXT, IP.EXE,
IP.CFG, EAHELP.HLP — three of them PKWare-compressed) must extract to the
same bytes as the loose copies. The disc checks itself.
Real-media RTPatch mode (FX_FA_PATCH)¶
FX_FA_PATCH names the 1.02F updater fae102.exe. Given the 1.00F originals —
from Disc 1's SETUP.ESA (FX_FA_DISC1) or an explicit FX_FA_PATCH_SOURCE
directory — the fa_patch_apply test applies the patch with fx patch apply and
checks each rebuilt file's SHA-256 against the committed
fa-patch.sha256:
cmake --preset gcc -DFX_FA_PATCH=/path/to/fae102.exe -DFX_FA_DISC1=/run/media/you/FA_1_00F1
ctest --preset gcc -R fa_patch_apply
FA.SMS, FA_1.LIB, FA_2.LIB and the official FA.EXE all reconstruct
byte-for-byte to 1.02F. Regenerate the manifest with fa_patch.py generate
--out … after an intended output change. Set FX_FA_PATCH alongside the disc
variables (above) to also drive the full install-then---patch pipeline.
The committed FA.EXE hash is the pristine official 1.02F byte; a licensed
install's copy may differ by one byte if it carries a no-CD crack (a JNZ→JZ
flip in the CD check) — a property of that install, not of the patch.
Fuzzing¶
libFuzzer harnesses live in fuzz/ and build only under the fuzz
preset (Clang; the whole tree gets coverage instrumentation plus ASan/UBSan):
cmake --preset fuzz
cmake --build --preset fuzz --target fuzzers
ctest --preset fuzz # 60-second smoke run per harness
For a longer local session, run a harness directly against its seed corpus:
build/fuzz/fuzz/fuzz_ealib -max_total_time=600 \
-dict=fuzz/fuzz_ealib.dict fuzz/corpus/fuzz_ealib
To add a harness (the Phase 4 rollout, epic #51):
create fuzz/<name>.cpp implementing LLVMFuzzerTestOneInput, add
fx_add_fuzzer(<name>) to fuzz/CMakeLists.txt, and commit tiny synthetic
seeds under fuzz/corpus/<name>/ — never game assets, and name them
seed-*.bin (*.LIB and friends are gitignored by design). An optional
fuzz/<name>.dict is picked up automatically. The ctest smoke run and the CI
fuzz job need no further wiring.
Fuzzing runs in two CI tiers (#119):
- Per-PR smoke (
fuzz-smoke, in ci.yml): 60 seconds per harness over its committed seed corpus — cheap enough to run every harness on every PR, so a parser regression on malformed input fails the PR that introduces it. - Weekly deep run
(fuzz-deep.yml,
Tuesdays +
workflow_dispatch): 30 minutes per harness with the same limits and dictionaries, deep enough to reach states the smoke never will.
Finding policy: findings are written as crash-*/oom-*/timeout-*
reproducers (gitignored; in CI they upload as the fuzz-findings /
fuzz-deep-findings artifacts). The deep run also fails red and auto-files —
or extends — an open fuzzing-labeled issue pointing at the run and its
reproducer artifact. Minimize with the harness's -minimize_crash=1, then
fix and add a Catch2 regression test in the same PR as the fix.
Continuous Integration¶
Every PR to main (and every push to it) runs the
CI workflow: a matrix that runs
cmake --preset <p>, cmake --build --preset <p>, ctest --preset <p> per leg.
| Check | Runner | Proves |
|---|---|---|
gcc |
ubuntu-latest | Linux GCC build + full test suite |
clang |
ubuntu-latest | Linux Clang build + full test suite |
asan-ubsan |
ubuntu-latest | Full suite under AddressSanitizer + UBSan — memory errors and UB in the binary parsers fail the PR |
msvc |
windows-latest | Windows MSVC build + full test suite |
macos (informational) |
macos-latest | AppleClang build + suite as an early-warning signal; continue-on-error — never blocks a PR |
fuzz-smoke |
ubuntu-latest | 60-second libFuzzer run per harness over its seed corpus — parser crashes on malformed input fail the PR |
| Fuzz (deep) | ubuntu-latest | Weekly scheduled 30-minute-per-harness run; findings upload as reproducer artifacts and auto-file a fuzzing issue — see Fuzzing |
docs-status |
ubuntu-latest | tools/check_status.py --self-test + --check: format-spec front-matter and template conformance (spec-authoring.md), encoding and link hygiene across all markdown — relative links resolve case-exactly, links in docs/ stay inside the docs tree, repo blob/tree URLs point at real main paths — front-matter claims vs. lib/+cli/+tests/+fuzz/ reality, and currency of the generated status matrix — a stale matrix fails the PR |
coverage |
ubuntu-latest | gcov line coverage over lib/ + cli/, gcovr summary on the run's summary page + HTML artifact; enforces a floor that only ratchets up (raised by epic #50, never lowered) |
| CodeQL | ubuntu-latest | Static analysis (security-extended) over all first-party C++; also runs weekly against refreshed query packs |
| Docs | ubuntu-latest | mkdocs build --strict over the whole docs tree — a broken link, broken anchor, or page missing from the site nav fails the PR; on push to main it also deploys the published site (runs only when docs or site config change) |
Every uses: in the workflows is pinned to a commit SHA (with the version in a
trailing comment); Dependabot
keeps the pins current. Test presets are configured with noTestsAction: error,
so a leg that discovers zero tests fails instead of passing vacuously.
Documentation Site¶
The docs/ tree is published as https://fighterscodex.com/ —
an mkdocs-material site built from the same markdown sources GitHub renders.
The Docs workflow
builds the site with mkdocs build --strict on every PR that touches docs or
site config, and deploys to GitHub Pages on push to main. Strict mode plus
the validation: block in
mkdocs.yml
means a broken link, a broken #anchor, or a page missing from the nav fails
the build — site health is CI-enforced, not aspirational.
The site is served from the custom domain fighterscodex.com. The domain lives
in the repository's Pages configuration, not in the deployed content: because
Pages is deployed from a workflow artifact rather than a branch, GitHub does not
read a CNAME file out of the artifact to set it (that behaviour is branch-deploy
only). docs/CNAME is still committed — mkdocs copies it verbatim into site/,
and it is the conventional backstop GitHub's own docs ask for — but it is not what
makes the domain take effect, and editing it alone will not change the domain.
DNS: the apex resolves via A/AAAA records to GitHub's Pages IPs, www is a CNAME
to jomkz.github.io (registrar DNS forms want the bare hostname — a trailing dot
is zone-file syntax and produces a malformed record), and fighterscodex.org is a
registrar-level redirect to the .com. HTTPS is enforced, on a GitHub-provisioned
Let's Encrypt certificate.
To preview locally (the site toolchain is the repo's only pip dependency, pinned in requirements-docs.txt):
python3 -m venv ~/.venvs/fx-docs
~/.venvs/fx-docs/bin/pip install -r requirements-docs.txt
~/.venvs/fx-docs/bin/mkdocs serve # live preview at http://127.0.0.1:8000/
~/.venvs/fx-docs/bin/mkdocs build --strict # what CI runs
Two conventions keep GitHub and the site rendering identically: heading
anchors use GitHub-style slugs (configured via pymdownx.slugs.slugify), and
links from docs/ to files outside the docs tree (source, workflows, repo
meta) are written as absolute github.com/... URLs — check_status.py
verifies those URLs point at real paths, so they can't silently rot.
The site also exposes a Print / PDF page (mkdocs-print-site-plugin, last
in the plugins: list by requirement): every page concatenated into a single
document with a cover and table of contents — use the browser's print-to-PDF
for an offline copy of the whole knowledge base.
IDE Setup¶
VS Code (Linux and Windows)¶
Recommended extensions are declared in .vscode/extensions.json: C/C++,
CMake Tools, and Hex Editor (useful for inspecting binary game assets). CMake
Tools reads CMakePresets.json natively — pick the preset from the status
bar. IntelliSense works from the compile_commands.json each Linux preset
exports (clangd users: --compile-commands-dir=${workspaceFolder}/build/gcc).
Tasks in .vscode/tasks.json are preset-based and pick the right commands per
OS (gcc preset on Linux, msvc on Windows):
| Task | Shortcut | Action |
|---|---|---|
| Configure | — | cmake --preset gcc / cmake --preset msvc |
| Build all | Ctrl+Shift+B |
cmake --build --preset gcc / --preset msvc-debug |
| Build fx (CLI) | — | Build all, restricted to the fx target |
| Build fx_tests | — | Build the test binary |
| Run tests | — | ctest --preset gcc / ctest --preset msvc |
| Run fxs | — | Launches the GUI (both OSes) |
If cmake is not in PATH on Windows, add it via terminal.integrated.env.windows
in your user settings.json.
Visual Studio¶
Open the generated solution directly (build\fighters-codex.sln after
cmake --preset msvc), or use File → Open → CMake… on the root
CMakeLists.txt — VS configures the project automatically. Set the startup
project to fxs for F5 debugging.
Project Structure¶
fighters-codex/
├── lib/ # fx_lib static library (all codecs, no platform deps)
│ ├── include/fx/ # public headers
│ ├── src/ # codec implementations
│ └── vendor/ # stb (vendored)
├── cli/ # fx CLI frontend
├── gui/ # fxs ImGui frontend (SDL3 + OpenGL 3.3, Linux + Windows)
│ ├── src/
│ │ ├── main.cpp # SDL3 + GL host, event loop, window placement, ImGui init
│ │ ├── app.h / app.cpp # App class, session management, menu bar
│ │ ├── platform/ # window, texture, dialogs, theme, fonts, audio player
│ │ ├── panels/ # lib_browser, editor_host, preview
│ │ └── editors/ # per-format editors (audio, mission, brf, pic, ...)
│ └── vendor/ # Dear ImGui, glad, miniaudio (vendored)
├── tests/ # Catch2 suite, embed smoke, CLI e2e, FA integration
├── tools/ # dll_info and other RE utilities
├── scripts/ # release tooling, Ghidra headless scripts
├── db/ # game-executable reconstruction symbol database (epic #209)
└── docs/ # documentation (the primary output)
Adding a new editor¶
- Add a new
EditorKindenum value ingui/src/app.h - Wire the file extension to the new kind in
App::OpenEntry()(app.cpp) - Create
gui/src/editors/<format>_editor.hand<format>_editor.cpp - Call
Draw<Format>Editor(app)fromDrawEditorHost()ingui/src/panels/editor_host.cpp - Add the
.cpptogui/CMakeLists.txt
Editors never touch SDL or GL directly — file dialogs, GPU textures, and
audio go through gui/src/platform/. Dialogs are asynchronous: the
continuation runs frames later, so capture inputs by value at request time
and re-validate app.editor state on arrival (see
gui/src/platform/dialogs.h).
What still needs the Windows bench¶
- fxs interactive verification on Windows — CI compiles it and runs
the display-free
gui_tests, but Windows runners expose no GL 3.3, so rendering, native dialogs, and theming need eyes on the bench - Release packaging verification for the Windows zips (the Linux tarballs are exercised by the release workflow's Linux leg and its dry-run mode)
re-gameplaywork: anything requiring the running game, batched into bench campaigns (epic #56)- The Windows-side
fa_extract_manifestverify run that closes the cross-platform byte-identity loop for epic #42
Branch Names¶
<type>/<short-kebab-description>
The type prefix matches the Conventional Commit type of the work (see CONTRIBUTING.md):
| Prefix | Use for | Example |
|---|---|---|
feat/ |
New functionality | feat/seq-codec |
fix/ |
Bug fixes | fix/pic-palette-index |
docs/ |
Documentation-only changes | docs/lib-format-spec |
refactor/ |
Restructuring without behavior change | refactor/split-cmd-lib |
build/ |
Build system and tooling | build/cmake-presets |
test/ |
Test-only additions | test/fnt-fixtures |
chore/ |
Maintenance, releases, CI | chore/release-v0.5.0 |
Rules: lowercase kebab-case after the slash, keep it short, one logical change
per branch. Branches merge to main via PR.
Releasing¶
- Optionally draft changelog entries from conventional commits since the last tag:
python3 scripts/draft-changelog.py
Review and edit CHANGELOG.md (the script drafts; you refine), then commit any
changes before releasing. See
CONTRIBUTING.md
for the commit message format that drives this.
- Ensure
CHANGELOG.mdhas the desired content under## [Unreleased]. - When ready to ship, run the release script with the new version — from
main(the script creates thechore/release-vX.Y.Zbranch) or from that release branch if the changelog curation already lives there:
python3 scripts/release.py 0.5.0
This will:
- Switch to chore/release-v0.5.0 when starting from main
- Bump the version in CMakeLists.txt
- Rotate CHANGELOG.md — promotes [Unreleased] to the new version with today's date and updates the comparison links
- Commit both files as chore: release v0.5.0
The script never tags: main is protected, so the release commit lands via
PR squash-merge, and the local commit's SHA never appears on main — a tag
created now would point at an unreachable commit (the v0.4.0 misfire).
- Review the commit (
git show --stat HEAD), push the branch, and open the PR:
git push -u origin chore/release-v0.5.0
gh pr create --fill
- After CI is green, squash-merge the PR, then tag the squash commit:
git switch main && git pull
git log -1 --oneline # must show: chore: release v0.5.0 (#<PR>)
git tag v0.5.0 && git push origin v0.5.0
- After the release workflow publishes, verify all six artifacts (
fx,fxs, andfx-lib— one Windows zip and one Linux tarball each) and bump fa-bridge'sextern/fx_libsubmodule to the new tag when the release changedfx_lib.
Pushing the tag triggers the GitHub Actions release workflow: it builds and
tests on both OSes, packages the Windows zips and Linux tarballs, extracts
the release body with scripts/extract-changelog.py (run it locally with a
version argument; the changelog_extract ctest exercises it on every run),
and publishes the GitHub Release.
To validate packaging without tagging, run the workflow manually (Actions → Release → Run workflow): the dry run builds, tests, and uploads the packages as workflow artifacts but skips the publish job.
Vendored Dependencies¶
Runtime dependencies are checked in — the library, CLI, and GUI build without
a package manager. Two network fetches exist: Catch2 for the test suite (see
Testing), and SDL3 for the GUI only when no system SDL3 is
found — find_package(SDL3) first, then a pinned, SHA-256-checksummed
release tarball built statically (the decision record is
ADR-0001; the pin lives in
gui/CMakeLists.txt).
| Library | Location | License |
|---|---|---|
| Dear ImGui (+ SDL3/OpenGL3 backends) | gui/vendor/imgui/ |
MIT |
| glad (GL 3.3 core loader) | gui/vendor/glad/ |
CC0 / Apache-2.0 (generated) |
| miniaudio | gui/vendor/miniaudio/ |
MIT-0 / Public Domain |
| stb_image | lib/vendor/ |
MIT / Public Domain |
| stb_image_write | lib/vendor/ |
MIT / Public Domain |
| blast (PKWare DCL) | lib/src/blast.cpp |
zlib/libpng (Mark Adler) |