gdharness 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/cli.js CHANGED
@@ -15992,7 +15992,7 @@ var init_tool_definitions = __esm(() => {
15992
15992
  requires: ["projectPath"],
15993
15993
  operations: {
15994
15994
  status: {
15995
- summary: "which resources are outdated or failed, or one resource with resourcePath",
15995
+ summary: "which resources are outdated or failed, or one resource with resourcePath: a source changed since its import, an import whose output under .godot/imported is gone (missing_outputs), and a glTF scene imported before the images it uses (imported_before), each with its reason",
15996
15996
  requires: []
15997
15997
  },
15998
15998
  options: { summary: "the import options of one resource", requires: ["resourcePath"] },
@@ -41842,13 +41842,23 @@ class GodotServer {
41842
41842
  if (!declined || Date.now() - started >= timeoutMs) {
41843
41843
  break;
41844
41844
  }
41845
+ await new Promise((settle) => setTimeout(settle, 100));
41845
41846
  }
41846
41847
  let busy = true;
41847
- while (busy && Date.now() - started < timeoutMs) {
41848
+ let idleSince = null;
41849
+ while (Date.now() - started < timeoutMs) {
41848
41850
  await new Promise((settle) => setTimeout(settle, 100));
41849
41851
  const status = asParams(await this.godotBridge.invokeTool("rescan_filesystem", { ...args, statusOnly: true }));
41850
41852
  const pending = Boolean(status["pending"]) && Date.now() - started < SCAN_START_MS;
41851
41853
  busy = Boolean(status["scanning"]) || Boolean(status["importing"]) || pending;
41854
+ if (busy) {
41855
+ idleSince = null;
41856
+ } else {
41857
+ idleSince ??= Date.now();
41858
+ if (Date.now() - idleSince >= SCAN_SETTLED_MS) {
41859
+ break;
41860
+ }
41861
+ }
41852
41862
  }
41853
41863
  if (!busy && projectPath !== "" && (atRisk.length > 0 || ghosts.length > 0 || blind.unchecked !== undefined)) {
41854
41864
  const until = Date.now() + CACHE_WRITE_MS;
@@ -42318,7 +42328,7 @@ async function runGodotServer() {
42318
42328
  const server = new GodotServer;
42319
42329
  await server.run();
42320
42330
  }
42321
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run7, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, STOP_WAIT_MS = 1e4, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, SCAN_WAIT_MS = 30000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
42331
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run7, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, STOP_WAIT_MS = 1e4, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, SCAN_WAIT_MS = 30000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, SCAN_SETTLED_MS = 500, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
42322
42332
  var init_server3 = __esm(() => {
42323
42333
  init_mcp();
42324
42334
  init_stdio2();
@@ -11,12 +11,10 @@ var _editor_plugin: EditorPlugin = null
11
11
  var _values: Serialisation = Serialisation.new()
12
12
  var _properties: PropertyValues = PropertyValues.new()
13
13
 
14
- ## How many scans the editor has finished, counted off its own signal, and what the count was when
15
- ## a scan was last asked for. A queued scan has not started, so the flags the engine offers read
16
- ## the same as one that is over; the count is what tells the two apart.
14
+ ## How many times the editor has said its filesystem changed, which a scan run on the main thread
15
+ ## does before `scan()` returns: counted so a rescan can tell a scan that ran there from one the
16
+ ## editor declined.
17
17
  var _scans_finished: int = 0
18
- var _scans_finished_at_request: int = 0
19
- var _scan_pending: bool = false
20
18
 
21
19
  ## When the last scan finished, on the engine's clock, or -1 before the first. The editor writes
22
20
  ## the class cache a frame after it stops reporting a scan, measured on 4.7.2, so a game started
@@ -36,7 +34,6 @@ func set_editor_plugin(plugin: EditorPlugin) -> void:
36
34
 
37
35
  func _scan_finished() -> void:
38
36
  _scans_finished += 1
39
- _scan_pending = false
40
37
  _scan_finished_at_msec = Time.get_ticks_msec()
41
38
 
42
39
 
@@ -662,31 +659,24 @@ func rescan_filesystem(args: Dictionary) -> Dictionary:
662
659
  var busy: bool = filesystem.is_scanning() or filesystem.is_importing()
663
660
  var started: bool = false
664
661
  if not Read.as_bool(args.get("statusOnly", false)) and not busy:
665
- _scans_finished_at_request = _scans_finished
666
- _scan_pending = true
662
+ var finished_before: int = _scans_finished
667
663
  filesystem.scan()
668
- started = true
664
+ # Whether the scan ran, read off the editor rather than assumed. `scan()` returns without
665
+ # a word while the thread of the scan before is still to be joined, which is a frame or
666
+ # more after that scan stops reporting itself, and that scan then signals as it is joined.
667
+ # Taken as started, the answer came from a scan begun before the files were written:
668
+ # downstream, a rescan after 698 files were rewritten answered finished in 1.5s and the
669
+ # one changed file stayed on its old import. A threaded scan reports itself from inside
670
+ # the call, and one run on this thread has finished and signalled before it returns.
671
+ started = filesystem.is_scanning() or _scans_finished != finished_before
669
672
 
670
673
  # Importing is reported separately from scanning, and a class is not registered until
671
674
  # both are done, so a caller watching only one of them can look too early.
672
- #
673
- # `pending` is the third state, and it is the one that was missing. A scan is queued rather
674
- # than run, so for the first frames after asking, both flags are false and the editor has not
675
- # started: a caller polling them reads "finished" off a scan that has not begun. Measured, a
676
- # headless editor of 400 scripts takes 1619ms and answered honestly, while a downstream editor
677
- # of 376 classes answered finished 296ms in and then wrote its own stale class list over the
678
- # cache that had just been corrected. Whoever reads this is waiting for the scan to have
679
- # landed, so "not yet" and "over" must not be spelled the same.
680
- var scanning: bool = filesystem.is_scanning()
681
- var importing: bool = filesystem.is_importing()
682
- if scanning or importing or _scans_finished != _scans_finished_at_request:
683
- _scan_pending = false
684
675
  return {
685
676
  "ok": true,
686
677
  "started": started,
687
- "scanning": scanning,
688
- "importing": importing,
689
- "pending": _scan_pending,
678
+ "scanning": filesystem.is_scanning(),
679
+ "importing": filesystem.is_importing(),
690
680
  "scansFinished": _scans_finished,
691
681
  }
692
682
 
@@ -704,7 +694,6 @@ func scan_status(_args: Dictionary) -> Dictionary:
704
694
  "ok": true,
705
695
  "scanning": filesystem.is_scanning(),
706
696
  "importing": filesystem.is_importing(),
707
- "pending": _scan_pending,
708
697
  "sinceScanFinishedMs":
709
698
  -1 if _scan_finished_at_msec < 0 else Time.get_ticks_msec() - _scan_finished_at_msec,
710
699
  }
@@ -380,12 +380,106 @@ func _import_status_of(resource_path: String, import_file_path: String) -> Dicti
380
380
  "source_exists": true
381
381
  }
382
382
 
383
- var source_modified: int = FileAccess.get_modified_time(resource_path)
384
- var import_modified: int = FileAccess.get_modified_time(import_file_path)
385
-
386
- return {
387
- "path": resource_path,
388
- "status": "needs_reimport" if source_modified > import_modified else "up_to_date",
389
- "import_file_exists": true,
390
- "source_exists": true
383
+ var status: Dictionary = {
384
+ "path": resource_path, "status": "up_to_date", "import_file_exists": true, "source_exists": true
391
385
  }
386
+ if FileAccess.get_modified_time(resource_path) > FileAccess.get_modified_time(import_file_path):
387
+ status["status"] = "needs_reimport"
388
+ status["reason"] = "the source changed after it was imported"
389
+ return status
390
+
391
+ # The sidecar alone does not say the import is there: deleting the outputs under
392
+ # .godot/imported is the ordinary way to force one, and a resource whose output is gone was
393
+ # answered as current, which is what a reimport is then skipped on.
394
+ var outputs: Array[String] = _outputs_of(import_file_path)
395
+ var missing: Array[String] = []
396
+ for output: String in outputs:
397
+ if not FileAccess.file_exists(output):
398
+ missing.append(output)
399
+ if not missing.is_empty():
400
+ status["status"] = "needs_reimport"
401
+ status["reason"] = "its imported output is not on disk"
402
+ status["missing_outputs"] = missing
403
+ return status
404
+
405
+ # A scene imported before the textures it uses were: it loads each image as it imports, so one
406
+ # imported ahead of them is built without them, and nothing about its own files says so. A second
407
+ # reimport started over one in progress did exactly that downstream, and 64 scenes that were
408
+ # untextured all read as up to date here.
409
+ var built_at: int = _oldest(outputs, import_file_path)
410
+ var before: Array[String] = []
411
+ for image: String in _images_of(resource_path):
412
+ if FileAccess.file_exists(image + ".import"):
413
+ if _newest(_outputs_of(image + ".import"), image + ".import") > built_at:
414
+ before.append(image)
415
+ if not before.is_empty():
416
+ status["status"] = "needs_reimport"
417
+ status["reason"] = "it was imported before the images it uses, so it was built without them"
418
+ status["imported_before"] = before
419
+ return status
420
+
421
+
422
+ ## The files an import wrote, from its sidecar's [code]dest_files[/code]; empty when it lists none.
423
+ static func _outputs_of(import_file_path: String) -> Array[String]:
424
+ var config: ConfigFile = ConfigFile.new()
425
+ var outputs: Array[String] = []
426
+ if config.load(import_file_path) != OK:
427
+ return outputs
428
+ var listed: Variant = config.get_value("deps", "dest_files", [])
429
+ if listed is Array:
430
+ for one: Variant in listed:
431
+ outputs.append(str(one))
432
+ return outputs
433
+
434
+
435
+ ## When the earliest of [param outputs] was written, or [param fallback] when none is on disk.
436
+ static func _oldest(outputs: Array[String], fallback: String) -> int:
437
+ var oldest: int = -1
438
+ for output: String in outputs:
439
+ if FileAccess.file_exists(output):
440
+ var at: int = FileAccess.get_modified_time(output)
441
+ oldest = at if oldest < 0 else mini(oldest, at)
442
+ return oldest if oldest >= 0 else FileAccess.get_modified_time(fallback)
443
+
444
+
445
+ ## When the latest of [param outputs] was written, or [param fallback] when none is on disk.
446
+ static func _newest(outputs: Array[String], fallback: String) -> int:
447
+ var newest: int = -1
448
+ for output: String in outputs:
449
+ if FileAccess.file_exists(output):
450
+ newest = maxi(newest, FileAccess.get_modified_time(output))
451
+ return newest if newest >= 0 else FileAccess.get_modified_time(fallback)
452
+
453
+
454
+ ## The image files a glTF scene refers to by path, resolved against the scene's own directory.
455
+ ## Embedded images and every other scene format answer nothing.
456
+ static func _images_of(scene_path: String) -> Array[String]:
457
+ var images: Array[String] = []
458
+ var text: String = ""
459
+ match scene_path.get_extension().to_lower():
460
+ "gltf":
461
+ text = FileAccess.get_file_as_string(scene_path)
462
+ "glb":
463
+ # A 12-byte header, then the first chunk: its length, its type, and for "JSON" the text.
464
+ var bytes: PackedByteArray = FileAccess.get_file_as_bytes(scene_path)
465
+ if bytes.size() >= 20 and bytes.decode_u32(16) == 0x4E4F534A:
466
+ var length: int = bytes.decode_u32(12)
467
+ text = bytes.slice(20, mini(20 + length, bytes.size())).get_string_from_utf8()
468
+ if text.is_empty():
469
+ return images
470
+ var parsed: Variant = JSON.parse_string(text)
471
+ if not parsed is Dictionary:
472
+ return images
473
+ var document: Dictionary = parsed
474
+ var listed: Variant = document.get("images", [])
475
+ if not listed is Array:
476
+ return images
477
+ for image: Variant in listed:
478
+ if not image is Dictionary:
479
+ continue
480
+ var entry: Dictionary = image
481
+ var uri: String = str(entry.get("uri", ""))
482
+ if uri.is_empty() or uri.begins_with("data:"):
483
+ continue
484
+ images.append(scene_path.get_base_dir().path_join(uri.uri_decode()).simplify_path())
485
+ return images
package/build/index.js CHANGED
@@ -30118,7 +30118,7 @@ var TOOL_SPECS = [
30118
30118
  requires: ["projectPath"],
30119
30119
  operations: {
30120
30120
  status: {
30121
- summary: "which resources are outdated or failed, or one resource with resourcePath",
30121
+ summary: "which resources are outdated or failed, or one resource with resourcePath: a source changed since its import, an import whose output under .godot/imported is gone (missing_outputs), and a glTF scene imported before the images it uses (imported_before), each with its reason",
30122
30122
  requires: []
30123
30123
  },
30124
30124
  options: { summary: "the import options of one resource", requires: ["resourcePath"] },
@@ -31475,6 +31475,7 @@ function scanWaitAnswer(scanned) {
31475
31475
  }
31476
31476
  var PLAYING_STATUS_MS = 1000;
31477
31477
  var SCAN_START_MS = 2000;
31478
+ var SCAN_SETTLED_MS = 500;
31478
31479
  var WAIT_FOR_RUN_MS = 600000;
31479
31480
  var RUN_POLL_MS = 250;
31480
31481
  var PATH_SOLUTIONS = [
@@ -34579,13 +34580,23 @@ class GodotServer {
34579
34580
  if (!declined || Date.now() - started >= timeoutMs) {
34580
34581
  break;
34581
34582
  }
34583
+ await new Promise((settle) => setTimeout(settle, 100));
34582
34584
  }
34583
34585
  let busy = true;
34584
- while (busy && Date.now() - started < timeoutMs) {
34586
+ let idleSince = null;
34587
+ while (Date.now() - started < timeoutMs) {
34585
34588
  await new Promise((settle) => setTimeout(settle, 100));
34586
34589
  const status = asParams(await this.godotBridge.invokeTool("rescan_filesystem", { ...args, statusOnly: true }));
34587
34590
  const pending = Boolean(status["pending"]) && Date.now() - started < SCAN_START_MS;
34588
34591
  busy = Boolean(status["scanning"]) || Boolean(status["importing"]) || pending;
34592
+ if (busy) {
34593
+ idleSince = null;
34594
+ } else {
34595
+ idleSince ??= Date.now();
34596
+ if (Date.now() - idleSince >= SCAN_SETTLED_MS) {
34597
+ break;
34598
+ }
34599
+ }
34589
34600
  }
34590
34601
  if (!busy && projectPath !== "" && (atRisk.length > 0 || ghosts.length > 0 || blind.unchecked !== undefined)) {
34591
34602
  const until = Date.now() + CACHE_WRITE_MS;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",