Replace dasher-pack with unified animation-pack using original Blender bone names
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## Scanner that detects whether `addons/godot_ai/` is in a half-installed
|
||||
## state left behind by a self-update whose rollback couldn't restore the
|
||||
## previous addon contents (`UpdateReloadRunner.InstallStatus.FAILED_MIXED`).
|
||||
##
|
||||
## Without this surface the user sees "plugin won't start" with no actionable
|
||||
## context, re-runs the update, and compounds the mismatch (issue #354 /
|
||||
## audit-v2 #10). The dock paints a banner from `diagnose()` and
|
||||
## `editor_handler.gd::get_editor_state` includes the same Dictionary so an
|
||||
## MCP agent can see and report the state.
|
||||
|
||||
const ADDON_DIR := "res://addons/godot_ai/"
|
||||
## Producer is `update_reload_runner.gd::INSTALL_BACKUP_SUFFIX`. Inlined as a
|
||||
## literal because old two-phase runners can parse this diagnostic script
|
||||
## against stale runner Script-object content during their mixed-snapshot
|
||||
## scan. `test_update_backup_suffix_stays_in_sync` guards against drift.
|
||||
const BACKUP_SUFFIX := ".update_backup"
|
||||
## Cap so a runaway addons tree (someone parented the wrong dir, an old
|
||||
## crashed install left thousands of artifacts) can't blow the
|
||||
## `editor_state` payload size or freeze the editor on first paint.
|
||||
const MAX_BACKUP_RESULTS := 200
|
||||
## TTL for the `diagnose()` cache. `editor_state` is one of the highest-
|
||||
## traffic MCP tools (agents poll it constantly) and a recursive
|
||||
## `DirAccess` walk on every call would put I/O on the 4ms `_process()`
|
||||
## budget. Mixed-state is rare and persistent across editor restarts, so
|
||||
## a few seconds of staleness is acceptable; the dock's Re-scan button
|
||||
## bypasses the cache via `force=true` for immediate feedback.
|
||||
const CACHE_TTL_MSEC := 5000
|
||||
|
||||
static var _cache_value: Dictionary = {}
|
||||
static var _cache_timestamp_msec: int = -1
|
||||
|
||||
|
||||
## Walk `dir` recursively and return every `res://`-relative path that ends
|
||||
## in `.update_backup`, sorted ascending. Truncates at `MAX_BACKUP_RESULTS`
|
||||
## — the truncation flag is exposed via `diagnose()`.
|
||||
##
|
||||
## Walk order is deterministic: entries within each directory are sorted
|
||||
## alphabetically, subdirs pushed reverse-sorted so DFS pops them in
|
||||
## ascending order. Without this two scans of the same mixed tree could
|
||||
## return different 200-file slices when truncation kicks in (Godot's
|
||||
## `list_dir` order isn't guaranteed stable across filesystems).
|
||||
static func find_backups(dir: String = ADDON_DIR) -> Array:
|
||||
var results: Array = []
|
||||
var stack: Array = [dir]
|
||||
while not stack.is_empty():
|
||||
if results.size() >= MAX_BACKUP_RESULTS:
|
||||
break
|
||||
var current: String = stack.pop_back()
|
||||
var d := DirAccess.open(current)
|
||||
## Missing dir, permission error, or unreadable junction — skip
|
||||
## silently. A missing addons dir is the bare-clone case; mid-walk
|
||||
## errors stay quiet so a single permission glitch can't block the
|
||||
## diagnostic the rest of the scan would have produced.
|
||||
if d == null:
|
||||
continue
|
||||
var entries: Array = []
|
||||
d.list_dir_begin()
|
||||
while true:
|
||||
var entry := d.get_next()
|
||||
if entry.is_empty():
|
||||
break
|
||||
if entry == "." or entry == "..":
|
||||
continue
|
||||
entries.append({"name": entry, "is_dir": d.current_is_dir()})
|
||||
d.list_dir_end()
|
||||
entries.sort_custom(func(a, b): return a["name"] < b["name"])
|
||||
## Push subdirs reverse-sorted so the next outer iteration pops
|
||||
## them in ascending order — see method docstring for why this
|
||||
## determinism matters for the truncated case.
|
||||
for i in range(entries.size() - 1, -1, -1):
|
||||
var entry: Dictionary = entries[i]
|
||||
if entry["is_dir"]:
|
||||
stack.append(current.path_join(entry["name"]))
|
||||
for entry in entries:
|
||||
if entry["is_dir"]:
|
||||
continue
|
||||
if not String(entry["name"]).ends_with(BACKUP_SUFFIX):
|
||||
continue
|
||||
results.append(current.path_join(entry["name"]))
|
||||
if results.size() >= MAX_BACKUP_RESULTS:
|
||||
break
|
||||
results.sort()
|
||||
return results
|
||||
|
||||
|
||||
## Build the structured diagnostic Dictionary surfaced via `editor_state`
|
||||
## and the dock banner. Empty when the addons tree is clean — callers
|
||||
## gate banner visibility / response field on `is_empty()`.
|
||||
##
|
||||
## Cached for `CACHE_TTL_MSEC` when scanning the default `ADDON_DIR` so
|
||||
## per-`editor_state` polls don't re-walk the addons tree every frame.
|
||||
## Tests passing a custom `dir` always see a fresh scan (cache only
|
||||
## tracks the production path). `force=true` bypasses the cache — used
|
||||
## by the dock's Re-scan button so a manual fix is reflected immediately.
|
||||
static func diagnose(dir: String = ADDON_DIR, force: bool = false) -> Dictionary:
|
||||
var use_cache := dir == ADDON_DIR and not force
|
||||
if use_cache and _cache_timestamp_msec >= 0:
|
||||
if Time.get_ticks_msec() - _cache_timestamp_msec < CACHE_TTL_MSEC:
|
||||
return _cache_value.duplicate(true)
|
||||
|
||||
var backups := find_backups(dir)
|
||||
var result: Dictionary = {}
|
||||
if not backups.is_empty():
|
||||
## Most commonly produced by `_rollback_paths_written` returning
|
||||
## FAILED_MIXED, but `_finalize_install_success` removes backups on
|
||||
## a best-effort basis so a successful install can also leave them
|
||||
## behind if the cleanup `remove_absolute` hit a permission error.
|
||||
## The recovery action — delete the *.update_backup files — is the
|
||||
## same in both cases, so the message acknowledges both
|
||||
## possibilities rather than asserting the alarming one.
|
||||
result = {
|
||||
"addon_dir": dir,
|
||||
"backup_files": backups,
|
||||
"backup_count": backups.size(),
|
||||
"truncated": backups.size() >= MAX_BACKUP_RESULTS,
|
||||
"message": (
|
||||
"Found .update_backup files in addons/godot_ai/. This usually"
|
||||
+ " means a self-update rollback couldn't restore the previous"
|
||||
+ " addon contents (FAILED_MIXED) — the plugin may load a mix"
|
||||
+ " of old and new files. Restore the addon from your VCS or a"
|
||||
+ " fresh release ZIP, then delete the listed *.update_backup"
|
||||
+ " files. If the plugin runs without issues these are likely"
|
||||
+ " stale from a successful install and safe to delete."
|
||||
),
|
||||
}
|
||||
if use_cache:
|
||||
_cache_value = result.duplicate(true)
|
||||
_cache_timestamp_msec = Time.get_ticks_msec()
|
||||
return result
|
||||
|
||||
|
||||
## Reset the `diagnose()` cache. Tests that flip the addons-tree state
|
||||
## between calls use this to avoid TTL-bound flakiness; the dock's
|
||||
## Re-scan button uses `force=true` instead.
|
||||
static func clear_cache() -> void:
|
||||
_cache_value = {}
|
||||
_cache_timestamp_msec = -1
|
||||
Reference in New Issue
Block a user