diff --git a/RENDER.md b/RENDER.md index 65c9e26..a8be31a 100644 --- a/RENDER.md +++ b/RENDER.md @@ -919,6 +919,21 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - Defaults, quality presets, queue order, cache versions and visible rendering rules are unchanged. Asset-backed p95/p99 comparison remains required. +## 2026-07-16 Streamer Internal Access Gate + +- `verify_renderer_internal_access.gd` derives private queue, task, cache and + tile-state field names from `StreamingWorldLoader` declarations and rejects + external member/reflection access in gameplay and Godot `EditorPlugin` package sources. +- The gate follows the implementation inventory instead of duplicating a manual + list, so a newly named private renderer field is covered automatically when it + matches those ownership categories. +- Scene composition may still reference the loader script and the facade's + implementation path; those are composition details, not mutable queue access. +- Renderer diagnostic probes are outside this gameplay/editor checklist. + `probe_render_terrain_height.gd` remains a documented temporary exception until + readiness and ground-query facade contracts replace its internal inspection. +- This is a source-only boundary check and changes no renderer behavior or fidelity. + ## Practical Rule For Future Work If something improves quality but creates visible hitch, it is not done. Move it to bake/cache/background work, split finalization over frames, or prewarm it before the player can see it. diff --git a/coordination/claims/M03-RND-INTERNAL-ACCESS-GATE-001.md b/coordination/claims/M03-RND-INTERNAL-ACCESS-GATE-001.md index f1e875a..86730dd 100644 --- a/coordination/claims/M03-RND-INTERNAL-ACCESS-GATE-001.md +++ b/coordination/claims/M03-RND-INTERNAL-ACCESS-GATE-001.md @@ -30,8 +30,9 @@ queue, task, cache or tile-state fields. - Exclusive: `src/tools/verify_renderer_internal_access.gd`, this claim - Shared/hotspots: `docs/modules/world-renderer.md`, `RENDER.md`, `targets/03-renderer-facade.md` -- Read-only scan scope: `src/gameplay/**/*.gd`, Godot `EditorPlugin` scripts under - `src/**/*.gd` and `addons/**/*.gd`, and the streamer field declarations +- Read-only scan scope: `src/gameplay/**/*.gd`, `src/editor/**/*.gd`, Godot + `EditorPlugin` package scripts under `src/**/*.gd` and `addons/**/*.gd`, and + the streamer field declarations ## Contracts and data @@ -66,12 +67,36 @@ queue, task, cache or tile-state fields. - Simplest approach: derive forbidden fields from the current streamer instead of maintaining a second hand-written queue list - Rejected complexity: language plugin, custom linter framework or runtime proxy -- Unavoidable complexity: recursive source discovery distinguishes actual - `EditorPlugin` scripts from renderer/import addons +- Unavoidable complexity: recursive source discovery expands each actual + `EditorPlugin` root to its package scripts while excluding reference mirrors ## Status -- State: active -- Done: scope and exception boundary defined -- Next: implement gate, prove current consumers clean and document enforcement +- State: ready-for-review +- Done: derived 43 private renderer symbols, scanned seven gameplay and nine + EditorPlugin-package sources, added synthetic positive/negative fixtures and docs +- Next: M03 integrator reviews and merges `b58bf2c` - Blocked by: + + + +## Handoff + +- Commit: `b58bf2c` +- Results: repository gate derives private queue/task/cache/state declarations + from the streamer and rejects external member/reflection access in gameplay and + EditorPlugin package sources. Current scope passes with `43/7/9` inventory counts. +- Verification: internal access, facade and budget scheduler headless contracts; + Godot editor scan with local ignored dependencies; coordination, documentation + and diff gates passed. +- Fidelity: source-only enforcement changes no runtime configuration, queue order, + cache version, placement, material, animation or visibility behavior. No + build-12340 parity claim is added. +- External/local inputs: ignored native DLL and two generated ADT resource scripts + were copied from master only for facade/editor smoke and then removed. Proprietary + extracted world data was unavailable, producing expected degraded-data diagnostics. +- Remaining risks: renderer diagnostic terrain probe remains an explicit exception; + whitespace-obfuscated dynamic reflection is outside the lightweight source gate; + future non-EditorPlugin authoring code is already prohibited by repository policy. +- Documentation: world-renderer boundary/verification/capability/known-gap/source + map and `RENDER.md` implementation note updated; no new module was introduced. diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index 42c9ab1..a95cc57 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -68,6 +68,8 @@ Forbidden dependencies: Runtime, capture and probe callers use `WorldRenderFacade` for focus and metrics. `StreamingWorldLoader` remains the internal scene implementation while later M03 packages add environment, entity-visual and ground-query contracts. +Gameplay modules and Godot `EditorPlugin` package sources are repository-gated +from externally reading/writing loader-private queue, task, cache and tile-state fields. | Symbol | Kind | Purpose | Thread/lifetime | Errors | |---|---|---|---|---| @@ -271,6 +273,9 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m - Budget scheduler contract: exact lane exhaustion, shared chunk removal/create priority, independent lanes, frame reset, invalid limits, terminal cancellation, dependency boundaries and bounded permit timing without loading a world scene. +- Internal-access contract: derives the current streamer's private queue/task/ + cache/state field inventory and rejects external member/reflection access from + gameplay or EditorPlugin package code. - Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes, seven M00 cold/warm checkpoints and one dedicated mapped server-spawn checkpoint. - Fidelity evidence: семь локальных build 12340 reference JPG покрывают terrain/ADT/M2/WMO/liquid/sky и реальный `GryphonRoost01`; automated cold/warm metrics имеют `compared=14`, `missing=0`. Sky использует приблизительно paired camera, а animated-M2 остаётся asset-paired/placement-unpaired из-за synthetic probe. @@ -299,6 +304,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | Stable renderer facade | Partial | `M03-RND-FACADE-001` contract/runtime/tool regressions | Focus and metrics migrated; environment/entity visuals/ground query remain | | Pure streaming target planner | Implemented | `M03-RND-STREAMING-PLANNER-001` asset-free contract/performance regression | Queue scheduling and loaded-tile LOD application remain in streamer | | Bounded render budget scheduler | Implemented | `M03-RND-SCHEDULER-001` asset-free permit/cancellation/performance regression | Queue storage, worker concurrency and operation execution remain in streamer/services | +| Gameplay/editor internal-access boundary | Implemented | `M03-RND-INTERNAL-ACCESS-GATE-001` scans actual private streamer declarations against gameplay and EditorPlugin sources | Renderer diagnostic probes remain a scoped exception pending ground-query/readiness facade contracts | ## Known gaps and risks @@ -319,6 +325,9 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m - D3D12 descriptor issues remain. Explicit shutdown ownership removes the GUI capture's Node/resource/RID leaks in focused and seven-checkpoint runs; a timing-dependent ObjectDB warning for anonymous zero-reference `RefCounted` objects remains as a separate engine-teardown diagnostic. - M2/WMO/material/particle/ribbon/portal parity incomplete. - Public API is mostly exported configuration rather than stable contracts. +- `probe_render_terrain_height.gd` remains a renderer-only diagnostic exception + that reads tile readiness/terrain internals; it is excluded from the gameplay/ + EditorPlugin gate until equivalent ground-query and readiness facade contracts exist. ## Source map @@ -335,6 +344,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `src/tools/verify_world_render_facade.gd` | Facade delegation, snapshot isolation and consumer wiring regression | | `src/tools/verify_streaming_target_planner.gd` | Planner behavior, dependency and bounded timing regression | | `src/tools/verify_render_budget_scheduler.gd` | Scheduler bounds, shared-lane priority, cancellation and timing regression | +| `src/tools/verify_renderer_internal_access.gd` | Gameplay/EditorPlugin boundary gate derived from private streamer fields | | `addons/mpq_extractor/loaders/adt_builder.gd` | Terrain/material/liquid construction | | `addons/mpq_extractor/loaders/m2_builder.gd` | Static M2 mesh/material construction | | `addons/mpq_extractor/loaders/m2_native_animated_builder.gd` | Native animated M2 experiment | diff --git a/src/tools/verify_renderer_internal_access.gd b/src/tools/verify_renderer_internal_access.gd new file mode 100644 index 0000000..88b6e34 --- /dev/null +++ b/src/tools/verify_renderer_internal_access.gd @@ -0,0 +1,164 @@ +extends SceneTree + +## M03 repository gate preventing gameplay/EditorPlugin coupling to streamer internals. + +const STREAMING_WORLD_LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" +const GAMEPLAY_ROOT := "res://src/gameplay" +const SOURCE_ROOTS: Array[String] = ["res://src", "res://addons"] + + +func _initialize() -> void: + var failures: Array[String] = [] + var loader_source := _read_text(STREAMING_WORLD_LOADER_PATH, failures) + var private_renderer_symbols := _extract_private_renderer_symbols(loader_source, failures) + _verify_synthetic_detection(private_renderer_symbols, failures) + + var gameplay_paths := _collect_gdscript_paths(GAMEPLAY_ROOT) + var editor_source_paths := _discover_editor_source_paths(failures) + var consumer_paths_by_path: Dictionary = {} + for gameplay_path in gameplay_paths: + consumer_paths_by_path[gameplay_path] = true + for editor_source_path in editor_source_paths: + consumer_paths_by_path[editor_source_path] = true + + for consumer_path_variant in consumer_paths_by_path: + var consumer_path := String(consumer_path_variant) + var consumer_source := _read_text(consumer_path, failures) + for forbidden_symbol in _find_forbidden_symbols(consumer_source, private_renderer_symbols): + failures.append("%s accesses private renderer symbol %s" % [consumer_path, forbidden_symbol]) + + if not failures.is_empty(): + for failure in failures: + push_error("RENDERER_INTERNAL_ACCESS: %s" % failure) + quit(1) + return + + print("RENDERER_INTERNAL_ACCESS PASS private_symbols=%d gameplay=%d editor_sources=%d" % [ + private_renderer_symbols.size(), + gameplay_paths.size(), + editor_source_paths.size(), + ]) + quit(0) + + +func _extract_private_renderer_symbols(loader_source: String, failures: Array[String]) -> Array[String]: + var declaration_pattern := RegEx.new() + var compile_error := declaration_pattern.compile( + "(?m)^var\\s+(_[A-Za-z0-9_]*(?:queue|tasks|cache|states)[A-Za-z0-9_]*)" + ) + if compile_error != OK: + failures.append("cannot compile private renderer symbol pattern") + return [] + + var symbols_by_name: Dictionary = {} + for regex_match in declaration_pattern.search_all(loader_source): + symbols_by_name[regex_match.get_string(1)] = true + var symbols: Array[String] = [] + for symbol_variant in symbols_by_name: + symbols.append(String(symbol_variant)) + symbols.sort() + if symbols.is_empty(): + failures.append("streamer private symbol inventory is empty") + return symbols + + +func _discover_editor_source_paths(failures: Array[String]) -> Array[String]: + var all_source_paths: Array[String] = [] + for source_root in SOURCE_ROOTS: + all_source_paths.append_array(_collect_gdscript_paths(source_root)) + + var editor_roots_by_path: Dictionary = {} + var editor_source_paths_by_path: Dictionary = {} + for source_path in all_source_paths: + var source := _read_text(source_path, failures) + if source_path.contains("/editor/"): + editor_source_paths_by_path[source_path] = true + if source.contains("extends " + "EditorPlugin") or source_path.ends_with("/plugin.gd"): + editor_roots_by_path[source_path.get_base_dir()] = true + + for source_path in all_source_paths: + for editor_root_variant in editor_roots_by_path: + var editor_root := String(editor_root_variant) + if source_path.begins_with(editor_root + "/"): + editor_source_paths_by_path[source_path] = true + + var editor_source_paths: Array[String] = [] + for editor_source_path_variant in editor_source_paths_by_path: + editor_source_paths.append(String(editor_source_path_variant)) + editor_source_paths.sort() + return editor_source_paths + + +func _collect_gdscript_paths(root_path: String) -> Array[String]: + var paths: Array[String] = [] + var directory := DirAccess.open(root_path) + if directory == null: + return paths + + directory.list_dir_begin() + var entry_name := directory.get_next() + while not entry_name.is_empty(): + if entry_name.begins_with(".") or entry_name == "reference": + entry_name = directory.get_next() + continue + var entry_path := root_path.path_join(entry_name) + if directory.current_is_dir(): + paths.append_array(_collect_gdscript_paths(entry_path)) + elif entry_name.ends_with(".gd"): + paths.append(entry_path) + entry_name = directory.get_next() + directory.list_dir_end() + paths.sort() + return paths + + +func _find_forbidden_symbols(source: String, private_renderer_symbols: Array[String]) -> Array[String]: + var found_symbols: Array[String] = [] + for private_renderer_symbol in private_renderer_symbols: + if _contains_external_symbol_access(source, private_renderer_symbol): + found_symbols.append(private_renderer_symbol) + return found_symbols + + +func _contains_external_symbol_access(source: String, private_renderer_symbol: String) -> bool: + for access_text in [ + "." + private_renderer_symbol, + '.get("%s"' % private_renderer_symbol, + ".get('%s'" % private_renderer_symbol, + '.set("%s"' % private_renderer_symbol, + ".set('%s'" % private_renderer_symbol, + '.call("%s"' % private_renderer_symbol, + ".call('%s'" % private_renderer_symbol, + '["%s"]' % private_renderer_symbol, + "['%s']" % private_renderer_symbol, + ]: + if source.contains(access_text): + return true + return false + + +func _verify_synthetic_detection(private_renderer_symbols: Array[String], failures: Array[String]) -> void: + if not private_renderer_symbols.has("_tile_states"): + failures.append("streamer inventory does not contain _tile_states fixture") + return + var forbidden_fixture := 'var tile_states = world.get("_tile_states")' + var detected_symbols := _find_forbidden_symbols(forbidden_fixture, private_renderer_symbols) + if detected_symbols != ["_tile_states"]: + failures.append("synthetic private access was not detected exactly") + var direct_member_fixture := "var tile_states = world._tile_states" + if _find_forbidden_symbols(direct_member_fixture, private_renderer_symbols) != ["_tile_states"]: + failures.append("synthetic direct member access was not detected exactly") + var unrelated_owner_fixture := "var _tile_states: Dictionary = {}" + if not _find_forbidden_symbols(unrelated_owner_fixture, private_renderer_symbols).is_empty(): + failures.append("unrelated private declaration was rejected") + var safe_fixture := "var metrics = render_facade.renderer_metrics_snapshot()" + if not _find_forbidden_symbols(safe_fixture, private_renderer_symbols).is_empty(): + failures.append("facade metrics fixture was rejected") + + +func _read_text(path: String, failures: Array[String]) -> String: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + failures.append("cannot open %s" % path) + return "" + return file.get_as_text() diff --git a/src/tools/verify_renderer_internal_access.gd.uid b/src/tools/verify_renderer_internal_access.gd.uid new file mode 100644 index 0000000..84db69a --- /dev/null +++ b/src/tools/verify_renderer_internal_access.gd.uid @@ -0,0 +1 @@ +uid://y11r5k0ve0md