fix(client): bake the version into the build, so an export can invalidate its cache (T-1241)
current_schema_version() line-scanned res://../project.yaml at runtime. That resolves to the repo root in a dev run and to nothing in an exported build, so a shipped game got the "?.?.?" fallback every time. Since that tag is the Atlas disk cache's ONLY invalidation signal, every exported build stamped and compared the same sentinel: a canvas cached by one build would be served by every later build, forever. T-1239 is what that failure looks like once it happens. loading_screen.gd carried a byte-for-byte copy of the same function, so the version shown to the player was "?.?.?" in exactly the builds where a version string is worth showing. Both call sites now share client/scripts/build_version.gd, which reads application/config/version out of ProjectSettings — a value Godot bakes into the PCK, identical in the editor and in an export by construction rather than by luck. No file IO, no fallback branch. project.yaml stays the source of truth (CLAUDE.md); client/project.godot mirrors it. A mirror nobody checks would be worse than the bug it replaces -- the old code failed loudly everywhere, a stale mirror fails silently -- so tooling/check-client-version compares the two and the pre-push hook runs it unconditionally. Not gated on "were those files in this push": drift persists on main once introduced, and gating would let an existing drift ride along. The test this replaces asserted that current_schema_version() did not return its fallback, and passed -- in the one environment where the code under test worked. Three tests now pin the property that actually matters: a real version, sourced from the baked setting, matching project.yaml. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,13 @@ config_version=5
|
||||
[application]
|
||||
|
||||
config/name="The Settled Reach"
|
||||
; Mirror of project.yaml's `version:` — the repo root file stays the source of
|
||||
; truth (CLAUDE.md), this is the copy that survives an export. Godot bakes
|
||||
; ProjectSettings into the PCK, so `application/config/version` reads the same
|
||||
; in the editor and in a shipped build, where res://../project.yaml does not
|
||||
; exist at all (T-1241). Kept honest by `make check-client-version`, which the
|
||||
; pre-push hook runs — do not edit this by hand without moving project.yaml too.
|
||||
config/version="0.4.6"
|
||||
run/main_scene="res://scenes/main_menu.tscn"
|
||||
config/features=PackedStringArray("4.6", "GL Compatibility")
|
||||
config/icon="res://icon.svg"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
extends RefCounted
|
||||
|
||||
## The running client's version string — the ONE place anything asks (T-1241).
|
||||
##
|
||||
## Reads `application/config/version` out of ProjectSettings, which Godot bakes
|
||||
## into the exported PCK. That is the whole point: two call sites previously
|
||||
## line-scanned `res://../project.yaml` at runtime, which resolves to the repo
|
||||
## root in a dev run and TO NOTHING in an exported build — both returned their
|
||||
## "?.?.?" fallback in a shipped game.
|
||||
##
|
||||
## For the Atlas disk cache (D-255, step_canvas_disk_cache.gd) that was not a
|
||||
## cosmetic defect: the version tag is that cache's ONLY invalidation signal, so
|
||||
## a constant sentinel meant every exported build stamped and compared the same
|
||||
## string and a canvas cached by one build would be served by every later build,
|
||||
## forever. T-1239 is what that failure looks like when it happens — eight days
|
||||
## of a map drawn from a canvas whose generating code no longer existed.
|
||||
##
|
||||
## No file IO and no fallback branch, deliberately. A missing setting returns ""
|
||||
## and callers can see that; there is no environment where this reads one thing
|
||||
## in the editor and another in an export, which is exactly the property the
|
||||
## project.yaml scan lacked.
|
||||
##
|
||||
## `project.yaml` remains the source of truth for the version (CLAUDE.md); the
|
||||
## ProjectSettings entry is a mirror, and `tooling/check-client-version` fails
|
||||
## the push if the two drift.
|
||||
|
||||
const SETTING_KEY: String = "application/config/version"
|
||||
|
||||
|
||||
static func current() -> String:
|
||||
return str(ProjectSettings.get_setting(SETTING_KEY, ""))
|
||||
@@ -370,14 +370,65 @@ func test_mismatched_schema_version_has_reports_false() -> void:
|
||||
assert_bool(cache.has("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))).is_false()
|
||||
|
||||
|
||||
func test_current_schema_version_reads_project_yaml() -> void:
|
||||
# project.yaml's version field is "0.4.0" per CLAUDE.md at time of
|
||||
# writing — assert only that a non-placeholder value comes back (not the
|
||||
# exact string, so this test doesn't need updating every version bump).
|
||||
## T-1241. The predecessor of this test asserted that current_schema_version()
|
||||
## did not return its "?.?.?" fallback — and passed, in the ONE environment
|
||||
## where the code under test worked. It read res://../project.yaml, which exists
|
||||
## in a dev run and never in an exported build, so the shipped game got the
|
||||
## fallback for every build, the cache compared a constant sentinel against
|
||||
## itself, and nothing was ever invalidated. A green test said otherwise.
|
||||
##
|
||||
## The version now comes from ProjectSettings, which Godot bakes into the PCK, so
|
||||
## the dev and exported answers are the same value by construction rather than by
|
||||
## luck. These tests assert that construction: a real version, sourced from the
|
||||
## setting, with no file lookup left to differ between environments.
|
||||
func test_current_schema_version_is_a_real_version_not_the_fallback() -> void:
|
||||
var v: String = StepCanvasDiskCache.current_schema_version()
|
||||
assert_str(v).override_failure_message(
|
||||
"current_schema_version() must read a real value from project.yaml, not the '?' fallback"
|
||||
"current_schema_version() must return a real version, never the '?.?.?' fallback"
|
||||
).is_not_equal("?.?.?")
|
||||
assert_str(v).override_failure_message(
|
||||
"current_schema_version() must not be empty — the ProjectSettings key is missing"
|
||||
).is_not_empty()
|
||||
|
||||
|
||||
func test_current_schema_version_comes_from_project_settings() -> void:
|
||||
# The property that makes this export-safe: the value IS the baked setting,
|
||||
# not something re-derived from a file that an export would not contain.
|
||||
var setting: String = str(ProjectSettings.get_setting("application/config/version", ""))
|
||||
assert_str(setting).override_failure_message(
|
||||
"application/config/version must be set in client/project.godot — it is what "
|
||||
+ "survives an export, and the Atlas disk cache's only invalidation signal"
|
||||
).is_not_empty()
|
||||
assert_str(StepCanvasDiskCache.current_schema_version()).is_equal(setting)
|
||||
|
||||
|
||||
func test_baked_version_mirrors_project_yaml() -> void:
|
||||
# Drift guard, dev-only by nature (an export has no project.yaml to compare
|
||||
# against — which is the whole reason the value is baked). project.yaml stays
|
||||
# the source of truth; this catches the bump-one-forget-the-other case from
|
||||
# inside the suite, as well as from tooling/check-client-version in the hook.
|
||||
var yaml_path := ProjectSettings.globalize_path("res://") + "/../project.yaml"
|
||||
if not FileAccess.file_exists(yaml_path):
|
||||
return # exported/packaged run — nothing to compare, and nothing to guard
|
||||
var file := FileAccess.open(yaml_path, FileAccess.READ)
|
||||
assert_object(file).is_not_null()
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var yaml_version := ""
|
||||
for line: String in content.split("\n"):
|
||||
if line.begins_with("version:"):
|
||||
var parts := line.split(":", false, 1)
|
||||
if parts.size() >= 2:
|
||||
yaml_version = parts[1].strip_edges()
|
||||
break
|
||||
assert_str(yaml_version).override_failure_message(
|
||||
"project.yaml must carry a `version:` line — it is the source of truth"
|
||||
).is_not_empty()
|
||||
assert_str(StepCanvasDiskCache.current_schema_version()).override_failure_message(
|
||||
"client/project.godot's config/version has drifted from project.yaml. "
|
||||
+ "Fix with the value in project.yaml; see `make check-client-version`."
|
||||
).is_equal(yaml_version)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -96,6 +96,10 @@ extends RefCounted
|
||||
## comment.
|
||||
|
||||
const StepCanvasCache := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_cache.gd")
|
||||
## preload, not a `class_name` reference: this script is reachable from the
|
||||
## ImplantRegistry autoload's preload graph, and CLAUDE.md's autoload parse-order
|
||||
## rule makes a bare class_name unsafe there.
|
||||
const BuildVersion := preload("res://scripts/build_version.gd")
|
||||
|
||||
## Tier tags, mirroring stig-round2.md's IndexEntry.tier field exactly.
|
||||
const TIER_GEOMETRY: String = "Geometry"
|
||||
@@ -167,21 +171,14 @@ var _root: String = DEFAULT_ROOT
|
||||
var _indexes: Dictionary = {} # body_id String -> (key String -> IndexEntry Dictionary)
|
||||
|
||||
|
||||
## T-1241: this used to line-scan `res://../project.yaml` itself. That path
|
||||
## resolves to the repo root in a dev run and to nothing in an exported build, so
|
||||
## a shipped game got the "?.?.?" fallback — and since this tag is the cache's
|
||||
## only invalidation signal, EVERY exported build stamped and compared the same
|
||||
## sentinel, making the cache permanently un-invalidatable. The version now comes
|
||||
## from ProjectSettings, which Godot bakes into the PCK. See build_version.gd.
|
||||
static func current_schema_version() -> String:
|
||||
var yaml_path := ProjectSettings.globalize_path("res://") + "/../project.yaml"
|
||||
if not FileAccess.file_exists(yaml_path):
|
||||
return "?.?.?"
|
||||
var file := FileAccess.open(yaml_path, FileAccess.READ)
|
||||
if file == null:
|
||||
return "?.?.?"
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
for line: String in content.split("\n"):
|
||||
if line.begins_with("version:"):
|
||||
var parts := line.split(":", false, 1)
|
||||
if parts.size() >= 2:
|
||||
return parts[1].strip_edges()
|
||||
return "?.?.?"
|
||||
return BuildVersion.current()
|
||||
|
||||
|
||||
## `root` is injectable so tests never touch the real
|
||||
|
||||
@@ -2,7 +2,11 @@ extends MetaScreen
|
||||
## #257: Loading screen overlay — blocks input during save/load round-trip.
|
||||
## Shown when LOAD_GAME fires; hidden when save_result arrives (success or failure).
|
||||
## Full-screen, dark overlay with centered status text.
|
||||
## #724: Shows client version (from project.yaml) and protocol version below the status text.
|
||||
## #724: Shows client version and protocol version below the status text. The
|
||||
## version comes from ProjectSettings (mirrored from project.yaml) so it survives
|
||||
## an export — see build_version.gd (T-1241).
|
||||
|
||||
const BuildVersion := preload("res://scripts/build_version.gd")
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 1.0)
|
||||
const TEXT_COLOR := Color("#c8d0e0")
|
||||
@@ -55,21 +59,19 @@ func _build_ui() -> void:
|
||||
add_child(_version_label)
|
||||
|
||||
|
||||
## T-1241: was a byte-for-byte copy of the disk cache's own project.yaml scan,
|
||||
## with the same export defect — a shipped build has no res://../project.yaml, so
|
||||
## the version shown to the player read "?.?.?" in exactly the builds where a
|
||||
## version string is worth showing. Both call sites now share build_version.gd.
|
||||
##
|
||||
## The display fallback stays HERE rather than in the accessor: BuildVersion
|
||||
## returns "" for a missing setting and does not invent a value, because its
|
||||
## other caller keys cache invalidation on it and a plausible-looking sentinel
|
||||
## there is what made this bug survive. A label, unlike a cache key, still has to
|
||||
## render something, so it substitutes its own.
|
||||
func _read_client_version() -> String:
|
||||
var yaml_path := ProjectSettings.globalize_path("res://") + "/../project.yaml"
|
||||
if not FileAccess.file_exists(yaml_path):
|
||||
return "?.?.?"
|
||||
var file := FileAccess.open(yaml_path, FileAccess.READ)
|
||||
if file == null:
|
||||
return "?.?.?"
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
for line: String in content.split("\n"):
|
||||
if line.begins_with("version:"):
|
||||
var parts := line.split(":", false, 1)
|
||||
if parts.size() >= 2:
|
||||
return parts[1].strip_edges()
|
||||
return "?.?.?"
|
||||
var v := BuildVersion.current()
|
||||
return v if not v.is_empty() else "?.?.?"
|
||||
|
||||
|
||||
## Update the status text shown while loading. Call before show_loading() or after.
|
||||
|
||||
Reference in New Issue
Block a user