From a63c9bc5eec32089426c5504920c87b2c652f0f5 Mon Sep 17 00:00:00 2001 From: Naruto TAKAHASHI Date: Fri, 28 Aug 2026 15:08:09 +0900 Subject: [PATCH 1/3] build: provision test_gdextension alongside the sample projects The headless suite needs the same two things a sample project does -- the built addon and the converted .ssab -- and neither is committable: the addon is a build output, and MAINTAINING_PLAYERS.md is why a .ssab is not tracked either (it goes stale the moment the SDK's tests/ change). So the two scripts that already provision the samples provision this one too. `build-extension` syncs the descriptor, bin/ and icons/ from dev_gdextension the way it does for overall_gdextension; `deploy-examples` converts overall and Ringo into its ssab_generated/. The project is not under examples/ because it is not a sample -- the samples are what a reader is shown, and one project cannot also be the scratch pad. It still wears the `_gdextension` suffix, because that is what tells you which build a project targets: a custom module build has the classes compiled in already and aborts on double registration. --- scripts/build-extension.ps1 | 10 ++++++++++ scripts/build-extension.sh | 12 ++++++++++++ scripts/deploy-examples.ps1 | 25 +++++++++++++++++++++++++ scripts/deploy-examples.sh | 23 +++++++++++++++++++++++ 4 files changed, 70 insertions(+) diff --git a/scripts/build-extension.ps1 b/scripts/build-extension.ps1 index 5b55581..8651c77 100755 --- a/scripts/build-extension.ps1 +++ b/scripts/build-extension.ps1 @@ -101,4 +101,14 @@ foreach ($project in $OTHER_PROJECTS) { Copy-Item "examples\$MAIN_PROJECT\addons\spritestudio\icons" "$dest_dir\" -Recurse -Force } +# The headless test project is not a sample, so it is not under examples\ -- +# but it loads the extension exactly as one does, hence the _gdextension suffix +# every project carrying the addon wears. See test_gdextension\project.godot. +$test_dest = "test_gdextension\addons\spritestudio" +mkdir $test_dest -Force | Out-Null +Copy-Item "misc\spritestudio.gdextension" "$test_dest\spritestudio.gdextension" -Force +Write-Host "Syncing binaries and icons to test_gdextension..." +Copy-Item "examples\$MAIN_PROJECT\addons\spritestudio\bin" "$test_dest\" -Recurse -Force +Copy-Item "examples\$MAIN_PROJECT\addons\spritestudio\icons" "$test_dest\" -Recurse -Force + popd diff --git a/scripts/build-extension.sh b/scripts/build-extension.sh index 73b50b1..0ce719d 100755 --- a/scripts/build-extension.sh +++ b/scripts/build-extension.sh @@ -128,6 +128,11 @@ OTHER_PROJECTS=("overall_gdextension") /bin/mkdir -p "./examples/${MAIN_PROJECT}/addons/spritestudio/icons" /bin/cp ss_player/icons/icon_*.svg "./examples/${MAIN_PROJECT}/addons/spritestudio/icons/" +# The headless test project is not a sample, so it is not under examples/ -- +# but it loads the extension exactly as one does -- hence the _gdextension +# suffix every project carrying the addon wears. See test_gdextension/project.godot. +TEST_PROJECT="./test_gdextension" + # Copy from MAIN_PROJECT to OTHER_PROJECTS for project in "${OTHER_PROJECTS[@]}"; do DEST_DIR="./examples/${project}/addons/spritestudio" @@ -138,4 +143,11 @@ for project in "${OTHER_PROJECTS[@]}"; do /bin/cp -R "./examples/${MAIN_PROJECT}/addons/spritestudio/icons" "${DEST_DIR}/" done +TEST_DEST="${TEST_PROJECT}/addons/spritestudio" +/bin/mkdir -p "${TEST_DEST}" +/bin/cp misc/spritestudio.gdextension "${TEST_DEST}/" +echo "Syncing binaries and icons to test_gdextension..." +/bin/cp -R "./examples/${MAIN_PROJECT}/addons/spritestudio/bin" "${TEST_DEST}/" +/bin/cp -R "./examples/${MAIN_PROJECT}/addons/spritestudio/icons" "${TEST_DEST}/" + popd > /dev/null # ${ROOTDIR} diff --git a/scripts/deploy-examples.ps1 b/scripts/deploy-examples.ps1 index 0c84a2f..854a5d0 100644 --- a/scripts/deploy-examples.ps1 +++ b/scripts/deploy-examples.ps1 @@ -71,4 +71,29 @@ foreach ($ENTRY in $DEPLOYMENTS) { } } +# The headless test project is not a sample and so is not under examples\, but +# it reads the same fixtures -- one entry per pack run_tests.gd's preflight +# names. Keeping the .ssab out of git and regenerating it here is why the suite +# cannot quietly test a stale conversion. +$TEST_PACKS = @( + "overall" + "Ringo" +) + +foreach ($TEST in $TEST_PACKS) { + $SSPJ_PATH = Join-Path $SDK_TESTS_DIR "$TEST/$TEST.sspj" + $OUTPUT_DIR = Join-Path $rootDirectory "test_gdextension/ssab_generated/$TEST" + + if (!(Test-Path $SSPJ_PATH)) { + Write-Error "${APP}: $SSPJ_PATH not found" + } + + Write-Host "Updating SSAB for $TEST in $OUTPUT_DIR..." + mkdir -Force $OUTPUT_DIR > $null + & $CONVERTER "$SSPJ_PATH" -o "$OUTPUT_DIR" + if ($LASTEXITCODE -ne 0) { + Write-Error "${APP}: ssconverter-cli failed for $TEST ($LASTEXITCODE)" + } +} + Write-Host "Done!" diff --git a/scripts/deploy-examples.sh b/scripts/deploy-examples.sh index adb861a..49141f4 100755 --- a/scripts/deploy-examples.sh +++ b/scripts/deploy-examples.sh @@ -71,4 +71,27 @@ for ENTRY in "${DEPLOYMENTS[@]}"; do "${CONVERTER}" "${SSPJ_PATH}" -o "${OUTPUT_DIR}" done +# The headless test project is not a sample and so is not under examples/, but +# it reads the same fixtures -- one entry per pack run_tests.gd's preflight +# names. Keeping the .ssab out of git and regenerating it here is why the suite +# cannot quietly test a stale conversion. +TEST_PACKS=( + "overall" + "Ringo" +) + +for TEST in "${TEST_PACKS[@]}"; do + SSPJ_PATH="${SDK_TESTS_DIR}/${TEST}/${TEST}.sspj" + OUTPUT_DIR="${ROOTDIR}/test_gdextension/ssab_generated/${TEST}" + + if [ ! -f "${SSPJ_PATH}" ]; then + echo "${APP}: ${SSPJ_PATH} not found" >&2 + exit 1 + fi + + echo "Updating SSAB for ${TEST} in ${OUTPUT_DIR}..." + mkdir -p "${OUTPUT_DIR}" + "${CONVERTER}" "${SSPJ_PATH}" -o "${OUTPUT_DIR}" +done + echo "Done!" From 6c3f11fcbfbf35872cdceae4b0571334cc676f60 Mon Sep 17 00:00:00 2001 From: Naruto TAKAHASHI Date: Fri, 28 Aug 2026 15:08:38 +0900 Subject: [PATCH 2/3] test: a headless suite for the GDExtension build This repository had no automated tests. `scripts/run-tests.sh` (+ the .ps1 twin) now runs 35 cases over the bound API, the part override layer and the five signals -- against the GDExtension, through GDScript, which is the door a user's game goes through. The shape is the one godot-cpp uses for its own extension: a small project, assertions in GDScript, a shell wrapper that takes the binary from an environment variable. Not gdUnit4 or GUT -- this repository ships an addons/ folder, and vendoring a second one would mean guarding it out of build-release.sh forever. Binaries are never downloaded on your behalf. run-tests looks for one you already have -- godot=, $GODOT, godot-bin/, PATH -- and prints `scripts/fetch-godot.sh` rather than pulling tens of MB unasked; that script installs the editor build pinned in scripts/GODOT_VERSION.txt, and nothing else needs the 1.2 GB of export templates. godot/bin/* is deliberately not in the search: a custom module build has SpriteStudio compiled in and aborts on double registration, and run-tests recognises that message and says so. Three things the harness does that a bare loop would not: * Preflight. The addon and the .ssab are both build outputs, so a fresh clone has neither; run_tests.gd refuses to start rather than letting every case skip its way to a green run. * A skip is not a pass. A case that declares itself unrunnable here is reported apart from the passes and never counted as one. * The completion marker. A script error inside a case aborts _init and leaves the tree idling, so the wrapper passes --quit-after -- which costs the exit code its meaning, since Godot then leaves 0 on the way out. `==== SUITE FINISHED ====` is what says a run completed. Cases step with advance() under ANIMATION_PROCESS_MANUAL, never the frame clock, so a result does not depend on how long a frame took -- which is also what lets the same assertions mean the same thing on three platforms. Drawing is out of scope and cannot be otherwise: --headless installs a dummy rasteriser, and NOTIFICATION_DRAW is a no-op in the node anyway. The custom module build is not tested here. A module is compiled into the engine, so testing it would mean building Godot rather than downloading it -- 14 GB of tree against a 74-162 MB editor. The two builds share one copy of the playback logic; what they do not share is a layer of `#ifdef SPRITESTUDIO_GODOT_EXTENSION` adapters, which are includes and type conversions, so the module build's guard is that it still builds. The suite was checked by mutation, not by being green: dropping the cascade flag in SsInternalPlayer::set_part_visibility_override fails test_cascade_reaches_the_children and nothing else. One wart, and it is not ours. The first headless scan of a project loading any godot-cpp GDExtension aborts on the way out -- godot-cpp's own test/ extension reproduces it exactly, and a project with no extension does not. godot-cpp has no 4.6/4.7 release branch (godot-4.5-stable, then the 10.0 line), so an extension for Godot 4.7 is built from master against api_version=4.7, and that pairing is what does it. The scan's work completes, so run-tests retries once and requires the second run to pass. --- .gitignore | 10 + AGENTS.md | 46 +++++ scripts/GODOT_VERSION.txt | 1 + scripts/fetch-godot.ps1 | 81 ++++++++ scripts/fetch-godot.sh | 97 ++++++++++ scripts/run-tests.ps1 | 157 +++++++++++++++ scripts/run-tests.sh | 158 ++++++++++++++++ test_gdextension/project.godot | 27 +++ test_gdextension/run_tests.gd | 178 ++++++++++++++++++ test_gdextension/run_tests.gd.uid | 1 + test_gdextension/suites/test_overrides.gd | 109 +++++++++++ test_gdextension/suites/test_overrides.gd.uid | 1 + test_gdextension/suites/test_resource.gd | 76 ++++++++ test_gdextension/suites/test_resource.gd.uid | 1 + test_gdextension/suites/test_signals.gd | 112 +++++++++++ test_gdextension/suites/test_signals.gd.uid | 1 + test_gdextension/suites/test_transport.gd | 131 +++++++++++++ test_gdextension/suites/test_transport.gd.uid | 1 + test_gdextension/test_base.gd | 136 +++++++++++++ test_gdextension/test_base.gd.uid | 1 + 20 files changed, 1325 insertions(+) create mode 100644 scripts/GODOT_VERSION.txt create mode 100644 scripts/fetch-godot.ps1 create mode 100755 scripts/fetch-godot.sh create mode 100644 scripts/run-tests.ps1 create mode 100755 scripts/run-tests.sh create mode 100644 test_gdextension/project.godot create mode 100644 test_gdextension/run_tests.gd create mode 100644 test_gdextension/run_tests.gd.uid create mode 100644 test_gdextension/suites/test_overrides.gd create mode 100644 test_gdextension/suites/test_overrides.gd.uid create mode 100644 test_gdextension/suites/test_resource.gd create mode 100644 test_gdextension/suites/test_resource.gd.uid create mode 100644 test_gdextension/suites/test_signals.gd create mode 100644 test_gdextension/suites/test_signals.gd.uid create mode 100644 test_gdextension/suites/test_transport.gd create mode 100644 test_gdextension/suites/test_transport.gd.uid create mode 100644 test_gdextension/test_base.gd create mode 100644 test_gdextension/test_base.gd.uid diff --git a/.gitignore b/.gitignore index cd9a45a..71657d2 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,16 @@ compile_commands.json /examples/dev_*/ssab_generated /examples/dev_*/.ssplayer_sources.cfg /examples/*/addons/spritestudio +# The headless test project's build inputs: the addon comes from +# build-extension.*, the .ssab from deploy-examples.*, and the Godot +# binary run-tests.* runs from fetch-godot.*. None of the three is a +# source file, and a committed .ssab goes stale the moment the SDK's +# tests/ change. +/test_gdextension/addons/spritestudio +/test_gdextension/ssab_generated +/test_gdextension/.ssplayer_sources.cfg +/godot-bin +/scripts/.godot-cache/ ### Generated by gibo (https://github.com/simonwhitaker/gibo) ### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/C++.gitignore diff --git a/AGENTS.md b/AGENTS.md index bd6d242..4f6e2c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,10 +75,56 @@ Callouts (`> [!NOTE]`) are parsed natively — `mkdocs-callouts` is gone. Two co | Setup (Prebuilt SDK) | `./scripts/download-sdk.sh` (POSIX) / `.\scripts\download-sdk.ps1` (Win) | | Deploy Example Assets | `./scripts/deploy-examples.sh` (POSIX) / `.\scripts\deploy-examples.ps1` (Win) | | Build the release | `./scripts/build-release.sh` (POSIX) / `.\scripts\build-release.ps1` (Win) — the addon zip, from a downloaded matrix build | +| Run the headless tests | `./scripts/run-tests.sh` (POSIX) / `.\scripts\run-tests.ps1` (Win) — the GDExtension build, through GDScript. Needs a Godot binary: `godot=`, else `$GODOT`, `godot-bin/`, then PATH | +| Install the pinned Godot | `./scripts/fetch-godot.sh` (POSIX) / `.\scripts\fetch-godot.ps1` (Win) — the editor build named in `scripts/GODOT_VERSION.txt`, into `godot-bin/`. Nothing runs it for you | | Format C++ Code | `clang-format -i ss_player/*.{cpp,h}` (if available) | *Note: Setup (Source SDK) is recommended for developers using the submodule. Setup (Prebuilt SDK) is intended for CI or release-only environments.* +### The headless suite + +`test_gdextension/` is a Godot project that loads the built addon and drives +`SpriteStudioPlayer2D` from GDScript — 35 cases over the bound API, the part +override layer and the five signals. It is not a sample and does not live under +`examples/`: the samples are what a reader is shown, and one project cannot be +both that and a scratch pad (MAINTAINING_PLAYERS.md). It wears the +`_gdextension` suffix for the same reason every other project carrying the addon +does: a custom-module build has the classes compiled in already, so which build a +project targets has to be readable from its name. Its two inputs are build +outputs and gitignored — `build-extension.*` installs the addon into it, +`deploy-examples.*` writes its `.ssab`, and `run_tests.gd` refuses to start +without either rather than skipping its way to a green run. + +**Two things it deliberately does not cover.** Drawing, because `--headless` +installs a dummy rasteriser and there are no pixels to compare — and +`NOTIFICATION_DRAW` is a no-op in the node anyway, the InternalPlayer issuing +its own RenderingServer calls. And the **custom-module build**, because a module +is compiled into the engine: testing it would mean building Godot rather than +downloading it, and a module binary cannot even open `test_gdextension/` — it registers the +classes a second time and aborts (`run-tests.*` recognises that message and says +so). What the two builds share is one copy of the playback logic; what they do +not share is a layer of `#ifdef SPRITESTUDIO_GODOT_EXTENSION` adapters, which +are includes and type conversions — so the module build's guard is that it still +builds. + +Cases step with `advance()` under `ANIMATION_PROCESS_MANUAL`, never the frame +clock, so a result does not depend on how long a frame took. A case that cannot +run on this host declares a **skip**, which is reported apart from the passes +and never counted as one. + +**The first headless import crashes, and `run-tests.*` retries it once. It is a +godot-cpp problem, not ours.** A fresh scan of a project loading *any* godot-cpp +GDExtension aborts on the way out (null dereference, caught by Godot's own crash +handler) — **godot-cpp's own `test/` extension reproduces it exactly**, and a +project with no extension does not. godot-cpp has no 4.6/4.7 release branch: it +went from `godot-4.5-stable` straight to the 10.0 line, so an extension for +Godot 4.7 is built from master against `api_version=4.7`, and that pairing is +what does this. The scan's work completes, so the second run is clean and every +later one has nothing to do; the retry requires that second run to pass, because +a crash that repeats is still a failure. Not test-only — anything running +`godot --headless --import` on a fresh checkout meets it. + + ## Releases A release is a tag, pushed first and built second: push `v`, then dispatch **release gdextension** from that tag with `upload_release=true`. The tag names the Release; `upload_release=true` from anything else fails the run rather than silently producing none. The default dispatch (`upload_release=false`, off a `release/X.Y` branch) is a build for QA and creates no Release. Drafts are always created as drafts — a human reviews the assets and the generated notes, then publishes from the UI, choosing pre-release / latest there. diff --git a/scripts/GODOT_VERSION.txt b/scripts/GODOT_VERSION.txt new file mode 100644 index 0000000..32a9ad8 --- /dev/null +++ b/scripts/GODOT_VERSION.txt @@ -0,0 +1 @@ +4.7.2-stable diff --git a/scripts/fetch-godot.ps1 b/scripts/fetch-godot.ps1 new file mode 100644 index 0000000..9ff5264 --- /dev/null +++ b/scripts/fetch-godot.ps1 @@ -0,0 +1,81 @@ +#!/usr/bin/env pwsh +# +# Download the stock Godot editor pinned in scripts\GODOT_VERSION.txt into +# godot-bin\ (gitignored), for scripts\run-tests.ps1 to run the headless suite +# against. Windows counterpart of scripts/fetch-godot.sh. +# +# Nothing calls this on your behalf. run-tests.ps1 looks for a binary you +# already have -- godot=, $env:GODOT, godot-bin\, PATH -- and prints this +# command rather than downloading tens of MB without being asked. +# +# The editor build is the whole download, and it is all that is needed: the +# export templates are another ~1.2 GB and the suite exports nothing. +# +# This is deliberately NOT the same binary as godot\bin\*. That one is a custom +# module build of the engine, which has SpriteStudio compiled in -- it would +# register the classes a second time and abort on the extension's own project. +# +# Usage: scripts\fetch-godot.ps1 [force=yes] [out=] +# force : yes to re-download even when the pinned version is installed +# out : install directory (default: godot-bin\) +# +# Requires PowerShell 5+ (Invoke-WebRequest, Expand-Archive). + +$ErrorActionPreference = "Stop" +$RootDir = Split-Path -Parent (Split-Path -Parent $PSCommandPath) +$ScriptDir = Join-Path $RootDir "scripts" + +$Force = "no" +$OutDir = Join-Path $RootDir "godot-bin" +foreach ($item in $Args) { + if ($item -match "^-?-?help$" -or $item -eq "-h") { + $emit = $false + foreach ($line in (Get-Content $PSCommandPath)) { + if ($line -match '^# Usage:') { $emit = $true } + if ($emit) { $line -replace '^# ?', '' } + if ($emit -and $line -match '^# Requires') { break } + } + exit 0 + } + $kv = $item -split "=", 2 + switch ($kv[0]) { + "force" { $Force = $kv[1] } + "out" { $OutDir = $kv[1] } + default { Write-Error "unknown arg '$item'" } + } +} + +$Version = (Get-Content (Join-Path $ScriptDir "GODOT_VERSION.txt")).Trim() +$VersionFile = Join-Path $OutDir "VERSION" +$CacheDir = Join-Path $ScriptDir ".godot-cache" + +if ($Force -ne "yes" -and (Test-Path $VersionFile) -and + ((Get-Content $VersionFile).Trim() -eq $Version)) { + Write-Host "fetch-godot.ps1: Godot $Version is already installed in godot-bin\. Nothing to do." + exit 0 +} + +$Asset = "Godot_v${Version}_win64.exe.zip" +$Url = "https://github.com/godotengine/godot-builds/releases/download/$Version/$Asset" +$ZipFile = Join-Path $CacheDir $Asset + +mkdir $CacheDir -Force | Out-Null +if ((-not (Test-Path $ZipFile)) -or $Force -eq "yes") { + Write-Host "fetch-godot.ps1: downloading $Asset" + Invoke-WebRequest -Uri $Url -OutFile "$ZipFile.part" + Move-Item "$ZipFile.part" $ZipFile -Force +} else { + Write-Host "fetch-godot.ps1: reusing cached $Asset" +} + +if (Test-Path $OutDir) { Remove-Item -Recurse -Force $OutDir } +mkdir $OutDir -Force | Out-Null +Expand-Archive -Path $ZipFile -DestinationPath $OutDir -Force + +# Normalise what the archive unpacks to, so run-tests.ps1 has one path rather +# than a version-stamped name that changes with every bump. +$bin = Get-ChildItem -Path $OutDir -Filter "Godot_v*.exe" -File | Select-Object -First 1 +if ($bin) { Move-Item $bin.FullName (Join-Path $OutDir "godot.exe") -Force } + +Set-Content -Path $VersionFile -Value $Version +Write-Host "fetch-godot.ps1: Godot $Version installed in godot-bin\" diff --git a/scripts/fetch-godot.sh b/scripts/fetch-godot.sh new file mode 100755 index 0000000..7eadcbf --- /dev/null +++ b/scripts/fetch-godot.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# +# Download the stock Godot editor pinned in scripts/GODOT_VERSION.txt into +# godot-bin/ (gitignored), for scripts/run-tests.sh to run the headless suite +# against. +# +# Nothing calls this on your behalf. run-tests.sh looks for a binary you +# already have -- godot=, $GODOT, godot-bin/, godot/bin/, PATH -- and +# prints this command rather than downloading tens of MB without being asked. +# +# The editor build is the whole download, and it is all that is needed: the +# export templates are another ~1.2 GB and the suite exports nothing. +# +# This is deliberately NOT the same binary as godot/bin/*. That one is a custom +# module build of the engine, which has SpriteStudio compiled in -- it would +# register the classes a second time and abort on the extension's own project. +# Reach for it only with a project that carries no addons/spritestudio. +# +# Usage: scripts/fetch-godot.sh [force=yes] [out=] +# force : yes to re-download even when the pinned version is installed +# out : install directory (default: godot-bin/) +# +# Requires curl and unzip. +set -euo pipefail + +APP=$(basename "$0") +SCRIPTDIR=$(cd "$(dirname "$0")" && pwd -P) +ROOTDIR=$(cd "$SCRIPTDIR/.." && pwd -P) + +usage() { sed -n '/^# Usage:/,/^# Requires/p' "$0" | sed 's/^# \{0,1\}//'; } + +FORCE="no" +OUT_DIR="${ROOTDIR}/godot-bin" +for item in "$@"; do + case "$item" in + force=*) FORCE="${item#*=}" ;; + out=*) OUT_DIR="${item#*=}" ;; + -h|--help|help) usage; exit 0 ;; + *) echo "$APP: unknown arg '$item'" >&2; usage; exit 2 ;; + esac +done + +VERSION=$(tr -d ' \r\n' < "${SCRIPTDIR}/GODOT_VERSION.txt") +VERSION_FILE="${OUT_DIR}/VERSION" +CACHE_DIR="${SCRIPTDIR}/.godot-cache" + +if [ "$FORCE" != "yes" ] && [ -f "$VERSION_FILE" ] \ + && [ "$(tr -d ' \r\n' < "$VERSION_FILE")" = "$VERSION" ]; then + echo "$APP: Godot $VERSION is already installed in $(basename "$OUT_DIR")/. Nothing to do." + exit 0 +fi + +# The asset name encodes the platform; ARM Linux and 32-bit are not published as +# editor builds, so those hosts have to bring their own binary. +case "$(uname -s)" in + Darwin) ASSET="Godot_v${VERSION}_macos.universal.zip" ;; + Linux) + case "$(uname -m)" in + x86_64) ASSET="Godot_v${VERSION}_linux.x86_64.zip" ;; + *) echo "$APP: no published editor build for linux $(uname -m) — install Godot $VERSION yourself and pass godot= to run-tests.sh" >&2; exit 1 ;; + esac ;; + *) ASSET="Godot_v${VERSION}_win64.exe.zip" ;; +esac + +URL="https://github.com/godotengine/godot-builds/releases/download/${VERSION}/${ASSET}" +ZIP_FILE="${CACHE_DIR}/${ASSET}" + +command -v curl >/dev/null || { echo "$APP: curl not on PATH" >&2; exit 1; } +command -v unzip >/dev/null || { echo "$APP: unzip not on PATH" >&2; exit 1; } + +mkdir -p "$CACHE_DIR" +if [ ! -f "$ZIP_FILE" ] || [ "$FORCE" = "yes" ]; then + echo "$APP: downloading $ASSET" + curl -fL --progress-bar "$URL" -o "${ZIP_FILE}.part" + mv "${ZIP_FILE}.part" "$ZIP_FILE" +else + echo "$APP: reusing cached $(basename "$ZIP_FILE")" +fi + +rm -rf "$OUT_DIR" +mkdir -p "$OUT_DIR" +unzip -q "$ZIP_FILE" -d "$OUT_DIR" + +# Normalise what the archives unpack to, so run-tests.sh has one path per host +# rather than a version-stamped name that changes with every bump. +case "$(uname -s)" in + Darwin) : ;; # Godot.app, already a stable name + Linux) + BIN=$(find "$OUT_DIR" -maxdepth 1 -type f -name 'Godot_v*' | head -n 1) + [ -n "$BIN" ] && mv "$BIN" "${OUT_DIR}/godot" && chmod +x "${OUT_DIR}/godot" ;; + *) + BIN=$(find "$OUT_DIR" -maxdepth 1 -type f -name 'Godot_v*.exe' | head -n 1) + [ -n "$BIN" ] && mv "$BIN" "${OUT_DIR}/godot.exe" ;; +esac + +printf '%s\n' "$VERSION" > "$VERSION_FILE" +echo "$APP: Godot $VERSION installed in $(basename "$OUT_DIR")/" diff --git a/scripts/run-tests.ps1 b/scripts/run-tests.ps1 new file mode 100644 index 0000000..36c0e27 --- /dev/null +++ b/scripts/run-tests.ps1 @@ -0,0 +1,157 @@ +#!/usr/bin/env pwsh +# +# Run the headless test suite in test_gdextension\ against the GDExtension build. Windows +# counterpart of scripts/run-tests.sh (same key=value interface). +# +# The work is test_gdextension\run_tests.gd; this finds a Godot binary, runs the import +# pass the project needs before its first run, and forwards the options. +# +# Two prerequisites, both gitignored and so both missing from a fresh clone. +# run_tests.gd checks them before it starts anything and names the script that +# produces each: +# test_gdextension\addons\spritestudio\ <- build-extension.ps1 +# test_gdextension\ssab_generated\ <- deploy-examples.ps1 +# +# It never downloads anything on your behalf. When no binary is found it prints +# scripts\fetch-godot.ps1 and stops; a download of tens of MB is your decision. +# +# What this does NOT cover: drawing, and the custom-module build. --headless +# installs a dummy rasteriser, so there are no pixels to compare; and a module +# is compiled into the engine, so testing that build would mean building Godot +# rather than downloading it. The two builds share one copy of the playback +# logic and differ only in a layer of #ifdef SPRITESTUDIO_GODOT_EXTENSION +# adapters -- includes and type conversions, which is what a compiler checks. +# +# Usage: scripts\run-tests.ps1 [godot=] [only=] [import=no] +# godot : the Godot binary to use (else $env:GODOT, godot-bin\, then PATH) +# only : comma-separated substrings; run only the suites/cases that match +# import : no to skip the import pass, once test_gdextension\.godot exists +# +# Exit status: 0 all passed, 1 a case failed, 2 preflight or setup failed. + +$ErrorActionPreference = "Stop" +$RootDir = Split-Path -Parent (Split-Path -Parent $PSCommandPath) +$ScriptDir = Join-Path $RootDir "scripts" +$Project = Join-Path $RootDir "test_gdextension" + +$GodotBin = $env:GODOT +$Only = "" +$DoImport = "yes" +foreach ($item in $Args) { + if ($item -match "^-?-?help$" -or $item -eq "-h") { + $emit = $false + foreach ($line in (Get-Content $PSCommandPath)) { + if ($line -match '^# Usage:') { $emit = $true } + if ($emit) { $line -replace '^# ?', '' } + if ($emit -and $line -match '^# Exit status') { break } + } + exit 0 + } + $kv = $item -split "=", 2 + switch ($kv[0]) { + "godot" { $GodotBin = $kv[1] } + "only" { $Only = $kv[1] } + "import" { $DoImport = $kv[1] } + default { Write-Error "unknown arg '$item'" } + } +} + +# --- find a binary -------------------------------------------------------- +# godot\bin\* is deliberately not in this list. That is a custom module build +# with SpriteStudio compiled into it, so it registers the classes a second time +# and aborts on a project carrying addons\spritestudio -- see the message below. +if (-not $GodotBin) { + foreach ($cand in @( + (Join-Path $RootDir "godot-bin\godot.exe"), + (Join-Path $RootDir "godot-bin\godot"), + (Join-Path $RootDir "godot-bin\Godot.app\Contents\MacOS\Godot"))) { + if (Test-Path $cand) { $GodotBin = $cand; break } + } +} +if (-not $GodotBin) { + $onPath = Get-Command "godot" -ErrorAction SilentlyContinue + if ($onPath) { $GodotBin = $onPath.Source } +} +if (-not $GodotBin -or -not (Test-Path $GodotBin)) { + $pin = (Get-Content (Join-Path $ScriptDir "GODOT_VERSION.txt")).Trim() + Write-Host @" +run-tests.ps1: no Godot binary found. + + Pass one: scripts\run-tests.ps1 godot=C:\path\to\godot.exe + or install the pin: scripts\fetch-godot.ps1 ($pin, the editor build only) + +Not godot\bin\* — that is a custom module build with SpriteStudio compiled in, +and it cannot open a project that also loads the extension. +"@ + exit 2 +} + +Write-Host "run-tests.ps1: $(& $GodotBin --headless --version | Select-Object -Last 1) at $GodotBin" + +# --- import pass ---------------------------------------------------------- +# A project Godot has never opened has no .godot\, and the textures beside each +# .ssab are not importable until it does. Cheap after the first run. +# It is run twice on purpose, and the SECOND run is the one that has to pass. +# +# The first headless scan of a project that loads a godot-cpp GDExtension aborts +# on the way out (null dereference, caught by Godot's own crash handler). It is +# NOT this repository's code: godot-cpp's own test extension, with none of our +# sources in it, reproduces it exactly -- and a project with no extension at all +# does not. godot-cpp has no 4.6/4.7 release branch (it went from +# godot-4.5-stable straight to the 10.0 line), so an extension for Godot 4.7 is +# built from master against api_version=4.7, and that is the combination that +# does this. The scan's work completes: every later run exits 0 with nothing to +# do. +# +# So this is a retry, not a tolerance. A crash that repeats is still a failure +# here, and the clean second run is the evidence that the import finished -- +# nothing is being waved through on the strength of the first one. +if ($DoImport -eq "yes") { + $importLog = & $GodotBin --headless --path $Project --import 2>&1 + $importStatus = $LASTEXITCODE + if ($importStatus -ne 0) { + Write-Host "run-tests.ps1: the first import exited $importStatus (godot-cpp's known first-scan crash); retrying." + $importLog = & $GodotBin --headless --path $Project --import 2>&1 + $importStatus = $LASTEXITCODE + } + $importErrors = $importLog | Select-String -Pattern '^ERROR:' + if ($importStatus -ne 0 -or $importErrors) { + Write-Host "run-tests.ps1: the import pass failed." + $importLog | Select-Object -Last 25 | ForEach-Object { Write-Host $_ } + exit 2 + } +} + +# --- run ------------------------------------------------------------------ +# --quit-after is a hang guard, not a schedule: a script error inside a case +# aborts run_tests.gd's _init and leaves the tree idling forever. It costs the +# exit code its meaning on that path -- Godot leaves 0 on the way out -- which +# is why the marker below, and not the status, is what says a run completed. +$runArgs = @("--headless", "--path", $Project, "--quit-after", "100000", + "--script", "res://run_tests.gd") +if ($Only) { $runArgs += @("--", "--only=$Only") } + +$output = & $GodotBin @runArgs 2>&1 +$status = $LASTEXITCODE +$output | ForEach-Object { Write-Host $_ } + +# Godot aborts during extension validation, before run_tests.gd gets to say +# anything, so this is the one failure the wrapper has to explain itself. +if ($output -match "appears to be already registered") { + Write-Host @" + +run-tests.ps1: that Godot binary already has SpriteStudio compiled into it (a +custom module build), so loading the extension registers every class twice. + + Use a stock binary (scripts\fetch-godot.ps1), or remove + test_gdextension\addons\spritestudio to run the same suite against the module build. +"@ + exit 2 +} + +if ($output -notmatch "==== SUITE FINISHED ====") { + Write-Host "`nrun-tests.ps1: the run stopped before the suite finished — see above." + exit 1 +} + +exit $status diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh new file mode 100755 index 0000000..0140e77 --- /dev/null +++ b/scripts/run-tests.sh @@ -0,0 +1,158 @@ +#!/bin/bash +# +# Run the headless test suite in test_gdextension/ against the GDExtension build. +# +# The work is test_gdextension/run_tests.gd; this finds a Godot binary, runs the import +# pass the project needs before its first run, and forwards the options. +# +# Two prerequisites, both gitignored and so both missing from a fresh clone. +# run_tests.gd checks them before it starts anything and names the script that +# produces each: +# test_gdextension/addons/spritestudio/ <- build-extension.{sh,ps1} +# test_gdextension/ssab_generated/ <- deploy-examples.{sh,ps1} +# +# It never downloads anything on your behalf. When no binary is found it prints +# scripts/fetch-godot.sh and stops; a download of tens of MB is your decision. +# +# What this does NOT cover: drawing, and the custom-module build. --headless +# installs a dummy rasteriser, so there are no pixels to compare; and a module +# is compiled into the engine, so testing that build would mean building Godot +# rather than downloading it. The two builds share one copy of the playback +# logic and differ only in a layer of #ifdef SPRITESTUDIO_GODOT_EXTENSION +# adapters -- includes and type conversions, which is what a compiler checks, +# so the module build's guard is that it still builds. +# +# Usage: scripts/run-tests.sh [godot=] [only=] [import=no] +# godot : the Godot binary to use (else $GODOT, godot-bin/, then PATH) +# only : comma-separated substrings; run only the suites/cases that match +# import : no to skip the import pass, once test_gdextension/.godot exists +# +# Exit status: 0 all passed, 1 a case failed, 2 preflight or setup failed. +set -euo pipefail + +APP=$(basename "$0") +SCRIPTDIR=$(cd "$(dirname "$0")" && pwd -P) +ROOTDIR=$(cd "$SCRIPTDIR/.." && pwd -P) +PROJECT="${ROOTDIR}/test_gdextension" + +usage() { sed -n '/^# Usage:/,/^# Exit status/p' "$0" | sed 's/^# \{0,1\}//'; } + +GODOT_BIN="${GODOT:-}" +ONLY="" +DO_IMPORT="yes" +for item in "$@"; do + case "$item" in + godot=*) GODOT_BIN="${item#*=}" ;; + only=*) ONLY="${item#*=}" ;; + import=*) DO_IMPORT="${item#*=}" ;; + -h|--help|help) usage; exit 0 ;; + *) echo "$APP: unknown arg '$item'" >&2; usage; exit 2 ;; + esac +done + +# --- find a binary -------------------------------------------------------- +# godot/bin/* is deliberately not in this list. That is a custom module build +# with SpriteStudio compiled into it, so it registers the classes a second time +# and aborts on a project carrying addons/spritestudio -- see the message below. +if [ -z "$GODOT_BIN" ]; then + for cand in \ + "${ROOTDIR}/godot-bin/Godot.app/Contents/MacOS/Godot" \ + "${ROOTDIR}/godot-bin/godot" \ + "${ROOTDIR}/godot-bin/godot.exe"; do + if [ -x "$cand" ]; then GODOT_BIN="$cand"; break; fi + done +fi +if [ -z "$GODOT_BIN" ] && command -v godot >/dev/null; then + GODOT_BIN="$(command -v godot)" +fi +if [ -z "$GODOT_BIN" ] || [ ! -x "$GODOT_BIN" ]; then + cat >&2 </dev/null | tail -n 1) at ${GODOT_BIN}" + +# --- import pass ---------------------------------------------------------- +# A project Godot has never opened has no .godot/, and the textures beside each +# .ssab are not importable until it does. Cheap after the first run. +# It is run twice on purpose, and the SECOND run is the one that has to pass. +# +# The first headless scan of a project that loads a godot-cpp GDExtension aborts +# on the way out (null dereference, caught by Godot's own crash handler). It is +# NOT this repository's code: godot-cpp's own test extension, with none of our +# sources in it, reproduces it exactly -- and a project with no extension at all +# does not. godot-cpp has no 4.6/4.7 release branch (it went from +# godot-4.5-stable straight to the 10.0 line), so an extension for Godot 4.7 is +# built from master against api_version=4.7, and that is the combination that +# does this. The scan's work completes: every later run exits 0 with nothing to +# do. +# +# So this is a retry, not a tolerance. A crash that repeats is still a failure +# here, and the clean second run is the evidence that the import finished -- +# nothing is being waved through on the strength of the first one. +if [ "$DO_IMPORT" = "yes" ]; then + IMPORT_LOG=$(mktemp) + set +e + "$GODOT_BIN" --headless --path "$PROJECT" --import >"$IMPORT_LOG" 2>&1 + IMPORT_STATUS=$? + if [ "$IMPORT_STATUS" -ne 0 ]; then + echo "$APP: the first import exited $IMPORT_STATUS (godot-cpp's known first-scan crash); retrying." + "$GODOT_BIN" --headless --path "$PROJECT" --import >"$IMPORT_LOG" 2>&1 + IMPORT_STATUS=$? + fi + set -e + if [ "$IMPORT_STATUS" -ne 0 ] || grep -q '^ERROR:' "$IMPORT_LOG"; then + echo "$APP: the import pass failed." >&2 + tail -n 25 "$IMPORT_LOG" >&2 + rm -f "$IMPORT_LOG" + exit 2 + fi + rm -f "$IMPORT_LOG" +fi + +# --- run ------------------------------------------------------------------ +# --quit-after is a hang guard, not a schedule: a script error inside a case +# aborts run_tests.gd's _init and leaves the tree idling forever. It costs the +# exit code its meaning on that path -- Godot leaves 0 on the way out -- which +# is why the marker below, and not the status, is what says a run completed. +ARGS=(--headless --path "$PROJECT" --quit-after 100000 --script res://run_tests.gd) +[ -n "$ONLY" ] && ARGS+=(-- "--only=${ONLY}") + +set +e +OUTPUT=$("$GODOT_BIN" "${ARGS[@]}" 2>&1) +STATUS=$? +set -e +echo "$OUTPUT" + +# Godot aborts during extension validation, before run_tests.gd gets to say +# anything, so this is the one failure the wrapper has to explain itself. +if echo "$OUTPUT" | grep -q "appears to be already registered"; then + cat >&2 <&2 + echo "$APP: the run stopped before the suite finished — see above." >&2 + exit 1 +fi + +exit $STATUS diff --git a/test_gdextension/project.godot b/test_gdextension/project.godot new file mode 100644 index 0000000..80c7986 --- /dev/null +++ b/test_gdextension/project.godot @@ -0,0 +1,27 @@ +; Test project for the headless suite. Not a sample — the samples are under +; examples/, and MAINTAINING_PLAYERS.md is why they stay separate: a project +; that is both the demo and the scratch pad fills its source map with whatever +; a developer converted last and stops being committable. +; +; The _gdextension suffix is the repository's convention for a project that +; carries addons/spritestudio: a custom-module build of Godot already has the +; classes compiled in and aborts on double registration, so which build a +; project targets has to be visible in its name. +; +; The addon and ssab_generated/ are build outputs and gitignored: +; scripts/build-extension.* installs the first, scripts/deploy-examples.* the +; second. run_tests.gd refuses to start without them. + +config_version=5 + +[application] + +config/name="SpriteStudio Player tests (GDExtension)" +config/features=PackedStringArray("4.7") +run/disable_stdout=false + +[debug] + +; A failed assertion inside the extension should reach the log rather than pop +; a dialog nothing is there to dismiss. +settings/stdout/print_fps=false diff --git a/test_gdextension/run_tests.gd b/test_gdextension/run_tests.gd new file mode 100644 index 0000000..a686603 --- /dev/null +++ b/test_gdextension/run_tests.gd @@ -0,0 +1,178 @@ +## Headless test runner for the SpriteStudio GDExtension. +## +## Run through `scripts/run-tests.{sh,ps1}`, which finds a Godot binary and +## points it here. By hand: +## +## --headless --path test_gdextension --script res://run_tests.gd +## +## Scope, and why it is what it is. This exercises the **GDExtension** build, +## which is what a user drops into their project, and it exercises it through +## GDScript — the same door a user's game goes through. It therefore covers the +## bound API and the signals, and it does **not** cover drawing: `--headless` +## installs a dummy rasteriser, so pixels are not a thing that exists here. That +## is not a gap this runner should try to close; `NOTIFICATION_DRAW` is a no-op +## in `SpriteStudioPlayer2D` anyway, because the InternalPlayer issues its own +## RenderingServer calls. +## +## The custom-module build is not tested here and cannot be: a module is +## compiled into the engine, so testing it would mean building Godot rather than +## downloading it. What the two builds share is the playback logic — one copy — +## and what they do not share is a layer of `#ifdef SPRITESTUDIO_GODOT_EXTENSION` +## adapters, which are includes and type conversions. Those are what a compiler +## checks, so the module build's own guard is that it still builds. +## +## Exit status: 0 all passed - 1 a case failed - 2 preflight failed. +extends SceneTree + +const SUITE_DIR := "res://suites" + +## Fixture packs `deploy-examples` produces. Preflight refuses to start without +## them rather than letting every suite skip its way to a green run. +const REQUIRED_PACKS := { + "res://ssab_generated/overall/Basic.ssab": "overall", + "res://ssab_generated/Ringo/Ringo.ssab": "Ringo", +} + +## Classes the extension registers. Absent means it did not load, which is the +## one failure worth telling apart from every other. +const REQUIRED_CLASSES := ["SpriteStudioPlayer2D", "SSABResource"] + + +func _init() -> void: + var problems := _preflight() + if not problems.is_empty(): + print("run_tests.gd: the suite cannot run yet.\n") + for problem in problems: + print(" !! %s" % problem) + print("") + _finish(2) + return + + var suites := _discover() + if suites.is_empty(): + print("run_tests.gd: no suite found under %s" % SUITE_DIR) + _finish(2) + return + + var only := _only_filter() + var total_cases := 0 + var total_assertions := 0 + var failures: Array[String] = [] + var skips: Array[String] = [] + + print("== headless suite: %d files (%s) ==\n" % [suites.size(), Engine.get_version_info().string]) + + for path in suites: + var suite = load(path).new() + suite.root = root + var name := path.get_file().get_basename() + var cases := _cases_of(suite) + if not only.is_empty(): + cases = cases.filter(func(c): return only.any(func(o): return o in c or o in name)) + if cases.is_empty(): + continue + + var before_failures: int = suite.failures.size() + for case in cases: + suite.begin_case(case) + suite.setup() + suite.call(case) + suite.teardown() + suite.release_owned() + total_cases += 1 + + total_assertions += suite.assertions + failures.append_array(suite.failures) + skips.append_array(suite.skips) + + var failed: int = suite.failures.size() - before_failures + var verdict := "FAIL" if failed > 0 else "PASS" + var note := "" + if not suite.skips.is_empty(): + note = " %d skipped" % suite.skips.size() + print(" %-4s %-28s %2d cases, %3d assertions%s" + % [verdict, name, cases.size(), suite.assertions, note]) + + if not skips.is_empty(): + print("\n-- declared skips (not passes) --") + for skip in skips: + print(" SKIP %s" % skip) + + if not failures.is_empty(): + print("\n-- failures --") + for failure in failures: + print(" FAIL %s" % failure) + + print("\n== RESULT: %d cases, %d assertions, %d failed, %d skipped ==" + % [total_cases, total_assertions, failures.size(), skips.size()]) + _finish(1 if not failures.is_empty() else 0) + + +## Prints the marker `run-tests.*` looks for, then exits. +## +## The exit code alone is not enough to trust. A script error inside a case +## aborts `_init` before anything below it runs, and the tree then idles forever +## — so the wrapper passes `--quit-after`, which makes Godot exit 0 on the way +## out and turns a crashed run into a green one. The marker is what tells a run +## that finished apart from one that stopped in the middle. +func _finish(code: int) -> void: + print("==== SUITE FINISHED ====") + quit(code) + + +## The reasons the suite cannot run — empty when it can. +func _preflight() -> Array[String]: + var problems: Array[String] = [] + + var missing_classes := REQUIRED_CLASSES.filter(func(c): return not ClassDB.class_exists(c)) + if not missing_classes.is_empty(): + problems.append( + "the extension did not load: %s not registered\n" + % ", ".join(missing_classes) + + " -> build it (scripts/build-extension.sh) and check\n" + + " test_gdextension/addons/spritestudio/ has a binary for this platform") + + var missing_packs: Array[String] = [] + for path in REQUIRED_PACKS: + if not ResourceLoader.exists(path) and not FileAccess.file_exists(path): + missing_packs.append(REQUIRED_PACKS[path]) + if not missing_packs.is_empty(): + problems.append( + "playback data is not deployed: %s\n" % ", ".join(missing_packs) + + " -> scripts/deploy-examples.sh") + + return problems + + +## Suite scripts, in a stable order. +func _discover() -> Array[String]: + var found: Array[String] = [] + var dir := DirAccess.open(SUITE_DIR) + if dir == null: + return found + for file in dir.get_files(): + # Exported projects rename .gd to .gd.remap; this only ever runs from + # source, but reading the basename keeps it honest either way. + if file.ends_with(".gd"): + found.append("%s/%s" % [SUITE_DIR, file]) + found.sort() + return found + + +## `test_*` methods declared by the suite itself, not by test_base.gd. +func _cases_of(suite) -> Array[String]: + var cases: Array[String] = [] + for method in suite.get_method_list(): + var name: String = method["name"] + if name.begins_with("test_") and not cases.has(name): + cases.append(name) + cases.sort() + return cases + + +## `--only=[,]`, passed through by run-tests.{sh,ps1}. +func _only_filter() -> Array[String]: + for arg in OS.get_cmdline_user_args(): + if arg.begins_with("--only="): + return Array(arg.trim_prefix("--only=").split(",", false)) + return [] diff --git a/test_gdextension/run_tests.gd.uid b/test_gdextension/run_tests.gd.uid new file mode 100644 index 0000000..4304b06 --- /dev/null +++ b/test_gdextension/run_tests.gd.uid @@ -0,0 +1 @@ +uid://dq7epmt4lmji4 diff --git a/test_gdextension/suites/test_overrides.gd b/test_gdextension/suites/test_overrides.gd new file mode 100644 index 0000000..da9daf5 --- /dev/null +++ b/test_gdextension/suites/test_overrides.gd @@ -0,0 +1,109 @@ +## The part override layer, and the one rule that is easy to get wrong. +## +## **An override lands on the next update, not on the call.** `is_part_hidden` +## reports what the last computed frame said — `_part_hidden` is filled from the +## Brain's draw plan in `SsInternalPlayer` — so a host that sets an override and +## reads it back without stepping sees the old answer and concludes the call did +## nothing. Every case here steps once after setting, deliberately, and +## `test_an_override_lands_on_the_next_update` is the one that states it. +extends "res://test_base.gd" + +const BASIC := "res://ssab_generated/overall/Basic.ssab" + +var player: Node +var dt := 1.0 / 30.0 +var part := "" + + +func setup() -> void: + player = make_player(BASIC) + player.set_animation("anime_1") + dt = 1.0 / maxf(1.0, float(player.get_frame_rate())) + player.play() + player.advance(dt) + # Not the root: hiding that one is the cascade case below. + part = player.get_part_names()[1] + + +func test_a_part_is_visible_before_anything_overrides_it() -> void: + not_ok(player.is_part_hidden(part), "'%s' starts visible" % part) + + +func test_an_override_lands_on_the_next_update() -> void: + ok(player.set_part_visibility_override(part, true), "the call was accepted") + not_ok(player.is_part_hidden(part), "not yet — nothing has been computed") + player.advance(dt) + ok(player.is_part_hidden(part), "hidden once a frame has been computed") + + +func test_clearing_gives_the_part_back() -> void: + player.set_part_visibility_override(part, true) + player.advance(dt) + ok(player.is_part_hidden(part), "hidden") + ok(player.clear_part_visibility_override(part), "the clear was accepted") + player.advance(dt) + not_ok(player.is_part_hidden(part), "visible again") + + +## Cascade is what makes hiding a limb hide what hangs off it. +func test_cascade_reaches_the_children() -> void: + var root_part: String = player.get_part_names()[0] + player.set_part_visibility_override(root_part, true, true) + player.advance(dt) + ok(player.is_part_hidden(part), "a child of the cascaded part is hidden") + + +func test_without_cascade_a_child_is_left_alone() -> void: + var root_part: String = player.get_part_names()[0] + player.set_part_visibility_override(root_part, true, false) + player.advance(dt) + not_ok(player.is_part_hidden(part), "the child keeps its own visibility") + + +func test_clear_all_undoes_every_override_at_once() -> void: + player.set_part_visibility_override(part, true) + player.advance(dt) + ok(player.is_part_hidden(part), "hidden before the clear") + player.clear_all_part_overrides() + player.advance(dt) + not_ok(player.is_part_hidden(part), "visible after clear_all") + + +## The by-index half of the API has to mean the same thing as the by-name half; +## a host that already resolved an index should not have to go back to a string. +func test_the_index_half_agrees_with_the_name_half() -> void: + var index: int = player.find_part_index(part) + gt(index, 0, "'%s' resolves to an index" % part) + ok(player.set_part_visibility_override_by_index(index, true, false), + "the by-index call was accepted") + player.advance(dt) + ok(player.is_part_hidden(part), "the by-index override hides the same part") + player.clear_part_visibility_override_by_index(index) + player.advance(dt) + not_ok(player.is_part_hidden(part), "and the by-index clear gives it back") + + +func test_an_unknown_part_is_refused_rather_than_ignored() -> void: + not_ok(player.set_part_visibility_override("no such part", true), + "an override on a part that does not exist") + eq(player.find_part_index("no such part"), -1, "and it has no index") + + +## Colour and cell overrides are accepted through the same door. What they draw +## is a rendering question and out of scope here; that the call reports success +## for a real part and failure for an imaginary one is not. +func test_colour_and_cell_overrides_are_accepted_for_a_real_part() -> void: + ok(player.set_part_color_override(part, Color(1, 0, 0, 1), 0, 1.0, 0), + "a colour override on '%s'" % part) + player.advance(dt) + ok(player.clear_part_color_override(part), "clearing it") + + var cellmap: String = player.get_cellmap_names()[0] + var cells = player.get_cell_names(cellmap) + if cells.is_empty(): + skip("the first cellmap of Basic has no cells to swap to") + return + ok(player.set_part_cell_override(part, cellmap, cells[0]), + "a cell override on '%s'" % part) + player.advance(dt) + ok(player.clear_part_cell_override(part), "clearing it") diff --git a/test_gdextension/suites/test_overrides.gd.uid b/test_gdextension/suites/test_overrides.gd.uid new file mode 100644 index 0000000..c262be3 --- /dev/null +++ b/test_gdextension/suites/test_overrides.gd.uid @@ -0,0 +1 @@ +uid://d4kaw5ehi6m7b diff --git a/test_gdextension/suites/test_resource.gd b/test_gdextension/suites/test_resource.gd new file mode 100644 index 0000000..757606c --- /dev/null +++ b/test_gdextension/suites/test_resource.gd @@ -0,0 +1,76 @@ +## What a pack tells the node about itself, before anything plays. +## +## `SSABResource` is a Resource, so `load()` is the whole loading story — the +## pack's textures and its instance sub-packs are resolved beside it. These are +## the reads a host does to build a UI (an animation picker, a cell list), and +## every one of them is a name that the API conventions settled, so they are +## also where a rename shows up first. +extends "res://test_base.gd" + +const BASIC := "res://ssab_generated/overall/Basic.ssab" +const RINGO := "res://ssab_generated/Ringo/Ringo.ssab" + +var player: Node + + +func setup() -> void: + player = make_player(BASIC) + + +func test_a_pack_loads_as_its_own_resource_type() -> void: + var res := load(BASIC) + ok(res != null, "load() returned something") + eq(res.get_class(), "SSABResource", "the pack's resource type") + + +func test_the_pack_names_its_animations() -> void: + var names = player.get_animation_names() + gt(names.size(), 0, "Basic has at least one animation") + has(names, "anime_1", "Basic's animation") + + +func test_the_pack_names_its_cellmaps_and_cells() -> void: + var cellmaps = player.get_cellmap_names() + has(cellmaps, "common", "Basic's first cellmap") + gt(player.get_cell_names("common").size(), 0, "cells in 'common'") + eq(player.get_cell_names("no such cellmap").size(), 0, + "an unknown cellmap has no cells") + + +func test_the_pack_names_its_parts_root_first() -> void: + var parts = player.get_part_names() + gt(parts.size(), 1, "Basic has parts") + eq(parts[0], "root", "the part list starts at the root") + + +## The index is the currency of the `*_by_index` half of the override API, so +## the two ways of naming a part have to agree. +func test_a_part_resolves_from_its_name_to_its_index() -> void: + var parts = player.get_part_names() + for i in mini(parts.size(), 8): + eq(player.find_part_index(parts[i]), i, "index of '%s'" % parts[i]) + eq(player.find_part_index("no such part"), -1, "an unknown part has no index") + + +func test_frame_metadata_describes_the_animation() -> void: + player.set_animation("anime_1") + eq(player.get_current_animation(), "anime_1", "the animation that is set up") + gt(player.get_frame_rate(), 0, "frame rate") + gt(player.get_total_frames(), 0, "total frames") + eq(player.get_start_frame(), 0, "the first frame") + eq(player.get_end_frame(), player.get_total_frames() - 1, "the last frame") + + +## The section defaults to the whole animation; `test_playback` covers narrowing it. +func test_the_section_starts_as_the_whole_animation() -> void: + player.set_animation("anime_1") + eq(player.get_animation_section_start(), player.get_start_frame(), "section start") + eq(player.get_animation_section_end(), player.get_end_frame(), "section end") + + +## A second pack, so nothing above is an accident of the one file. +func test_a_second_pack_reads_the_same_way() -> void: + var ringo := make_player(RINGO) + gt(ringo.get_animation_names().size(), 1, "Ringo has several animations") + gt(ringo.get_part_names().size(), 1, "Ringo has parts") + eq(ringo.get_part_names()[0], "root", "Ringo's part list starts at the root") diff --git a/test_gdextension/suites/test_resource.gd.uid b/test_gdextension/suites/test_resource.gd.uid new file mode 100644 index 0000000..28d212d --- /dev/null +++ b/test_gdextension/suites/test_resource.gd.uid @@ -0,0 +1 @@ +uid://du3jrc0bw86yx diff --git a/test_gdextension/suites/test_signals.gd b/test_gdextension/suites/test_signals.gd new file mode 100644 index 0000000..8fffb6b --- /dev/null +++ b/test_gdextension/suites/test_signals.gd @@ -0,0 +1,112 @@ +## The five signals, their payloads, and when each one fires. +## +## Worth its own suite because a signal is the one part of the API that fails +## silently: a renamed signal, or one that stopped being emitted, breaks every +## host that connected to it and breaks nothing that a build would notice. The +## arity matters as much as the name — connecting a zero-argument callable to +## `animation_finished(anim_name)` is an error at emit time, not at connect time. +extends "res://test_base.gd" + +const BASIC := "res://ssab_generated/overall/Basic.ssab" +const RINGO := "res://ssab_generated/Ringo/Ringo.ssab" + +var player: Node +var dt := 1.0 / 30.0 +var seen: Array = [] +var frames: Array = [] + + +func setup() -> void: + seen = [] + frames = [] + player = make_player(BASIC) + player.animation_started.connect(func(n): seen.append(["started", n])) + player.animation_changed.connect(func(n): seen.append(["changed", n])) + player.animation_finished.connect(func(n): seen.append(["finished", n])) + player.animation_looped.connect(func(n): seen.append(["looped", n])) + player.frame_updated.connect(func(f): frames.append(f)) + player.set_animation("anime_1") + dt = 1.0 / maxf(1.0, float(player.get_frame_rate())) + + +func _kinds() -> Array: + return seen.map(func(e): return e[0]) + + +func test_the_node_declares_all_five() -> void: + for name in ["animation_started", "animation_changed", "animation_finished", + "animation_looped", "frame_updated"]: + ok(player.has_signal(name), "the '%s' signal exists" % name) + + +## Setting up an animation is not starting it: a host that pre-selects an +## animation on a stopped player should not see a start. +func test_setting_an_animation_does_not_start_it() -> void: + eq(_kinds().has("started"), false, "no start from set_animation alone") + + +func test_play_starts_it_and_names_it() -> void: + player.play() + player.advance(dt) + has(_kinds(), "started", "animation_started fired") + for entry in seen: + if entry[0] == "started": + eq(entry[1], "anime_1", "animation_started carries the animation name") + + +func test_frame_updated_fires_once_per_advance() -> void: + player.play() + for i in 6: + player.advance(dt) + eq(frames.size(), 6, "one frame_updated per advance()") + ok(frames[-1] is float, "frame_updated carries a frame number") + ok(frames[-1] > frames[0], "and the number moves") + + +## Two loops of a non-looping-forever animation: the boundary is a `looped`, the +## end of the last pass is a `finished`, and they arrive in that order. +func test_a_bounded_run_loops_then_finishes() -> void: + player.set_loop_count(2) + player.play() + for i in 60: + player.advance(dt) + var kinds := _kinds() + has(kinds, "looped", "animation_looped fired at the loop boundary") + has(kinds, "finished", "animation_finished fired at the end") + ok(kinds.find("looped") < kinds.find("finished"), + "the loop boundary comes before the end") + ok(player.is_finished(), "and the player reports itself finished") + + +func test_every_payload_is_the_animation_name() -> void: + player.set_loop_count(1) + player.play() + for i in 40: + player.advance(dt) + gt(seen.size(), 0, "something fired") + for entry in seen: + eq(entry[1], "anime_1", "'%s' carries the animation name" % entry[0]) + + +## The signal a host uses to react to a switch, as opposed to a start. +## +## Ringo rather than Basic, which carries a single animation and so has nothing +## to switch to. +func test_switching_animations_reports_the_change() -> void: + var multi = make_player(RINGO) + multi.set_animation_process_mode(2) + var switches: Array = [] + multi.animation_changed.connect(func(n): switches.append(n)) + + var names = multi.get_animation_names() + gt(names.size(), 1, "Ringo has more than one animation") + multi.set_animation(names[0]) + multi.play() + multi.advance(dt) + switches.clear() + + multi.set_animation(names[1]) + multi.advance(dt) + eq(switches.size(), 1, "animation_changed fired once on the switch") + if switches.size() == 1: + eq(switches[0], names[1], "and it names the animation switched to") diff --git a/test_gdextension/suites/test_signals.gd.uid b/test_gdextension/suites/test_signals.gd.uid new file mode 100644 index 0000000..ebb799e --- /dev/null +++ b/test_gdextension/suites/test_signals.gd.uid @@ -0,0 +1 @@ +uid://btndhbi4ej3bx diff --git a/test_gdextension/suites/test_transport.gd b/test_gdextension/suites/test_transport.gd new file mode 100644 index 0000000..6c8f2e9 --- /dev/null +++ b/test_gdextension/suites/test_transport.gd @@ -0,0 +1,131 @@ +## Play, pause, resume, stop — and what each one leaves behind. +## +## Everything here steps with `advance()` under `ANIMATION_PROCESS_MANUAL`, so +## the assertions are about the transport rather than about how long a frame +## took on this machine. That is also the only way these can mean the same thing +## on three platforms. +extends "res://test_base.gd" + +const BASIC := "res://ssab_generated/overall/Basic.ssab" + +var player: Node +var dt := 1.0 / 30.0 + + +func setup() -> void: + player = make_player(BASIC) + player.set_animation("anime_1") + dt = 1.0 / maxf(1.0, float(player.get_frame_rate())) + + +func test_a_player_starts_stopped() -> void: + not_ok(player.is_playing(), "not playing before play()") + not_ok(player.is_pausing(), "not pausing before play()") + near(player.get_frame_no(), 0.0, "the head starts at the first frame") + + +func test_play_starts_it_and_advance_moves_the_head() -> void: + player.play() + ok(player.is_playing(), "playing after play()") + near(player.get_frame_no(), 0.0, "play() alone does not advance") + for i in 5: + player.advance(dt) + near(player.get_frame_no(), 5.0, "five steps of one frame each", 0.001) + + +## The property that makes every other case here reproducible. +func test_manual_mode_advances_only_when_asked() -> void: + eq(player.get_animation_process_mode(), 2, "MANUAL") + player.play() + var before: float = player.get_frame_no() + # No advance() between these two reads: nothing else may move the head. + near(player.get_frame_no(), before, "the head does not move on its own") + + +## `pause()` is not `stop()`: the head stays where it is and the player still +## reports itself playing, which is what lets a host resume without re-deciding +## what was playing. +func test_pause_holds_the_head_and_resume_gives_it_back() -> void: + player.play() + for i in 3: + player.advance(dt) + var held: float = player.get_frame_no() + + player.pause() + ok(player.is_pausing(), "pausing after pause()") + ok(player.is_playing(), "still reports playing while paused") + player.advance(dt) + near(player.get_frame_no(), held, "a paused player does not advance") + + player.resume() + not_ok(player.is_pausing(), "not pausing after resume()") + player.advance(dt) + near(player.get_frame_no(), held + 1.0, "the head moves again after resume", 0.001) + + +## `stop()` leaves the head where it was rather than rewinding, so a host that +## wants the first frame back asks for it. +func test_stop_ends_playback_without_rewinding() -> void: + player.play() + for i in 4: + player.advance(dt) + var reached: float = player.get_frame_no() + player.stop() + not_ok(player.is_playing(), "not playing after stop()") + near(player.get_frame_no(), reached, "stop() does not rewind") + + +func test_the_head_can_be_placed_by_hand() -> void: + player.set_frame_no(7.0) + near(player.get_frame_no(), 7.0, "set_frame_no") + + +func test_speed_scales_the_step() -> void: + eq(player.get_speed_scale(), 1.0, "the default speed") + player.set_speed_scale(2.0) + eq(player.get_speed_scale(), 2.0, "the speed that took effect") + player.play() + player.advance(dt) + near(player.get_frame_no(), 2.0, "one step at double speed", 0.001) + + +func test_the_head_runs_forward_by_default() -> void: + player.play() + player.advance(dt) + ok(player.is_playing_forward(), "forward by default") + eq(player.get_playback_direction(), 0, "the configured direction") + + +## Reverse does not mean "the same run, mirrored": `play()` puts the head on the +## LAST frame, because that is where a backwards pass begins. A seek before +## `play()` is therefore not a way to choose where reverse starts — it is +## overwritten — and a host that wants to start part-way seeks afterwards. +func test_reverse_starts_at_the_end_and_walks_back() -> void: + player.set_playback_direction(1, 0) + eq(player.get_playback_direction(), 1, "the configured direction") + player.play() + near(player.get_frame_no(), float(player.get_end_frame()), + "reverse play() starts at the last frame") + not_ok(player.is_playing_forward(), "the head is heading backwards") + + var from: float = player.get_frame_no() + player.advance(dt) + near(player.get_frame_no(), from - 1.0, "one step backwards", 0.001) + + +func test_a_seek_after_play_is_where_reverse_carries_on_from() -> void: + player.set_playback_direction(1, 0) + player.play() + player.set_frame_no(10.0) + near(player.get_frame_no(), 10.0, "the seek took") + player.advance(dt) + near(player.get_frame_no(), 9.0, "and the next step goes backwards from there", 0.001) + + +func test_flip_is_reported_back() -> void: + not_ok(player.is_flipped_h(), "not flipped to start with") + not_ok(player.is_flipped_v(), "not flipped to start with") + player.set_flip_h(true) + player.set_flip_v(true) + ok(player.is_flipped_h(), "flip_h") + ok(player.is_flipped_v(), "flip_v") diff --git a/test_gdextension/suites/test_transport.gd.uid b/test_gdextension/suites/test_transport.gd.uid new file mode 100644 index 0000000..c72720d --- /dev/null +++ b/test_gdextension/suites/test_transport.gd.uid @@ -0,0 +1 @@ +uid://bkbfrhtwgb2f diff --git a/test_gdextension/test_base.gd b/test_gdextension/test_base.gd new file mode 100644 index 0000000..42e689d --- /dev/null +++ b/test_gdextension/test_base.gd @@ -0,0 +1,136 @@ +## Assertions and per-case bookkeeping for the headless suite. +## +## A suite extends this and names its cases `test_*`; `run_tests.gd` finds them +## by reflection and calls each one with a fresh `setup()`/`teardown()` around +## it, so a case that leaves a player in a strange state cannot mislead the next. +## +## Three verdicts, not two. `fail()` is a defect. `skip()` is a case that +## **declared** it cannot run here — a host service this platform does not +## offer — and is reported apart from the passes rather than counted as one: +## the whole point of running on three platforms without asserting the same +## things on all three is that the difference stays visible. +extends RefCounted + +var failures: Array[String] = [] +var skips: Array[String] = [] +var assertions := 0 + +var _case := "" +var _owned: Array[Node] = [] + +## The tree cases attach nodes to. `run_tests.gd` sets it before the first case. +var root: Node = null + + +func begin_case(name: String) -> void: + _case = name + + +## Hook for a suite that needs one player per case. Called before each `test_*`. +func setup() -> void: + pass + + +## Hook for a suite's own cleanup. Called after each `test_*`, pass or fail. +func teardown() -> void: + pass + + +## Frees whatever `own()` was given, after `teardown()`. +func release_owned() -> void: + for node in _owned: + if is_instance_valid(node): + if node.get_parent() != null: + node.get_parent().remove_child(node) + node.free() + _owned.clear() + + +## Hands a node to the harness to free at the end of the case. +## +## Not a convenience: a `SpriteStudioPlayer2D` that is never freed leaks its +## canvas-item RIDs, and Godot reports that only at exit — as a warning, long +## after the case that caused it, and with nothing naming the culprit. +func own(node: Node) -> Node: + _owned.append(node) + return node + + +## A player parented to the test root, stepped by hand rather than by the clock. +## +## MANUAL is what makes a case reproducible: nothing advances between the +## assertions except the `advance()` calls the case itself makes, so the result +## does not depend on how long a frame took on this machine. +func make_player(ssab_path := "") -> Node: + var player = ClassDB.instantiate("SpriteStudioPlayer2D") + own(player) + root.add_child(player) + player.set_animation_process_mode(2) # ANIMATION_PROCESS_MANUAL + if ssab_path != "": + player.set_ssab_resource(load(ssab_path)) + return player + + +func _record_failure(message: String) -> void: + failures.append("%s: %s" % [_case, message]) + + +func ok(condition: bool, what: String) -> bool: + assertions += 1 + if not condition: + _record_failure("%s -- expected true" % what) + return condition + + +func not_ok(condition: bool, what: String) -> bool: + return ok(not condition, what) + + +func eq(actual, expected, what: String) -> bool: + assertions += 1 + if actual != expected: + _record_failure("%s -- expected %s, got %s" % [what, expected, actual]) + return false + return true + + +func ne(actual, unexpected, what: String) -> bool: + assertions += 1 + if actual == unexpected: + _record_failure("%s -- expected anything but %s" % [what, unexpected]) + return false + return true + + +## Float comparison with a tolerance, which is every float comparison here. +## +## The frame arithmetic is the Brain's and is the same source on every platform, +## but it arrives through a `double` -> `float` boundary and there is no reason +## to demand the last bit of it agree across three compilers. +func near(actual: float, expected: float, what: String, eps := 0.0001) -> bool: + assertions += 1 + if absf(actual - expected) > eps: + _record_failure("%s -- expected %f +/- %f, got %f" % [what, expected, eps, actual]) + return false + return true + + +func gt(actual, floor_value, what: String) -> bool: + assertions += 1 + if not (actual > floor_value): + _record_failure("%s -- expected greater than %s, got %s" % [what, floor_value, actual]) + return false + return true + + +func has(collection, value, what: String) -> bool: + assertions += 1 + if not (value in collection): + _record_failure("%s -- %s is not in %s" % [what, value, collection]) + return false + return true + + +## Declares this case unrunnable here, with the reason. Not a pass. +func skip(reason: String) -> void: + skips.append("%s: %s" % [_case, reason]) diff --git a/test_gdextension/test_base.gd.uid b/test_gdextension/test_base.gd.uid new file mode 100644 index 0000000..fec2444 --- /dev/null +++ b/test_gdextension/test_base.gd.uid @@ -0,0 +1 @@ +uid://cifjvvc8q8l1f From 0356c6ed686904aeeea5d2f210fe27d56c8c23da Mon Sep 17 00:00:00 2001 From: Naruto TAKAHASHI Date: Fri, 28 Aug 2026 15:11:41 +0900 Subject: [PATCH 3/3] docs: name what actually triggers the first-scan crash Not the import. A project with zero importable files crashes the same way, and deleting only .godot/extension_list.cfg from an imported project brings it back -- so the trigger is the run in which Godot first DISCOVERS the extension and loads it mid-scan, rather than at startup from that cache. Also rules out the descriptor as a cause: `reloadable` unset, false and true all reproduce, and godot-cpp's own test extension does it with a different entry_symbol and compatibility_minimum. --- AGENTS.md | 21 ++++++++++++--------- scripts/run-tests.ps1 | 22 +++++++++++++--------- scripts/run-tests.sh | 22 +++++++++++++--------- 3 files changed, 38 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4f6e2c3..7ec460d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,15 +113,18 @@ run on this host declares a **skip**, which is reported apart from the passes and never counted as one. **The first headless import crashes, and `run-tests.*` retries it once. It is a -godot-cpp problem, not ours.** A fresh scan of a project loading *any* godot-cpp -GDExtension aborts on the way out (null dereference, caught by Godot's own crash -handler) — **godot-cpp's own `test/` extension reproduces it exactly**, and a -project with no extension does not. godot-cpp has no 4.6/4.7 release branch: it -went from `godot-4.5-stable` straight to the 10.0 line, so an extension for -Godot 4.7 is built from master against `api_version=4.7`, and that pairing is -what does this. The scan's work completes, so the second run is clean and every -later one has nothing to do; the retry requires that second run to pass, because -a crash that repeats is still a failure. Not test-only — anything running +godot-cpp problem, not ours.** The run in which Godot first *discovers* the +extension aborts on the way out (null dereference, caught by Godot's own crash +handler). Not the import — a project with **zero importable files** does it too; +what triggers it is the extension being loaded mid-scan rather than at startup +from `.godot/extension_list.cfg`, and deleting just that file brings it back. +**godot-cpp's own `test/` extension reproduces it exactly**, a project with no +extension does not, and registering nothing at all still does — so it is the +pairing, not this code. godot-cpp has no 4.6/4.7 release branch: it went from +`godot-4.5-stable` straight to the 10.0 line, so an extension for Godot 4.7 is +built from master against `api_version=4.7`. The scan's work completes, so the +second run is clean; the retry requires that second run to pass, because a crash +that repeats is still a failure. Not test-only — anything running `godot --headless --import` on a fresh checkout meets it. diff --git a/scripts/run-tests.ps1 b/scripts/run-tests.ps1 index 36c0e27..72689f6 100644 --- a/scripts/run-tests.ps1 +++ b/scripts/run-tests.ps1 @@ -93,16 +93,20 @@ Write-Host "run-tests.ps1: $(& $GodotBin --headless --version | Select-Object -L # .ssab are not importable until it does. Cheap after the first run. # It is run twice on purpose, and the SECOND run is the one that has to pass. # -# The first headless scan of a project that loads a godot-cpp GDExtension aborts -# on the way out (null dereference, caught by Godot's own crash handler). It is -# NOT this repository's code: godot-cpp's own test extension, with none of our -# sources in it, reproduces it exactly -- and a project with no extension at all -# does not. godot-cpp has no 4.6/4.7 release branch (it went from -# godot-4.5-stable straight to the 10.0 line), so an extension for Godot 4.7 is -# built from master against api_version=4.7, and that is the combination that -# does this. The scan's work completes: every later run exits 0 with nothing to -# do. +# The run in which Godot first DISCOVERS the extension aborts on the way out +# (null dereference, caught by Godot's own crash handler). Not the import: a +# project with zero importable files does it too. What triggers it is the +# extension being loaded mid-scan rather than at startup from +# .godot/extension_list.cfg -- delete just that file and it happens again. # +# It is NOT this repository's code. godot-cpp's own test extension, with none of +# our sources and a different descriptor, reproduces it exactly; a project with +# no extension does not; and registering nothing at all still does. godot-cpp +# has no 4.6/4.7 release branch (it went from godot-4.5-stable straight to the +# 10.0 line), so an extension for Godot 4.7 is built from master against +# api_version=4.7, and that is the combination that does this. +# +# The scan's work completes -- the second run exits 0 with nothing left to do. # So this is a retry, not a tolerance. A crash that repeats is still a failure # here, and the clean second run is the evidence that the import finished -- # nothing is being waved through on the strength of the first one. diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 0140e77..b5a0a7c 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -85,16 +85,20 @@ echo "$APP: $("$GODOT_BIN" --headless --version 2>/dev/null | tail -n 1) at ${GO # .ssab are not importable until it does. Cheap after the first run. # It is run twice on purpose, and the SECOND run is the one that has to pass. # -# The first headless scan of a project that loads a godot-cpp GDExtension aborts -# on the way out (null dereference, caught by Godot's own crash handler). It is -# NOT this repository's code: godot-cpp's own test extension, with none of our -# sources in it, reproduces it exactly -- and a project with no extension at all -# does not. godot-cpp has no 4.6/4.7 release branch (it went from -# godot-4.5-stable straight to the 10.0 line), so an extension for Godot 4.7 is -# built from master against api_version=4.7, and that is the combination that -# does this. The scan's work completes: every later run exits 0 with nothing to -# do. +# The run in which Godot first DISCOVERS the extension aborts on the way out +# (null dereference, caught by Godot's own crash handler). Not the import: a +# project with zero importable files does it too. What triggers it is the +# extension being loaded mid-scan rather than at startup from +# .godot/extension_list.cfg -- delete just that file and it happens again. # +# It is NOT this repository's code. godot-cpp's own test extension, with none of +# our sources and a different descriptor, reproduces it exactly; a project with +# no extension does not; and registering nothing at all still does. godot-cpp +# has no 4.6/4.7 release branch (it went from godot-4.5-stable straight to the +# 10.0 line), so an extension for Godot 4.7 is built from master against +# api_version=4.7, and that is the combination that does this. +# +# The scan's work completes -- the second run exits 0 with nothing left to do. # So this is a retry, not a tolerance. A crash that repeats is still a failure # here, and the clean second run is the evidence that the import finished -- # nothing is being waved through on the strength of the first one.