Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c3de075
Add animations.cfg parsing and new exporter for that
SomaZ Sep 4, 2024
7d44a67
Add looping and fps properties for animation exports
SomaZ Sep 7, 2024
fdc2c30
Update makefile to include JAG2AnimationCFG.py
SomaZ Sep 17, 2024
43e1526
Fix animation playing after import in Blender versions > 4.0
SomaZ Jan 4, 2026
fd017f7
Fix AnimationCGF typo
mrwonko Jan 3, 2026
b487683
Type AnimationCFG arguments
mrwonko Jan 3, 2026
689ed66
Fix differnce typo
mrwonko Jan 3, 2026
646aaa6
Add TODO about testing non-English animation.cfg export
mrwonko Jan 3, 2026
c456d44
fix seqence typo
mrwonko Jan 4, 2026
f0bba81
Fix pyright errors in ported NLA code
mrwonko Jul 26, 2026
9801d0e
Add make format/pep8 targets
mrwonko Jul 26, 2026
9e1b0e8
Add pep8 CI job, document make format/pep8, note succinctness convention
mrwonko Jul 26, 2026
44a0763
Autopep8 full repo, restore pycodestyle's default ignore list
mrwonko Jul 26, 2026
e4923c6
Add blender-run-script skill for one-off headless Blender scripts
mrwonko Jul 26, 2026
b824736
Add animation.cfg fixture for simpleskel, two sequences split at fram…
mrwonko Jul 26, 2026
2b5e132
Guard Action Slots behind hasattr for Blender 4.1 compatibility
mrwonko Jul 26, 2026
e4fad28
Add simpleskel_nla.blend fixture and its generator script
mrwonko Jul 26, 2026
d330fab
Fix corrupted first frame of every sequence after the first in CFG im…
mrwonko Jul 26, 2026
0f00cab
Add case_nla_export and case_nla_roundtrip test cases
mrwonko Jul 26, 2026
ba71449
Give the two test sequences distinct fps values
mrwonko Jul 26, 2026
6c412a4
Document animation.cfg/NLA import-export in the manual
mrwonko Jul 26, 2026
d9793eb
Address review: note deliberate fps difference, explain noqa
mrwonko Jul 26, 2026
a1e58bd
Address review: add type annotations to tests/run_tests.py
mrwonko Jul 26, 2026
ab6234e
Address review: cross-check both artifacts in NLA tests
mrwonko Jul 26, 2026
b32817a
Pin current animation.cfg parser behavior ahead of tokenizer rewrite
mrwonko Jul 26, 2026
75c2a2b
Replace naive animation.cfg parser with a proper tokenizer
mrwonko Jul 26, 2026
eb25989
Address review: use an f-string in AnimationSequence.__str__
mrwonko Jul 26, 2026
b095e1d
Address review: extend comment convention to issues, credit SomaZ
mrwonko Jul 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .claude/skills/blender-run-script/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
name: blender-run-script
description: Run an arbitrary one-off Python script headlessly inside a pinned Blender version via podman. Use for anything the fixed test suite (blender-tests) doesn't cover -- generating/regenerating a .blend fixture, inspecting .gla/.glm data that needs bpy/mathutils, or any other ad-hoc headless Blender task.
---

# Run an arbitrary script in headless Blender

A generic counterpart to `.claude/skills/blender-tests`, which only runs the fixed
`tests/run_tests.py` suite. This one takes any script path instead, for the recurring need to run
one-off headless Blender work (e.g. generating a `.blend` test fixture) without having to craft a
fresh `podman run ... -v "$(pwd)":/repo:Z ...` invocation each time -- the `$(pwd)`-based mount is
what makes the ad-hoc version unsafe to whitelist as a fixed pattern.

```
.claude/skills/blender-run-script/run_blender_script.sh <version> <script-path-relative-to-repo> [-- <args> ...]
# e.g.
.claude/skills/blender-run-script/run_blender_script.sh 4.1 tests/tools/generate_simpleskel_nla_blend.py
```

Pulls `docker.io/blenderkit/headless-blender:blender-<version>-stable` if not already present,
mounts the repo read-write at `/repo` inside the container, and runs
`blender --background --python-exit-code 1 --python /repo/<script-path> -- <args>`. Doesn't rely on
the caller's `$(pwd)` or take a repo path -- it derives the repo root from its own on-disk
location, so call it directly from any cwd.

Pin the version deliberately: a `.blend` saved by a newer Blender can't be opened by an older one
(see the add-on's minimum supported version in `bl_info["blender"]`, `__init__.py`), so fixtures
meant to stay openable on the oldest supported version must be generated with that version, not
whatever's newest.
34 changes: 34 additions & 0 deletions .claude/skills/blender-run-script/run_blender_script.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Runs an arbitrary script headlessly in one version of Blender via podman.
# Usage: run_blender_script.sh <version> <script-path-relative-to-repo> [-- <args> ...]
# e.g. run_blender_script.sh 4.1 tests/tools/generate_simpleskel_nla_blend.py
#
# Repo root is derived from this script's own location, not the caller's cwd, so the
# invocation never needs a $(pwd)-style substitution at the call site -- that's what made
# the equivalent ad-hoc commands unsafe to whitelist as a fixed pattern.
set -euo pipefail

if [ "$#" -lt 2 ]; then
echo "Usage: $(basename "$0") <version> <script-path-relative-to-repo> [-- <args> ...]" >&2
echo " e.g. $(basename "$0") 4.1 tests/tools/generate_simpleskel_nla_blend.py" >&2
exit 2
fi

VERSION="$1"
SCRIPT_PATH="$2"
shift 2

# an optional leading "--" separating our own args from the target script's is conventional
# but not required -- drop it if present so both forms work.
if [ "$#" -gt 0 ] && [ "$1" = "--" ]; then
shift
fi

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"

podman run --rm \
--entrypoint /home/headless/blender/blender \
-v "$REPO_ROOT:/repo:Z" \
"docker.io/blenderkit/headless-blender:blender-${VERSION}-stable" \
--background --python-exit-code 1 --python "/repo/$SCRIPT_PATH" -- "$@"
18 changes: 16 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,20 @@ jobs:
- run: pip install pyright fake-bpy-module-4.5
- run: pyright --pythonpath "$(command -v python3)"

pep8:
# See smoke-test above: skip on a schedule tick that doesn't need a nightly build.
if: |
github.event_name != 'schedule' || needs.check-nightly-needed.outputs.changed == 'true'
needs: [ check-nightly-needed ]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install pycodestyle
- run: make pep8

nightly:
# Only runs on a schedule or manual trigger -- not on every push, to avoid rebuilding
# and republishing a nightly prerelease for every single commit -- only once smoke-test
Expand All @@ -95,7 +109,7 @@ jobs:
if: |
(github.event_name == 'schedule' && needs.check-nightly-needed.outputs.changed == 'true') ||
github.event_name == 'workflow_dispatch'
needs: [ smoke-test, typecheck, check-nightly-needed ]
needs: [ smoke-test, typecheck, pep8, check-nightly-needed ]
runs-on: ubuntu-latest
steps:
- name: Set up Git repository
Expand Down Expand Up @@ -162,7 +176,7 @@ jobs:
release:
# Only runs for vX.Y.Z tag pushes, and only once smoke-test and typecheck have passed.
if: startsWith(github.ref, 'refs/tags/v')
needs: [ smoke-test, typecheck ]
needs: [ smoke-test, typecheck, pep8 ]
runs-on: ubuntu-latest
steps:
- name: Set up Git repository
Expand Down
7 changes: 5 additions & 2 deletions .pep8
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[pycodestyle]
# disable wrapping of long lines, our `# pyright: ignore` line comments can get long
ignore = E501
# specifying `ignore` replaces pycodestyle's own default ignore list rather than extending it, so
# restate pycodestyle's defaults (E121,E123,E126,E226,E24,E704,W503,W504) here alongside E501 --
# otherwise those checks turn on and flag pre-existing, stylistically-fine code.
# E501: our `# pyright: ignore` line comments can get long.
ignore = E121,E123,E126,E226,E24,E704,W503,W504,E501
17 changes: 11 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ files, compare). If asked to add tests, see "Testing" below for the intended dir
`build/jediacademy_plugins_doc.pdf` (compiled from `jediacademy_plugins_doc.tex` via `pdflatex`).
- `make build/jediacademy.zip` — package just the add-on `.py` files (see `PY_FILES` in `Makefile`) plus
the readme into a zip installable via Blender's add-on preferences.
- CI (`.github/workflows/ci.yml`) runs `smoke-test` and `typecheck` on every PR, `vX.Y.Z` tag push, and
on its daily `schedule`/manual `workflow_dispatch` — deliberately *not* on every push to `master`,
- CI (`.github/workflows/ci.yml`) runs `smoke-test`, `typecheck`, and `pep8` on every PR, `vX.Y.Z` tag push,
and on its daily `schedule`/manual `workflow_dispatch` — deliberately *not* on every push to `master`,
since a PR already ran them before merge. On a `schedule` tick specifically, they're also skipped if
there's nothing to do (see `check-nightly-needed` below) — a no-op nightly tick doesn't spin up the
Blender matrix or pyright for nothing. Two more jobs depend on `smoke-test`/`typecheck`
(`needs: [smoke-test, typecheck]`) and only run/publish if both passed (or were skipped as a no-op):
Blender matrix or pyright for nothing. Two more jobs depend on `smoke-test`/`typecheck`/`pep8`
(`needs: [smoke-test, typecheck, pep8]`) and only run/publish if all three passed (or were skipped as a no-op):
`nightly` (force-updates the `nightly` prerelease tag/release with a freshly built manual and zip;
only on the `schedule`/`workflow_dispatch` triggers, and on `schedule` only if `master` has new commits
since the last nightly *attempt* — checked via the `check-nightly-needed` job, which always runs
Expand All @@ -40,8 +40,13 @@ files, compare). If asked to add tests, see "Testing" below for the intended dir
"Releases" below).
- Formatting/linting: pycodestyle via `.pep8` (only rule disabled: E501 line length, to allow long
`# pyright: ignore` comments). VS Code is configured (`.vscode/settings.json`) to use `autopep8` as the
Python formatter and pyright type checking at `standard` mode. There's no separate CLI lint command
currently wired up — run `pycodestyle` / `pyright` directly if checking manually.
Python formatter and pyright type checking at `standard` mode. `make format` runs `autopep8 --in-place`
over the same file set; `make pep8` runs `pycodestyle` in check-only mode (what CI's `pep8` job runs).

## Writing comments, commit messages, and PR descriptions

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this also extends to issues


Keep all three succinct: state the fact/change, skip restating what the diff already shows. A comment
should carry the one thing the code alone doesn't (a non-obvious *why*), not a narration of the *what*.

### Known Blender version support

Expand Down
12 changes: 10 additions & 2 deletions JAFilesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ def SplitPrefix(fullPath):
# find /DIRNAME/
if pos == -1:
return "", normFullPath
pos = pos+len(DIRNAME)+2*len(os.path.sep)
pos = pos + len(DIRNAME) + 2 * len(os.path.sep)
# find first / after that
pos = searchme.find(os.path.sep, pos)
if pos == -1:
return "", normFullPath
return [normFullPath[:pos+len(os.path.sep)], normFullPath[pos+len(os.path.sep):]]
return [normFullPath[:pos + len(os.path.sep)], normFullPath[pos + len(os.path.sep):]]

# removes a file extension, i.e. /foo/bar.baz -> /foo/bar

Expand Down Expand Up @@ -94,3 +94,11 @@ def FindFile(relpath, prefix, extensions):

def FileExists(path: str) -> bool:
return os.path.isfile(path)

# returns the absolute directory (with trailing separator) containing a game-relative file, given
# its prefix - e.g. for locating animation.cfg next to a .gla file


def PathToFile(relpath, base_path):
absPath = AbsPath(relpath, base_path)
return os.path.dirname(absPath) + os.path.sep
181 changes: 181 additions & 0 deletions JAG2AnimationCFG.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####

from .mod_reload import reload_modules
reload_modules(locals(), __package__, ["JAFilesystem"], [".casts", ".error_types"]) # nopep8

import bpy
from . import JAFilesystem
from .error_types import ErrorMessage
from typing import List, Tuple


class AnimationSequence():
def __init__(self):
self.name = ""
self.start_frame = -1
self.num_frames = -1
self.loop = False
self.fps = -1

def __str__(self):
return "{name}\t\t{start}\t{frames}\t{loop}\t{fps}".format(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's use an f-string

name=self.name,
start=self.start_frame,
frames=self.num_frames,
loop=0 if self.loop else -1,
fps=self.fps
)

@classmethod
def from_cfg_line(cls, txt_line):
try:
# remove comments inline first, someone might have annotated these
line = txt_line.split("//")[0]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this parser is over simplified, a mid-token // does not initiate a comment, and there are also multi-line comments. Tokens may also be quoted. Numbers can have trailing garbage. I'm not sure if an entry must even stay on the same line?

Build a generator-based token parser and use that instead. Use https://github.com/mrwonko/ghoul2-browser-tools/blob/main/src/commonTokenizer.ts for reference, but note that we don't need to retain whitespace and comments here, we can just yield a string for each token. Unit-test the generic token parser using our fixture.

You can also look at mrwonko/ghoul2-browser-tools#28 for animation.cfg parsing reference, but it has not been reviewed yet, so don't trust it completely. The canonical source is BG_ParseAnimationFile in https://github.com/jedis/jediacademy/blob/master/codemp/game/bg_panimate.c, you already have a local copy of that repo at ~/jediacademy.

Do test-driven development: first add a unit test to persist the current parse result for our fixture, plus maybe an additional case for partial final line. Make sure it passes. Then adjust the parser in a separate commit and verify it does not regress.

name, sf, nf, l, fps = line.split()
new_frame = cls()
new_frame.name = name
new_frame.start_frame = int(sf)
new_frame.num_frames = int(nf)
new_frame.loop = int(l) != -1
new_frame.fps = int(fps)
return new_frame
except Exception:
return None

@classmethod
def from_blender_markers(cls, marker1: bpy.types.TimelineMarker, marker2: bpy.types.TimelineMarker, fps: int, offset: int = 0):
new_frame = cls()
new_frame.name = marker1.name
new_frame.start_frame = int(marker1.frame + offset)
new_frame.num_frames = int(marker2.frame - marker1.frame)
new_frame.loop = False
new_frame.fps = int(fps)
return new_frame

@classmethod
def from_blender_strip(cls, nla_strip: bpy.types.NlaStrip, length_difference: int, fps: int, offset: int = 0):
assert nla_strip.action is not None
new_frame = cls()
new_frame.name = nla_strip.action.name
new_frame.start_frame = int(nla_strip.frame_start + offset)
new_frame.num_frames = int(nla_strip.frame_end - nla_strip.frame_start + length_difference)
new_frame.loop = bool(nla_strip.action.g2_sequence_prop.loop_frame) # pyright: ignore[reportAttributeAccessIssue]
new_frame.fps = int(nla_strip.action.g2_sequence_prop.fps) # pyright: ignore[reportAttributeAccessIssue]
return new_frame


class AnimationCFG():

def __init__(self):
self.sequences: List[AnimationSequence] = []

def __str__(self):
lines = [str(seq) for seq in self.sequences]
return "\n".join(lines)

def load_from_cfg(self, cfg_file_path: str) -> Tuple[bool, ErrorMessage]:
success, cfg_abs = JAFilesystem.FindFile(cfg_file_path + "/animation", "", ["cfg"])
if not success:
print("Could not find file: ", cfg_abs, sep="")
return False, ErrorMessage("Could not find the animation.cfg next to the .gla file")

try:
file = open(cfg_abs, mode="r")
except IOError:
print("Could not open file: ", cfg_abs, sep="")
return False, ErrorMessage("Could not open skin!")
for line in file:
if line.startswith("//") or line.strip() == "":
continue
sequence = AnimationSequence().from_cfg_line(line)
if sequence:
self.sequences.append(sequence)
else:
print("Could not parse following line in animations.cfg", line)
self.sequences.sort(key=lambda sequence: sequence.start_frame)
return True, ErrorMessage("Nothing")

def from_blender_markers(self, scene: bpy.types.Scene, offset: int):
start_frame = scene.frame_start
offset -= start_frame
end_frame = scene.frame_end
base_fps = scene.render.fps

blender_markers = [
marker for marker in scene.timeline_markers if (
marker.frame >= start_frame and marker.frame <= end_frame + 1)
]
blender_markers.sort(key=lambda marker: marker.frame)

if (len(blender_markers) == 0 or
(len(blender_markers) == 1 and blender_markers[0].frame == end_frame + 1)):
return False, ErrorMessage("No timeline markers found! Add Markers to label animations.")

if blender_markers[len(blender_markers) - 1].frame != end_frame + 1:
blender_markers.append(
scene.timeline_markers.new("LAST_EXPORT_FRAME", frame=end_frame + 1))

for marker1, marker2 in zip(blender_markers[:-1], blender_markers[1:]):
self.sequences.append(AnimationSequence().from_blender_markers(
marker1,
marker2,
base_fps,
offset
))

last_frame = scene.timeline_markers.get("LAST_EXPORT_FRAME")
if last_frame:
scene.timeline_markers.remove(last_frame)

return True, ErrorMessage("Nothing")

def from_blender_nla_tracks(self, scene: bpy.types.Scene, offset: int):
start_frame = scene.frame_start
offset -= start_frame
end_frame = scene.frame_end
base_fps = scene.render.fps

skeleton_object = bpy.data.objects.get("skeleton_root")
if skeleton_object is None:
return False, ErrorMessage("Could not find skeleton object: skeleton_root")
if skeleton_object.animation_data is None:
return False, ErrorMessage('Skeleton object (skeleton_root) does not have animation data')
if len(skeleton_object.animation_data.nla_tracks) == 0:
return False, ErrorMessage("Couldn't find NLA tracks for the Skeleton object: skeleton_root")

blender_strips: List[Tuple[bpy.types.NlaStrip, int]] = []
for nla_track in [track for track in skeleton_object.animation_data.nla_tracks]:
# TODO test if this works when not using English localisation
length_difference = 0 if nla_track.name.startswith("Stills Layer") else 1
for nla_strip in [strip for strip in nla_track.strips if strip.frame_start >= start_frame and strip.frame_start <= end_frame]:
blender_strips.append((nla_strip, length_difference))
blender_strips.sort(key=lambda strip: strip[0].frame_start)

if len(blender_strips) == 0:
return False, ErrorMessage("No NLA strips found! Add animation strips to label animations.")

for strip, length_difference in blender_strips:
self.sequences.append(AnimationSequence().from_blender_strip(
strip,
length_difference,
base_fps,
offset
))

return True, ErrorMessage("Nothing")
Loading
Loading