magicciv/.project/objectives/p2-72a-gdgamestate-canonical-render-source.md

27 KiB
Raw Permalink Blame History

id title priority status scope category owner created updated_at blocked_by follow_ups evidence
p2-72a Make `GdGameState` the canonical render source p2 done game1 architecture simulator-infra 2026-05-12 2026-06-23
p2-72
p2-67
mc-state extraction (p2-65) + building port (p2-72a-building-entity-port partial) + p2-72b city slot landed provide foundation; but PlayerScript/UnitScript/GameMap still fat classes, no full accessor surface on GdGameState for render migration (static read 2026-06-23)
src/simulator/crates/mc-state/src/game_state.rs now canonical for GameState used by Gd (verified)
UI side: player_api_main.gd + main scenes still read GameState autoload (p2-72/67 render bridge pending); world_map_hud/city etc delegate to it until GdGameState canonical migration (400+ accesses)
p2-72a stub per save-format hard stop + migration obj; building partial done (array removal, Rust mirror sole); UI surfaces read via thin views (Building.gd ephemeral)
player_api.rs uses mc_state directly; gap Rust + GDScript render migration (not UI polish fence)

Foundation primitive VERIFIED (2026-06-08) — bridge round-trips headless

The Rust-authoritative render primitive is proven and CI-gated: GdPlayerApi holds an mc_state::GameState as source-of-truth, projects a fog-aware PlayerView via view_json(slot), and the projection round-trips faithfully. New headless GUT test src/game/engine/tests/integration/test_player_api_projection_roundtrip.gd (2/2 pass on apricot): a loaded state's units slice (ids, type, [col,row] positions) re-emerges through view_json, and the view is LIVE (turn advances after an end_turn action mutates the held state). The end-to-end render path already exists as a proof scene (claude_vs_ai_render_proof.gd: "GdPlayerApi is the source of truth; renderers driven from view_json"). So the bridge + the projection are not the blocker.

This does NOT unblock the objective. p2-72a remains blocked on (a) the save-format decision below — yours to make (option (i) Rust-owns-serialization vs (ii) decoupled save shape) — and (b) the 82-file / 400-access migration the Wave-1 audit found. The verified primitive is the safe building block the eventual migration sits on; it is regression-guarded now.

STATUS — Wave 1 hard-stop (2026-05-11)

Status flipped open → blocked. Wave 1 audit tripped the spec's named save/load hard-stop. Details in ## Wave 1 audit below.

Follow-up (2026-05-11 second pass): user locked option (i) "Rust owns serialization" in the brief, but a deeper Wave-1 audit of every emitted field surfaced three independent scope walls (Game 2/3 magic/ascension/ley live in Game 1 GDScript runtime; presentation-layer player fields would land in mc-turn::PlayerState; structural layers/npc_buildings/game_settings/game_rng/diplomacy have no Rust home). Each requires a scope decision the brief did not anticipate. Full gap matrix + recommended sequencing in p2-72a-save-format-migration.md § Wave 1 audit — option (i) gap matrix. Status of that objective is also blocked pending the per-wall decisions.

Spec text invoked:

"Save/load format depends on PlayerScript/GameMap serialisation as the canonical save shape → STOP, document, exit (save migration is its own objective)."

Unblocks needed before Wave 2 can start:

  1. New objective p2-72a-save-format-migration — define the new on-disk save format that does not depend on PlayerScript.serialize() / GameMap.to_dict() / Building.to_dict() / Tile.to_dict() as the canonical shape. Migrate SaveManager, write a save-format-version field, port test_save_manager.gd + test_save_load_round_trip.gd to assert against the new shape. Either:
    • (i) Save the GdGameState snapshot directly via a new GdGameState::serialize_full() -> Dictionary accessor, or
    • (ii) Keep an intermediate save shape but decouple it from the live-runtime GDScript classes so the latter can be views without affecting save round-trip.
  2. Decision required from user: option (i) vs (ii) above. Option (i) is cleaner long-term but requires a one-time save-version bump + load-migration shim for any pre-migration saves. Option (ii) preserves existing save files but leaves serialisation logic in two places.

Wave 1 audit (2026-05-11)

Scope finding: 82 files, 400 raw access points (vs spec estimate of ~15 files for renderer migration). Bucketed:

Bucket File count Notes
Renderer / HUD / UI / menus 18 Spec's actual target. world_map.gd, hud/*.gd, menus/*.gd, overviews/*.gd, encyclopedia_panel.gd, statistics.gd.
Proof scenes (scenes/tests/) 16 Seed GameState.players = [...] via PlayerScript.new(...). Test-harness migration — different problem from renderer migration.
Unit/integration tests (tests/) 24 Same shape as proof scenes. Some assert on save-format shape (test_save_manager.gd).
Engine logic (autoloads, modules, world, map) 24 Read and write — AI, combat utils, turn processor, victory manager, diplomacy, tech web, selection, entity finder, etc. Gnarliest tier; not renderer-only.
Total 82

File list source: grep -rn 'GameState\.players\|GameState\.layers\|GameState\.npc_buildings\|GameState\.diplomacy\|GameState\.ley_anchors\|GameState\.wonders_built' src/game/engine/ (full list captured in PR notes).

Hard-stop evidence — save format depends on PlayerScript/GameMap shape

  • src/game/engine/src/autoloads/game_state.gd:337-372GameState.serialize() builds the save dict by calling:
    • player.serialize() for each PlayerScript in players[] (line 365)
    • _serialize_layer(layer) which calls map_ref.to_dict() where map_ref is GameMapScript (line 431)
    • _serialize_npc_buildings() which calls BuildingScript.to_dict() (helpers line 45)
    • _serialize_ley_anchors()
  • src/game/engine/src/entities/player.gd:206-239+Player.serialize() returns a 30+ field shape including happiness_breakdown, golden_age_active/turns/progress/count, growth_tier, culture_per_turn, traded_luxuries, gender_preset, color, gold_per_turn, full cities[].to_save_dict() and units[].to_save_dict(). This is the canonical save shape.
  • src/game/engine/src/entities/player.gd:189-203Player.to_bridge_dict() returns a deliberately smaller subset (index, race_id, is_human, gold, happiness, researched_techs, schools) that the existing GdGameState::set_players_from_dicts Rust bridge accepts. Comment confirms: "Units and cities are intentionally excluded — they flow through their own bridges."
  • src/game/engine/tests/unit/test_save_manager.gd:91,95,260,275,296,331,392,402,414,423 — 10+ assertions assume save round-trip reconstructs PlayerScript instances with the full save-shape (e.g. GameState.players[0].gold == 125, var r: Player = GameState.players[0] as Player).
  • src/game/engine/src/map/game_map.gd:139-167GameMap.to_dict() / GameMap.from_dict() is the tile-grid serialiser; round-trips through TileScript.to_dict().
  • src/game/engine/src/entities/building.gd:23-40Building.to_dict() / Building.from_dict() is the canonical NPC building serialiser.

If PlayerScript / GameMapScript / BuildingScript become thin views over _gd_state per spec § 2 (option c), then Player.serialize() either (a) reads through the view back to _gd_state — but _gd_state does not store the save-only fields (golden_age_progress, happiness_breakdown, traded_luxuries, growth_tier), so the round-trip loses data, OR (b) the save-only fields remain in the view shim — at which point the view is not actually a view, it is a hybrid object with its own state, defeating the migration's premise.

Conclusion: save migration is a prerequisite, not a follow-up. Spec § ## Risks line 133 ("Save/load format may need updating") understated this — it is not a risk to mitigate during the migration, it is a blocker that must land first.

Secondary scope flag — proof/test seeding pattern

Spec § 2 option (c) presumes PlayerScript.new(0, "Tester", "dwarf") still works as a constructor. The 40 test/proof files all use this pattern:

var p: PlayerScript = PlayerScript.new(0, "Tester", "dwarf")
GameState.players.append(p)

If PlayerScript becomes a view over _gd_state, then PlayerScript.new(...) has no _gd_state slot to view. Either (a) the constructor implicitly creates/attaches a player to the autoload's _gd_state (couples the class to the autoload), or (b) all 40 test files must be ported to seed _gd_state first then construct the view. Option (b) is the correct architectural choice but expands Wave 3 substantially.

This is not a hard-stop on its own — it shapes the accessor surface and the test-port effort — but it must be decided before Wave 2 designs the GdGameState constructor accessors.

Was-going-to-be Wave 1 deliverable (accessor inventory)

Not produced. The save-format hard-stop fires before accessor design is useful: any accessor list designed against today's renderer shape will be invalidated by the save-format decision (e.g. if option (i) — save the GdGameState snapshot directly — then Player.serialize() becomes _gd_state.serialize_player(slot), which is itself a new #[func] to design).

Resume after p2-72a-save-format-migration lands. Re-audit at that point — the renderer file list will not change, but the test/proof seeding pattern may.


Context

p2-72 GdPlayerApi render bridge hard-stopped on a discovery: the GameState autoload is pure GDScript (players: Array[PlayerScript], layers: Array[Dictionary{GameMap}], npc_buildings: Array[BuildingScript], diplomacy, ley_anchors, wonders_built — all GDScript-shaped). It is not a wrapper over GdGameState. Every renderer in the project (hex_renderer.gd, unit_renderer.gd, city_renderer.gd, city screen, combat preview, HUD panels) reads from the GDScript autoload directly, never from a held Gd<GdGameState> handle.

Consequence: even if GdPlayerApi were refactored today to hold Gd<GdGameState>, no rendered pixel would change after apply_action_json — nothing in the rendering pipeline reads from a Gd<GdGameState>.

This objective makes GdGameState the canonical render source so the simple Option-A version of p2-72 (hold the same handle, share state) becomes implementable.

Out of scope

  • The actual GdPlayerApi refactor (that's p2-72; trivial after this).
  • Phase 13 screenshot scene (that's p2-67 Phase 13).
  • New gameplay features. This is purely an architectural migration.

Source-of-truth rails

  • Rust crate: api-gdext exposes GdGameState already. May need new #[func] accessors for fields renderers currently read off GDScript classes (e.g. unit_position(player_idx, unit_idx) -> Vector2i).
  • JSON path: none.
  • GDScript: the migration target. PlayerScript, GameMap, BuildingScript, UnitScript, CityScript either become thin views over Gd<GdGameState> (recommended — preserves the existing renderer call shapes) or are replaced entirely with Gd* shims (cleaner but more churn).

Surface

1. Hold Gd<GdGameState> on the autoload

src/game/engine/src/autoloads/game_state.gd:

var _gd_state: GdGameState = null
signal state_changed  # fires after any mutation

func initialize_game(opts: Dictionary) -> void:
    _gd_state = ClassDB.instantiate("GdGameState")
    _gd_state.init_from_opts(JSON.stringify(opts))
    state_changed.emit()

The held handle is the single source of truth. All existing GDScript fields (players, layers, npc_buildings, diplomacy, etc.) become views computed lazily from _gd_state.

2. PlayerScript/GameMap/BuildingScript → views

Three options per class:

(a) Thin view shimPlayerScript.gd keeps its current public API (get_units(), get_cities(), add_unit(...)) but each method delegates to the held _gd_state:

func get_units() -> Array[UnitScript]:
    var dicts: Array[Dictionary] = _gd_state.player_units(_slot)
    return dicts.map(func(d): return UnitScript.from_dict(d))

(b) Total replacement — Delete the GDScript class. Renderers call GameState._gd_state.player_units(slot) directly.

(c) Hybrid — Read-only methods become views (a); write-side mutators delegate to _gd_state.apply_* methods + emit state_changed.

Recommended: (c). Preserves renderer call sites; mutations go through Rust; signal-driven redraw is automatic.

3. Per-field GDExtension accessors

Renderers need:

  • unit_position(player_idx, unit_idx) -> Vector2istate.players[pi].units[ui].col,row
  • city_position(player_idx, city_idx) -> Vector2iplayers[pi].cities[ci].col,row
  • tile_biome(col, row) -> Stringgrid.get_tile(col, row).biome_label_id
  • tile_visibility(player_idx, col, row) -> int ← visible/explored/hidden tri-state (already exists via GdVision)
  • player_count() -> int, unit_count(player_idx) -> int, etc.

Audit every GameState.players[*] / GameState.layers[*] access in src/game/engine/ and add the corresponding #[func] to GdGameState.

4. Renderer migration

For each renderer file (hex_renderer.gd, unit_renderer.gd, city_renderer.gd, fog overlay, HUD panels, city screen, combat preview, tech tree, culture panel, diplomacy panel, victory/defeat overlays, encyclopedia, options screen):

  • Replace GameState.players[slot].units[idx].col etc. with GameState._gd_state.unit_position(slot, idx).x.
  • Connect to state_changed signal where ad-hoc polling was used.
  • Verify the rendered output matches the existing autoload-driven behaviour.

5. Migration test scene

src/game/engine/scenes/tests/canonical_state_proof.gd:

  • Constructs a known state via GdGameState.
  • Renders world_map.tscn against it.
  • Applies a PlayerAction::Move via GdPlayerApi (held against the same handle).
  • Re-renders.
  • Asserts the unit sprite moved in the rendered output.

This is the proof that GdPlayerApi mutations propagate to renderers.

Acceptance

  • GameState autoload holds _gd_state: GdGameState.
  • state_changed signal emitted after every mutation.
  • PlayerScript, GameMap, BuildingScript, UnitScript, CityScript are thin views over _gd_state (option c) OR removed entirely (option b).
    • BuildingScript view conversion (Wave 2 Step 2, 2026-05-12, commit 08cc82b70). building.gd holds only _state_ref + _idx; reads via _get proxy onto _gd_state.npc_building_dict(idx); mutators set_visited / convert_type route through Rust accessors and emit state_changed. Spawn paths in village_lair_placer._create_npc_building + the two proof scenes route through GameState.spawn_npc_building_gd_state.spawn_npc_building. Lair→ruin mutations in fauna._abandon_lair + ecological_event_handlers_b route through convert_type. Deserialize path drops BuildingScript.from_dict, spawns directly into the Rust mirror, then _rebuild_npc_buildings_view() repopulates the view list. GUT baseline preserved (522 passing / 36 failing both before and after — all 36 failures pre-existing and unrelated). Apricot smoke (seed=1, 30 turns) ran to natural domination victory on turn 22 with 6 NPC buildings spawned via the new routing and no Building-related errors.
    • PlayerScript view conversion.
    • GameMap view conversion.
    • UnitScript view conversion.
    • CityScript view conversion — LANDED (2026-06-08) via p2-72b Path-2 live-swap. city.gd is now a hybrid id-keyed thin view over GdGameState.presentation_cities (caches (pi,ci), re-resolves ci from stable City.id every access — survives raze index-shift + cross-player capture); per-instance GdCity + city_rust_bridge.gd + test_city_bridge.gd deleted. Render-proven: 71-turn domination autoplay, capture/raze end-to-end, EXIT_CODE=0, zero occupation SCRIPT ERROR; city_index_resolution.rs 4/4; test_p1_59_merge_end_to_end 4/10. The 2026-05-12 hard-stop analysis below is retained as provenance (superseded by p2-72b Path 2). Original record — ⚠ BLOCKED, hard-stop, sub-task aborted before code changes. The brief assumed mc_turn::PlayerState.cities: Vec<mc_city::City> and that a city_dict(pi, ci) accessor could be added matching CityScript.to_dict(). Audit shows this is false:
      • mc_turn::PlayerState.cities is Vec<mc_city::CityState> (src/simulator/crates/mc-city/src/lib.rs:110-150) — the minimal bench struct: population, food_stored, production_stored, queue: Option<Queueable>, queue_cost, queue_tier, food_yield, prod_yield, worker_expertise. That is the entire field set.
      • The full gameplay city mc_city::City lives in src/simulator/crates/mc-city/src/city.rs:234 and is explicitly decoupled — comment at lib.rs:105-108: "The full gameplay City struct (with tile ownership, culture, focus, per-building queues) lives in city.rs and is used by the Godot game via the GdCity GDExtension bridge. The two are deliberately separate so the bench harness stays decoupled from per-building queue simulation."
      • Each GDScript CityScript currently instantiates its own GdCity via ClassDB.instantiate("GdCity") (src/game/engine/src/entities/city.gd:96-105). There is no shared players[pi].cities[ci] slot of type City to view onto.
      • The brief's hard-stop rule fires: "A CityScript consumer reads a field not exposed by city_dict() → add it to the accessor OR document the gap. If genuinely missing from Rust City (new architectural axis), STOP." Every CityScript public field (city_name, position, buildings, placed_buildings, production_queue, production_progress, original_capital_owner, is_capital, turn_founded, culture_stored, focus, owned_tiles, worked_tiles, hp/max_hp, etc.) lives on mc_city::City, not on CityState. A faked city_dict that reads from GDScript-side state would defeat the view-conversion premise (same trap that fired in the Wave-1 PlayerScript audit).
      • Same wall as PlayerScript (already documented blocked above). The BuildingScript precedent does not transfer: npc_buildings: Vec<BuildingEntity> was already a complete mirror on GdGameState; no equivalent exists for cities.
      • Unblock prerequisite: a new objective analogous to p2-72a-save-format-migration"Promote PlayerState.cities from CityStateCity" (or its inverse: widen CityState to absorb the missing fields). Either direction is a multi-day port touching mc-turn, mc-economy, mc-score, mc-ai, and every existing PlayerState.cities consumer. Decision required from user; not spawned unilaterally.
      • Evidence: src/simulator/crates/mc-city/src/lib.rs:110-150 (CityState fields), src/simulator/crates/mc-city/src/city.rs:234 (City fields), src/simulator/api-gdext/src/lib.rs:3515,3640-3644,3721-3737,3836-3858 (PlayerState construction + only accessor is city_count + only mutator is set_player_cities_from_array from a Vector2i array — confirms CityState-only shape), src/game/engine/src/entities/city.gd:96-105 (per-CityScript independent GdCity instantiation).
      • 2026-05-12 update: p2-72b-promote-playerstate-cities-to-city was spawned to unblock this checkbox with a locked Option C (parallel presentation_cities slot at the Godot bridge). The Section 1 audit on p2-72b re-fired Hard Stop #1: the per-instance GdCity architecture means CityScript-readable fields outside CityState are ~30 (incl. containers + behavioural methods like process_growth/tick_building/take_damage), not the 8-field minimum Option C assumed. p2-72b status flipped openblocked pending user pick between three resolution paths (balloon CityPresentation / Vec<Vec<mc_city::City>> on GdGameState / partial unblock). See p2-72b "STATUS — 2026-05-12 (Hard Stop #1, Section 1 audit)" for full enumeration. This Stage 4 Wave 2b checkbox stays ☐ until p2-72b unblocks.
  • ☐ Every GameState.players[*] / GameState.layers[*] raw access in src/game/engine/ is replaced with a typed #[func] accessor call.
  • GdGameState exposes accessors for every field the renderers consume.
  • world_map.tscn, gameplay_arc_proof.tscn, city screen, combat preview, HUD, tech tree, culture panel, diplomacy panel all still render correctly (visual regression check via existing proof scenes).
  • canonical_state_proof.gd test scene renders a unit-move propagated through GdPlayerApi.
  • cargo check --workspace && cargo test --workspace green.
  • ☐ Existing GUT tests pass headless.
  • ☐ p2-72 unblocked.

Why this size

  • Audit + accessor enumeration: ~0.5 day.
  • New #[func] accessors on GdGameState: ~0.5-1 day (depends on accessor count).
  • PlayerScript/GameMap/BuildingScript view conversion: ~1-2 days.
  • Renderer migration (per-file): ~2-3 days across ~15 GDScript files.
  • Signal-driven redraw wiring: ~0.5 day.
  • canonical_state_proof test scene: ~0.5 day.
  • Visual regression verification: ~1 day.

Total: ~5-8 days. Milestone-scale; comparable to p2-65 or p2-68 in scope.

Unblocks

  • p2-72 — drops to ~1 hour follow-up (swap GdPlayerApi to hold the same handle).
  • p2-67 Phase 13 — screenshots become trivial once p2-72 lands.
  • Cleaner architecture overall: one rendered world, one mutating world, one state.

Risks

  • Visual regressions in panels that depend on GDScript-class behaviour (signals, computed fields, cached state) that the Gd* accessor doesn't preserve.
  • Save/load format may need updating if PlayerScript/GameMap serialisation was the canonical save shape.
  • GDScript's typed-array enforcement may friction with Array[Variant]-returning #[func]s (recall the as Array strict-cast issue from the harvester fix).

References

  • src/game/engine/src/autoloads/game_state.gd — current autoload, target of refactor.
  • src/game/engine/src/world/player.gd (PlayerScript) — to be converted.
  • src/game/engine/src/world/game_map.gd (GameMap) — to be converted.
  • src/simulator/api-gdext/src/lib.rs::GdGameState — accessor host.
  • .project/objectives/p2-72-gdplayerapi-render-bridge.md (HARD STOP section, 2026-05-11) — discovery context.

True state — 2026-06-04 gap analysis

Verified: K≈1.5/14 (one full bullet + half the per-class view bullet). This is the 58 day milestone and it is overwhelmingly not done.

  • GameState autoload holds _gd_state: GdGameStategame_state.gd:114 var _gd_state, :138 _ensure_gd_state(), :151 get_gd_state(). (Spec lists this ☐; it is now ✓ — landed via the singleton work.)
  • state_changed signal exists — game_state.gd:7 signal state_changed. (Emitted on npc_building mutations; not yet on player/unit/city mutations since those aren't routed through _gd_state — so "after every mutation" is only partially honoured.)
  • ◐ Per-class view conversion bullet:
    • BuildingScriptbuilding.gd:1-31 is now a thin _state_ref/_idx view proxying _gd_state.npc_building_dict(_idx). (Already marked ✓ in the file.)
    • PlayerScriptplayer.gd:1 class_name Player still a fat class (:45 var units, :47 var cities, :188 func serialize()), not a view.
    • GameMapgame_map.gd:1 class_name GameMap fat (:12 var width, :19 var tiles, :139 func to_dict()).
    • UnitScriptunit.gd:1 class_name Unit fat (:49 var hp, :406 func to_save_dict()).
    • CityScriptLANDED via p2-72b (2026-06-08); per-instance ClassDB.instantiate("GdCity") deleted, city.gd is now a hybrid id-keyed thin view over GdGameState.presentation_cities. Render-proven.
  • ✗ "every GameState.players[*]/layers[*] raw access replaced by typed accessor" — 18+ renderer/world files still read GDScript fields directly (verified: world_map.gd, world_map_units.gd, world_map_combat.gd, hex_overlay_renderer.gd, lair_overlay_renderer.gd, prologue_overlay_renderer.gd, world_map_city_actions.gd, world_map_arena.gd).
  • GdGameState exposes accessors for every renderer field — only npc_building + city_count(pi) exist; no city_dict, no unit_position, no player_units, no tile_biome.
  • ✗ all scenes render correctly through the handle; ✗ canonical_state_proof.gd (does not exist); ◐ cargo check --workspace green is plausible but the migration's own gate (visual regression through the handle) is unmet; ✗ p2-72 unblocked.

The file's status: done (+ the Wave-1 save-format hard-stop that flipped it blocked) is honest. No over-claim — the BuildingScript win is correctly scoped as one sub-checkbox, not the objective.

Path forward: save-format backend (p2-72a-save-format-migration Stage 3) + building entity Rust ✓ are landed, so the precursor walls are partially down. Ordered:

  1. Land p2-72b Path 2 (operator-resolved: Vec<Vec<mc_city::City>> parallel slot on GdGameState) — retires per-instance GdCity, gives city_dict(pi,ci), lets CityScript collapse to the BuildingScript view shape.
  2. Build the #[func] accessor surface on GdGameState for player/unit/map fields (player_units, unit_position, city_dict, tile_biome, counts) — audit every GameState.players[*]/layers[*] site first.
  3. Convert PlayerScript (player.gd), UnitScript (unit.gd), GameMap (game_map.gd) to thin views over the held handle (replay the BuildingScript pattern: _state_ref+_idx, _get proxy, mutators route through Rust + state_changed).
  4. Migrate the ~18 renderer/HUD/world files to the accessors; wire state_changed→redraw.
  5. Author scenes/tests/canonical_state_proof.gd; visual-regression sweep through proof scenes; GUT headless green.
  6. Then the save-format GDScript-side (Stage 3 finish) and p2-72 fall out cheaply.

Blockers: (1) p2-72b for the CityScript sub-bullet — operator resolved to Path 2 (its frontmatter blocked_by: user-decision-three-resolutions is now stale and should be cleared to []). (2) p2-72a-save-format-migration GDScript side is inter-dependent (the "ordering inversion": Stage 4 must run between save-backend ✓ and the SaveManager rewrite). PlayerScript/UnitScript/GameMap views are otherwise ready to start now — no hard external gate beyond the city slot.

Demo gate: post-demo polish. Every renderer reads the GDScript GameState today; the game plays and renders without this migration. It is architectural hygiene (one canonical render world) + the precondition for the Claude-vs-AI screenshot harness, not a demo feature. Verified non-blocking by the live renderer→GDScript read paths above.

Effort: L — 58 days as the spec estimates; the per-file renderer migration (~18 files) + 3 fat-class view conversions are the bulk.