test(M03): enforce streamer internal access boundary
This commit is contained in:
@@ -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
|
- Defaults, quality presets, queue order, cache versions and visible rendering
|
||||||
rules are unchanged. Asset-backed p95/p99 comparison remains required.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ queue, task, cache or tile-state fields.
|
|||||||
- Exclusive: `src/tools/verify_renderer_internal_access.gd`, this claim
|
- Exclusive: `src/tools/verify_renderer_internal_access.gd`, this claim
|
||||||
- Shared/hotspots: `docs/modules/world-renderer.md`, `RENDER.md`,
|
- Shared/hotspots: `docs/modules/world-renderer.md`, `RENDER.md`,
|
||||||
`targets/03-renderer-facade.md`
|
`targets/03-renderer-facade.md`
|
||||||
- Read-only scan scope: `src/gameplay/**/*.gd`, Godot `EditorPlugin` scripts under
|
- Read-only scan scope: `src/gameplay/**/*.gd`, `src/editor/**/*.gd`, Godot
|
||||||
`src/**/*.gd` and `addons/**/*.gd`, and the streamer field declarations
|
`EditorPlugin` package scripts under `src/**/*.gd` and `addons/**/*.gd`, and
|
||||||
|
the streamer field declarations
|
||||||
|
|
||||||
## Contracts and data
|
## Contracts and data
|
||||||
|
|
||||||
@@ -66,8 +67,8 @@ queue, task, cache or tile-state fields.
|
|||||||
- Simplest approach: derive forbidden fields from the current streamer instead
|
- Simplest approach: derive forbidden fields from the current streamer instead
|
||||||
of maintaining a second hand-written queue list
|
of maintaining a second hand-written queue list
|
||||||
- Rejected complexity: language plugin, custom linter framework or runtime proxy
|
- Rejected complexity: language plugin, custom linter framework or runtime proxy
|
||||||
- Unavoidable complexity: recursive source discovery distinguishes actual
|
- Unavoidable complexity: recursive source discovery expands each actual
|
||||||
`EditorPlugin` scripts from renderer/import addons
|
`EditorPlugin` root to its package scripts while excluding reference mirrors
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ Forbidden dependencies:
|
|||||||
Runtime, capture and probe callers use `WorldRenderFacade` for focus and metrics.
|
Runtime, capture and probe callers use `WorldRenderFacade` for focus and metrics.
|
||||||
`StreamingWorldLoader` remains the internal scene implementation while later M03
|
`StreamingWorldLoader` remains the internal scene implementation while later M03
|
||||||
packages add environment, entity-visual and ground-query contracts.
|
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 |
|
| 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
|
- Budget scheduler contract: exact lane exhaustion, shared chunk removal/create
|
||||||
priority, independent lanes, frame reset, invalid limits, terminal cancellation,
|
priority, independent lanes, frame reset, invalid limits, terminal cancellation,
|
||||||
dependency boundaries and bounded permit timing without loading a world scene.
|
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
|
- Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes, seven M00
|
||||||
cold/warm checkpoints and one dedicated mapped server-spawn checkpoint.
|
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.
|
- 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 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.
|
- 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.
|
- M2/WMO/material/particle/ribbon/portal parity incomplete.
|
||||||
- Public API is mostly exported configuration rather than stable contracts.
|
- 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
|
## 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_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_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_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/adt_builder.gd` | Terrain/material/liquid construction |
|
||||||
| `addons/mpq_extractor/loaders/m2_builder.gd` | Static M2 mesh/material 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 |
|
| `addons/mpq_extractor/loaders/m2_native_animated_builder.gd` | Native animated M2 experiment |
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://y11r5k0ve0md
|
||||||
Reference in New Issue
Block a user